feat(clip_video): 支持亚秒级视频裁剪

- 增加对带毫秒时间戳的支持
- 优化时间戳解析和格式化逻辑
- 调整FFmpeg命令以支持毫秒级精度
- 更新文件命名规则以适应新时间戳格式
- 修复相关服务和接口以确保兼容性
This commit is contained in:
linyq 2025-05-07 20:35:47 +08:00
parent bc732c10fd
commit bacc1adfad
6 changed files with 161 additions and 115 deletions

View File

@ -368,7 +368,7 @@ class VideoClipParams(BaseModel):
tts_volume: Optional[float] = Field(default=1.0, description="解说语音音量(后处理)")
original_volume: Optional[float] = Field(default=1.0, description="视频原声音量")
bgm_volume: Optional[float] = Field(default=0.6, description="背景音乐音量")
bgm_volume: Optional[float] = Field(default=0.3, description="背景音乐音量")
class VideoTranscriptionRequest(BaseModel):

View File

@ -22,10 +22,10 @@ def parse_timestamp(timestamp: str) -> tuple:
解析时间戳字符串返回开始和结束时间
Args:
timestamp: 格式为'HH:MM:SS-HH:MM:SS'的时间戳字符串
timestamp: 格式为'HH:MM:SS-HH:MM:SS''HH:MM:SS,sss-HH:MM:SS,sss'的时间戳字符串
Returns:
tuple: (开始时间, 结束时间) 格式为'HH:MM:SS'
tuple: (开始时间, 结束时间) 格式为'HH:MM:SS''HH:MM:SS,sss'
"""
start_time, end_time = timestamp.split('-')
return start_time, end_time
@ -36,21 +36,40 @@ def calculate_end_time(start_time: str, duration: float, extra_seconds: float =
根据开始时间和持续时间计算结束时间
Args:
start_time: 开始时间格式为'HH:MM:SS'
start_time: 开始时间格式为'HH:MM:SS''HH:MM:SS,sss'(带毫秒)
duration: 持续时间单位为秒
extra_seconds: 额外添加的秒数默认为1秒
Returns:
str: 计算后的结束时间格式'HH:MM:SS'
str: 计算后的结束时间格式与输入格式相同
"""
h, m, s = map(int, start_time.split(':'))
total_seconds = h * 3600 + m * 60 + s + duration + extra_seconds
# 检查是否包含毫秒
has_milliseconds = ',' in start_time
milliseconds = 0
if has_milliseconds:
time_part, ms_part = start_time.split(',')
h, m, s = map(int, time_part.split(':'))
milliseconds = int(ms_part)
else:
h, m, s = map(int, start_time.split(':'))
# 转换为总毫秒数
total_milliseconds = ((h * 3600 + m * 60 + s) * 1000 + milliseconds +
int((duration + extra_seconds) * 1000))
# 计算新的时、分、秒、毫秒
ms_new = total_milliseconds % 1000
total_seconds = total_milliseconds // 1000
h_new = int(total_seconds // 3600)
m_new = int((total_seconds % 3600) // 60)
s_new = int(total_seconds % 60)
return f"{h_new:02d}:{m_new:02d}:{s_new:02d}"
# 返回与输入格式一致的时间字符串
if has_milliseconds:
return f"{h_new:02d}:{m_new:02d}:{s_new:02d},{ms_new:03d}"
else:
return f"{h_new:02d}:{m_new:02d}:{s_new:02d}"
def check_hardware_acceleration() -> Optional[str]:
@ -144,24 +163,30 @@ def clip_video(
result = {}
for item in tts_result:
_id = item["_id"]
_id = item.get("_id", item.get("timestamp", "unknown"))
timestamp = item["timestamp"]
start_time, _ = parse_timestamp(timestamp)
# 根据持续时间计算真正的结束时间加上1秒余量
duration = item["duration"]
calculated_end_time = calculate_end_time(start_time, duration)
# 转换为FFmpeg兼容的时间格式逗号替换为点
ffmpeg_start_time = start_time.replace(',', '.')
ffmpeg_end_time = calculated_end_time.replace(',', '.')
# 格式化输出文件名
output_filename = f"vid-{start_time.replace(':', '-')}-{calculated_end_time.replace(':', '-')}.mp4"
# 格式化输出文件名(使用连字符替代冒号和逗号)
safe_start_time = start_time.replace(':', '-').replace(',', '-')
safe_end_time = calculated_end_time.replace(':', '-').replace(',', '-')
output_filename = f"vid_{safe_start_time}@{safe_end_time}.mp4"
output_path = os.path.join(output_dir, output_filename)
# 构建FFmpeg命令
ffmpeg_cmd = [
"ffmpeg", "-y", *hwaccel_args,
"-i", video_origin_path,
"-ss", start_time,
"-to", calculated_end_time,
"-ss", ffmpeg_start_time,
"-to", ffmpeg_end_time,
"-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264",
"-c:a", "aac",
"-strict", "experimental",
@ -170,7 +195,7 @@ def clip_video(
# 执行FFmpeg命令
try:
logger.info(f"裁剪视频片段: {timestamp} -> {start_time}{calculated_end_time}")
logger.info(f"裁剪视频片段: {timestamp} -> {ffmpeg_start_time}{ffmpeg_end_time}")
# logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}")
process = subprocess.run(

View File

@ -377,20 +377,18 @@ def validate_params(video_path, audio_path, output_file, params):
if __name__ == "__main__":
task_id = "qyn2-2-demo"
task_id = "demo"
# 提前裁剪是为了方便检查视频
subclip_path_videos = {
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-00-00-00-01-15.mp4',
2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-01-15-00-04-40.mp4',
3: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-41-00-04-58.mp4',
4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-58-00-05-45.mp4',
5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-05-45-00-06-00.mp4',
6: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-06-00-00-06-03.mp4',
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-00-00_000-00-00-27_000.mp4',
2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-00-30_000-00-00-57_000.mp4',
3: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-01-00_000-00-01-27_000.mp4',
4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-01-30_000-00-01-57_000.mp4',
}
params = VideoClipParams(
video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/demo.json",
video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/qyn2-2无片头片尾.mp4",
video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/2025-0507-185159.json",
video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/test.mp4",
)
start_subclip(task_id, params, subclip_path_videos)

View File

@ -21,16 +21,25 @@ def extract_timestamp_from_video_path(video_path: str) -> str:
video_path: 视频文件路径
Returns:
提取出的时间戳格式为 'HH:MM:SS-HH:MM:SS'
提取出的时间戳格式为 'HH:MM:SS-HH:MM:SS' 'HH:MM:SS,sss-HH:MM:SS,sss'
"""
# 使用正则表达式从文件名中提取时间戳
filename = os.path.basename(video_path)
match = re.search(r'vid-(\d{2}-\d{2}-\d{2})-(\d{2}-\d{2}-\d{2})\.mp4', filename)
if match:
# 匹配新格式: vid_00-00-00-000@00-00-20-250.mp4
match_new = re.search(r'vid_(\d{2})-(\d{2})-(\d{2})-(\d{3})@(\d{2})-(\d{2})-(\d{2})-(\d{3})\.mp4', filename)
if match_new:
# 提取并格式化时间戳(包含毫秒)
start_h, start_m, start_s, start_ms = match_new.group(1), match_new.group(2), match_new.group(3), match_new.group(4)
end_h, end_m, end_s, end_ms = match_new.group(5), match_new.group(6), match_new.group(7), match_new.group(8)
return f"{start_h}:{start_m}:{start_s},{start_ms}-{end_h}:{end_m}:{end_s},{end_ms}"
# 匹配旧格式: vid-00-00-00-00-00-00.mp4
match_old = re.search(r'vid-(\d{2}-\d{2}-\d{2})-(\d{2}-\d{2}-\d{2})\.mp4', filename)
if match_old:
# 提取并格式化时间戳
start_time = match.group(1).replace('-', ':')
end_time = match.group(2).replace('-', ':')
start_time = match_old.group(1).replace('-', ':')
end_time = match_old.group(2).replace('-', ':')
return f"{start_time}-{end_time}"
return ""
@ -41,7 +50,7 @@ def calculate_duration(timestamp: str) -> float:
计算时间戳范围的持续时间
Args:
timestamp: 格式为 'HH:MM:SS-HH:MM:SS' 的时间戳
timestamp: 格式为 'HH:MM:SS-HH:MM:SS' 'HH:MM:SS,sss-HH:MM:SS,sss' 的时间戳
Returns:
持续时间
@ -49,13 +58,28 @@ def calculate_duration(timestamp: str) -> float:
try:
start_time, end_time = timestamp.split('-')
# 解析时间
start_h, start_m, start_s = map(int, start_time.split(':'))
end_h, end_m, end_s = map(int, end_time.split(':'))
# 处理毫秒部分
if ',' in start_time:
start_parts = start_time.split(',')
start_time_parts = start_parts[0].split(':')
start_ms = float('0.' + start_parts[1]) if len(start_parts) > 1 else 0
start_h, start_m, start_s = map(int, start_time_parts)
else:
start_h, start_m, start_s = map(int, start_time.split(':'))
start_ms = 0
if ',' in end_time:
end_parts = end_time.split(',')
end_time_parts = end_parts[0].split(':')
end_ms = float('0.' + end_parts[1]) if len(end_parts) > 1 else 0
end_h, end_m, end_s = map(int, end_time_parts)
else:
end_h, end_m, end_s = map(int, end_time.split(':'))
end_ms = 0
# 转换为秒
start_seconds = start_h * 3600 + start_m * 60 + start_s
end_seconds = end_h * 3600 + end_m * 60 + end_s
start_seconds = start_h * 3600 + start_m * 60 + start_s + start_ms
end_seconds = end_h * 3600 + end_m * 60 + end_s + end_ms
# 计算时间差(秒)
return round(end_seconds - start_seconds, 2)
@ -177,51 +201,51 @@ if __name__ == '__main__':
list_script = [
{
'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸范闲在北齐""了?这怎么可能!',
'timestamp': '00:00:00-00:01:15',
'timestamp': '00:00:00,001-00:01:15,001',
'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸范闲在北齐""了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!',
'OST': 0,
'_id': 1
},
{
'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!',
'timestamp': '00:01:15-00:04:40',
'timestamp': '00:01:15,001-00:04:40,001',
'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…',
'OST': 0,
'_id': 2
},
{
'picture': '画面切到王启年小心翼翼地向范闲汇报。',
'timestamp': '00:04:41-00:04:58',
'timestamp': '00:04:41,001-00:04:58,001',
'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪',
'OST': 1,
'_id': 3
},
{
'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。',
'timestamp': '00:04:58-00:05:45',
'timestamp': '00:04:58,001-00:05:45,001',
'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!',
'OST': 0,
'_id': 4
},
{
'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
'timestamp': '00:05:45-00:06:00',
'timestamp': '00:05:45,001-00:06:00,001',
'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
'OST': 0,
'_id': 5
},
{
'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。',
'timestamp': '00:06:00-00:06:03',
'timestamp': '00:06:00,001-00:06:03,001',
'narration': '抓刺客',
'OST': 1,
'_id': 6
}]
video_res = {
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-00-00-00-00-26.mp4',
2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-01-15-00-01-29.mp4',
4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-04-58-00-05-20.mp4',
5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-05-45-00-05-53.mp4'}
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-00-00-000@00-00-20-250.mp4',
2: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-00-30-000@00-00-48-950.mp4',
4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-01-00-000@00-01-15-688.mp4',
5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-01-30-000@00-01-49-512.mp4'}
audio_res = {
1: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3',
2: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_01_15-00_04_40.mp3',

View File

@ -6,6 +6,7 @@ streamlit~=1.45.0
watchdog==6.0.0
loguru~=0.7.3
tomli~=2.2.1
pydub==0.25.1
openai~=1.77.0
google-generativeai>=0.8.5

134
webui.py
View File

@ -120,77 +120,75 @@ def tr(key):
def render_generate_button():
"""渲染生成按钮和处理逻辑"""
if st.button(tr("Generate Video"), use_container_width=True, type="primary"):
from app.services import task as tm
# 重置日志容器和记录
log_container = st.empty()
log_records = []
def log_received(msg):
with log_container:
log_records.append(msg)
st.code("\n".join(log_records))
from loguru import logger
logger.add(log_received)
config.save_config()
task_id = st.session_state.get('task_id')
if not task_id:
st.error(tr("请先裁剪视频"))
return
if not st.session_state.get('video_clip_json_path'):
st.error(tr("脚本文件不能为空"))
return
if not st.session_state.get('video_origin_path'):
st.error(tr("视频文件不能为空"))
return
st.toast(tr("生成视频"))
logger.info(tr("开始生成视频"))
# 获取所有参数
script_params = script_settings.get_script_params()
video_params = video_settings.get_video_params()
audio_params = audio_settings.get_audio_params()
subtitle_params = subtitle_settings.get_subtitle_params()
# 合并所有参数
all_params = {
**script_params,
**video_params,
**audio_params,
**subtitle_params
}
# 创建参数对象
params = VideoClipParams(**all_params)
result = tm.start_subclip(
task_id=task_id,
params=params,
subclip_path_videos=st.session_state['subclip_videos']
)
video_files = result.get("videos", [])
st.success(tr("视生成完成"))
try:
from app.services import task as tm
import torch
if video_files:
player_cols = st.columns(len(video_files) * 2 + 1)
for i, url in enumerate(video_files):
player_cols[i * 2 + 1].video(url)
except Exception as e:
logger.error(f"播放视频失败: {e}")
# 重置日志容器和记录
log_container = st.empty()
log_records = []
file_utils.open_task_folder(config.root_dir, task_id)
logger.info(tr("视频生成完成"))
def log_received(msg):
with log_container:
log_records.append(msg)
st.code("\n".join(log_records))
from loguru import logger
logger.add(log_received)
config.save_config()
task_id = st.session_state.get('task_id')
if not task_id:
st.error(tr("请先裁剪视频"))
return
if not st.session_state.get('video_clip_json_path'):
st.error(tr("脚本文件不能为空"))
return
if not st.session_state.get('video_origin_path'):
st.error(tr("视频文件不能为空"))
return
st.toast(tr("生成视频"))
logger.info(tr("开始生成视频"))
# 获取所有参数
script_params = script_settings.get_script_params()
video_params = video_settings.get_video_params()
audio_params = audio_settings.get_audio_params()
subtitle_params = subtitle_settings.get_subtitle_params()
# 合并所有参数
all_params = {
**script_params,
**video_params,
**audio_params,
**subtitle_params
}
# 创建参数对象
params = VideoClipParams(**all_params)
result = tm.start_subclip(
task_id=task_id,
params=params,
subclip_path_videos=st.session_state['subclip_videos']
)
video_files = result.get("videos", [])
st.success(tr("视生成完成"))
try:
if video_files:
player_cols = st.columns(len(video_files) * 2 + 1)
for i, url in enumerate(video_files):
player_cols[i * 2 + 1].video(url)
except Exception as e:
logger.error(f"播放视频失败: {e}")
file_utils.open_task_folder(config.root_dir, task_id)
logger.info(tr("视频生成完成"))
finally:
PerformanceMonitor.cleanup_resources()
# finally:
# PerformanceMonitor.cleanup_resources()
def main():