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="解说语音音量(后处理)") tts_volume: Optional[float] = Field(default=1.0, description="解说语音音量(后处理)")
original_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): class VideoTranscriptionRequest(BaseModel):

View File

@ -22,10 +22,10 @@ def parse_timestamp(timestamp: str) -> tuple:
解析时间戳字符串返回开始和结束时间 解析时间戳字符串返回开始和结束时间
Args: Args:
timestamp: 格式为'HH:MM:SS-HH:MM:SS'的时间戳字符串 timestamp: 格式为'HH:MM:SS-HH:MM:SS''HH:MM:SS,sss-HH:MM:SS,sss'的时间戳字符串
Returns: Returns:
tuple: (开始时间, 结束时间) 格式为'HH:MM:SS' tuple: (开始时间, 结束时间) 格式为'HH:MM:SS''HH:MM:SS,sss'
""" """
start_time, end_time = timestamp.split('-') start_time, end_time = timestamp.split('-')
return start_time, end_time return start_time, end_time
@ -36,20 +36,39 @@ def calculate_end_time(start_time: str, duration: float, extra_seconds: float =
根据开始时间和持续时间计算结束时间 根据开始时间和持续时间计算结束时间
Args: Args:
start_time: 开始时间格式为'HH:MM:SS' start_time: 开始时间格式为'HH:MM:SS''HH:MM:SS,sss'(带毫秒)
duration: 持续时间单位为秒 duration: 持续时间单位为秒
extra_seconds: 额外添加的秒数默认为1秒 extra_seconds: 额外添加的秒数默认为1秒
Returns: 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) h_new = int(total_seconds // 3600)
m_new = int((total_seconds % 3600) // 60) m_new = int((total_seconds % 3600) // 60)
s_new = int(total_seconds % 60) s_new = int(total_seconds % 60)
# 返回与输入格式一致的时间字符串
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}" return f"{h_new:02d}:{m_new:02d}:{s_new:02d}"
@ -144,7 +163,7 @@ def clip_video(
result = {} result = {}
for item in tts_result: for item in tts_result:
_id = item["_id"] _id = item.get("_id", item.get("timestamp", "unknown"))
timestamp = item["timestamp"] timestamp = item["timestamp"]
start_time, _ = parse_timestamp(timestamp) start_time, _ = parse_timestamp(timestamp)
@ -152,16 +171,22 @@ def clip_video(
duration = item["duration"] duration = item["duration"]
calculated_end_time = calculate_end_time(start_time, duration) calculated_end_time = calculate_end_time(start_time, duration)
# 格式化输出文件名 # 转换为FFmpeg兼容的时间格式逗号替换为点
output_filename = f"vid-{start_time.replace(':', '-')}-{calculated_end_time.replace(':', '-')}.mp4" ffmpeg_start_time = start_time.replace(',', '.')
ffmpeg_end_time = calculated_end_time.replace(',', '.')
# 格式化输出文件名(使用连字符替代冒号和逗号)
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) output_path = os.path.join(output_dir, output_filename)
# 构建FFmpeg命令 # 构建FFmpeg命令
ffmpeg_cmd = [ ffmpeg_cmd = [
"ffmpeg", "-y", *hwaccel_args, "ffmpeg", "-y", *hwaccel_args,
"-i", video_origin_path, "-i", video_origin_path,
"-ss", start_time, "-ss", ffmpeg_start_time,
"-to", calculated_end_time, "-to", ffmpeg_end_time,
"-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264", "-c:v", "h264_videotoolbox" if hwaccel == "videotoolbox" else "libx264",
"-c:a", "aac", "-c:a", "aac",
"-strict", "experimental", "-strict", "experimental",
@ -170,7 +195,7 @@ def clip_video(
# 执行FFmpeg命令 # 执行FFmpeg命令
try: 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)}") # logger.debug(f"执行命令: {' '.join(ffmpeg_cmd)}")
process = subprocess.run( process = subprocess.run(

View File

@ -377,20 +377,18 @@ def validate_params(video_path, audio_path, output_file, params):
if __name__ == "__main__": if __name__ == "__main__":
task_id = "qyn2-2-demo" task_id = "demo"
# 提前裁剪是为了方便检查视频 # 提前裁剪是为了方便检查视频
subclip_path_videos = { subclip_path_videos = {
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/6e7e343c7592c7d6f9a9636b55000f23/vid-00-00-00-00-01-15.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/6e7e343c7592c7d6f9a9636b55000f23/vid-00-01-15-00-04-40.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/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-41-00-04-58.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/6e7e343c7592c7d6f9a9636b55000f23/vid-00-04-58-00-05-45.mp4', 4: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/588c37cba4e2e62714c24c4c9054fc51/vid-00-01-30_000-00-01-57_000.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',
} }
params = VideoClipParams( params = VideoClipParams(
video_clip_json_path="/Users/apple/Desktop/home/NarratoAI/resource/scripts/demo.json", 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/qyn2-2无片头片尾.mp4", video_origin_path="/Users/apple/Desktop/home/NarratoAI/resource/videos/test.mp4",
) )
start_subclip(task_id, params, subclip_path_videos) 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: 视频文件路径 video_path: 视频文件路径
Returns: 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) 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('-', ':') start_time = match_old.group(1).replace('-', ':')
end_time = match.group(2).replace('-', ':') end_time = match_old.group(2).replace('-', ':')
return f"{start_time}-{end_time}" return f"{start_time}-{end_time}"
return "" return ""
@ -41,7 +50,7 @@ def calculate_duration(timestamp: str) -> float:
计算时间戳范围的持续时间 计算时间戳范围的持续时间
Args: Args:
timestamp: 格式为 'HH:MM:SS-HH:MM:SS' 的时间戳 timestamp: 格式为 'HH:MM:SS-HH:MM:SS' 'HH:MM:SS,sss-HH:MM:SS,sss' 的时间戳
Returns: Returns:
持续时间 持续时间
@ -49,13 +58,28 @@ def calculate_duration(timestamp: str) -> float:
try: try:
start_time, end_time = timestamp.split('-') start_time, end_time = timestamp.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_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_h, end_m, end_s = map(int, end_time.split(':'))
end_ms = 0
# 转换为秒 # 转换为秒
start_seconds = start_h * 3600 + start_m * 60 + start_s start_seconds = start_h * 3600 + start_m * 60 + start_s + start_ms
end_seconds = end_h * 3600 + end_m * 60 + end_s end_seconds = end_h * 3600 + end_m * 60 + end_s + end_ms
# 计算时间差(秒) # 计算时间差(秒)
return round(end_seconds - start_seconds, 2) return round(end_seconds - start_seconds, 2)
@ -177,51 +201,51 @@ if __name__ == '__main__':
list_script = [ list_script = [
{ {
'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸范闲在北齐""了?这怎么可能!', 'picture': '【解说】好的,各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸范闲在北齐""了?这怎么可能!',
'timestamp': '00:00:00-00:01:15', 'timestamp': '00:00:00,001-00:01:15,001',
'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸范闲在北齐""了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!', 'narration': '好的各位,欢迎回到我的频道!《庆余年 2》刚开播就给了我们一个王炸范闲在北齐""了?这怎么可能!上集片尾那个巨大的悬念,这一集就立刻揭晓了!范闲假死归来,他面临的第一个,也是最大的难关,就是如何面对他最敬爱的,同时也是最可怕的那个人——庆帝!',
'OST': 0, 'OST': 0,
'_id': 1 '_id': 1
}, },
{ {
'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!', 'picture': '【解说】上一集我们看到,范闲在北齐遭遇了惊天变故,生死不明!',
'timestamp': '00:01:15-00:04:40', 'timestamp': '00:01:15,001-00:04:40,001',
'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…', 'narration': '但我们都知道,他绝不可能就这么轻易退场!第二集一开场,范闲就已经秘密回到了京都。他的生死传闻,可不像我们想象中那样只是小范围流传,而是…',
'OST': 0, 'OST': 0,
'_id': 2 '_id': 2
}, },
{ {
'picture': '画面切到王启年小心翼翼地向范闲汇报。', 'picture': '画面切到王启年小心翼翼地向范闲汇报。',
'timestamp': '00:04:41-00:04:58', 'timestamp': '00:04:41,001-00:04:58,001',
'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪', 'narration': '我发现大人的死讯不光是在民间,在官场上也它传开了,所以呢,所以啊,可不是什么好事,将来您跟陛下怎么交代,这可是欺君之罪',
'OST': 1, 'OST': 1,
'_id': 3 '_id': 3
}, },
{ {
'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。', 'picture': '【解说】"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。',
'timestamp': '00:04:58-00:05:45', 'timestamp': '00:04:58,001-00:05:45,001',
'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!', 'narration': '"欺君之罪"!在封建王朝,这可是抄家灭族的大罪!搁一般人,肯定脚底抹油溜之大吉了。但范闲是谁啊?他偏要反其道而行之!他竟然决定,直接去见庆帝!冒着天大的风险,用"假死"这个事实去赌庆帝的态度!',
'OST': 0, 'OST': 0,
'_id': 4 '_id': 4
}, },
{ {
'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!', 'picture': '【解说】但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
'timestamp': '00:05:45-00:06:00', 'timestamp': '00:05:45,001-00:06:00,001',
'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!', 'narration': '但想见庆帝,哪有那么容易?范闲艺高人胆大,竟然选择了最激进的方式——闯宫!',
'OST': 0, 'OST': 0,
'_id': 5 '_id': 5
}, },
{ {
'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。', 'picture': '画面切换到范闲蒙面闯入皇宫,被侍卫包围的场景。',
'timestamp': '00:06:00-00:06:03', 'timestamp': '00:06:00,001-00:06:03,001',
'narration': '抓刺客', 'narration': '抓刺客',
'OST': 1, 'OST': 1,
'_id': 6 '_id': 6
}] }]
video_res = { video_res = {
1: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-00-00-00-00-26.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/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-01-15-00-01-29.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/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-04-58-00-05-20.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/2c0c2ae91b4f58596634a0b2f64d3eb0/vid-00-05-45-00-05-53.mp4'} 5: '/Users/apple/Desktop/home/NarratoAI/storage/temp/clip_video/fc3db5844d1ba7d7d838be52c0dac1bd/vid_00-01-30-000@00-01-49-512.mp4'}
audio_res = { audio_res = {
1: '/Users/apple/Desktop/home/NarratoAI/storage/tasks/qyn2-2-demo/audio_00_00_00-00_01_15.mp3', 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', 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 watchdog==6.0.0
loguru~=0.7.3 loguru~=0.7.3
tomli~=2.2.1 tomli~=2.2.1
pydub==0.25.1
openai~=1.77.0 openai~=1.77.0
google-generativeai>=0.8.5 google-generativeai>=0.8.5

View File

@ -120,9 +120,7 @@ def tr(key):
def render_generate_button(): def render_generate_button():
"""渲染生成按钮和处理逻辑""" """渲染生成按钮和处理逻辑"""
if st.button(tr("Generate Video"), use_container_width=True, type="primary"): if st.button(tr("Generate Video"), use_container_width=True, type="primary"):
try:
from app.services import task as tm from app.services import task as tm
import torch
# 重置日志容器和记录 # 重置日志容器和记录
log_container = st.empty() log_container = st.empty()
@ -189,8 +187,8 @@ def render_generate_button():
file_utils.open_task_folder(config.root_dir, task_id) file_utils.open_task_folder(config.root_dir, task_id)
logger.info(tr("视频生成完成")) logger.info(tr("视频生成完成"))
finally: # finally:
PerformanceMonitor.cleanup_resources() # PerformanceMonitor.cleanup_resources()
def main(): def main():