feat(video): 添加源视频时长检测和帧冻结补足功能

- 探测源视频实际时长并计算变速后的有效时长
- 检测源视频时长不足的情况并记录警告日志
- 计算时长短缺并自动冻结最后一帧进行补足
- 更新 FFmpeg 命令构建逻辑以支持时长补足
- 合并转场 overlap 冻结和时长不足冻结的处理
- 添加必要的参数传递以支持时长检测功能
This commit is contained in:
2026-02-04 17:59:46 +08:00
parent eeb21cada3
commit c57524f174

View File

@@ -135,13 +135,36 @@ class RenderSegmentVideoHandler(BaseHandler):
logger.warning(f"[task:{task.task_id}] Failed to download overlay, continuing without it") logger.warning(f"[task:{task.task_id}] Failed to download overlay, continuing without it")
overlay_file = None overlay_file = None
# 6. 计算 overlap 时长(用于转场帧冻结 # 6. 探测源视频时长(仅对视频素材
# 用于检测时长不足并通过冻结最后一帧补足
source_duration_sec = None
if not is_image:
source_duration = self.probe_duration(input_file)
if source_duration:
source_duration_sec = source_duration
speed = float(render_spec.speed) if render_spec.speed else 1.0
if speed > 0:
# 计算变速后的有效时长
effective_duration_sec = source_duration_sec / speed
required_duration_sec = duration_ms / 1000.0
# 如果源视频时长不足,记录日志
if effective_duration_sec < required_duration_sec:
shortage_sec = required_duration_sec - effective_duration_sec
logger.warning(
f"[task:{task.task_id}] Source video duration insufficient: "
f"effective={effective_duration_sec:.2f}s (speed={speed}), "
f"required={required_duration_sec:.2f}s, "
f"will freeze last frame for {shortage_sec:.2f}s"
)
# 7. 计算 overlap 时长(用于转场帧冻结)
# 头部 overlap: 来自前一片段的出场转场 # 头部 overlap: 来自前一片段的出场转场
overlap_head_ms = task.get_overlap_head_ms() overlap_head_ms = task.get_overlap_head_ms()
# 尾部 overlap: 当前片段的出场转场 # 尾部 overlap: 当前片段的出场转场
overlap_tail_ms = task.get_overlap_tail_ms_v2() overlap_tail_ms = task.get_overlap_tail_ms_v2()
# 7. 构建 FFmpeg 命令 # 8. 构建 FFmpeg 命令
output_file = os.path.join(work_dir, 'output.mp4') output_file = os.path.join(work_dir, 'output.mp4')
cmd = self._build_command( cmd = self._build_command(
input_file=input_file, input_file=input_file,
@@ -152,28 +175,29 @@ class RenderSegmentVideoHandler(BaseHandler):
lut_file=lut_file, lut_file=lut_file,
overlay_file=overlay_file, overlay_file=overlay_file,
overlap_head_ms=overlap_head_ms, overlap_head_ms=overlap_head_ms,
overlap_tail_ms=overlap_tail_ms overlap_tail_ms=overlap_tail_ms,
source_duration_sec=source_duration_sec
) )
# 8. 执行 FFmpeg # 9. 执行 FFmpeg
if not self.run_ffmpeg(cmd, task.task_id): if not self.run_ffmpeg(cmd, task.task_id):
return TaskResult.fail( return TaskResult.fail(
ErrorCode.E_FFMPEG_FAILED, ErrorCode.E_FFMPEG_FAILED,
"FFmpeg rendering failed" "FFmpeg rendering failed"
) )
# 9. 验证输出文件 # 10. 验证输出文件
if not self.ensure_file_exists(output_file, min_size=4096): if not self.ensure_file_exists(output_file, min_size=4096):
return TaskResult.fail( return TaskResult.fail(
ErrorCode.E_FFMPEG_FAILED, ErrorCode.E_FFMPEG_FAILED,
"Output file is missing or too small" "Output file is missing or too small"
) )
# 10. 获取实际时长 # 11. 获取实际时长
actual_duration = self.probe_duration(output_file) actual_duration = self.probe_duration(output_file)
actual_duration_ms = int(actual_duration * 1000) if actual_duration else duration_ms actual_duration_ms = int(actual_duration * 1000) if actual_duration else duration_ms
# 11. 上传产物 # 12. 上传产物
video_url = self.upload_file(task.task_id, 'video', output_file) video_url = self.upload_file(task.task_id, 'video', output_file)
if not video_url: if not video_url:
return TaskResult.fail( return TaskResult.fail(
@@ -181,7 +205,7 @@ class RenderSegmentVideoHandler(BaseHandler):
"Failed to upload video" "Failed to upload video"
) )
# 12. 构建结果(包含 overlap 信息) # 13. 构建结果(包含 overlap 信息)
result_data = { result_data = {
'videoUrl': video_url, 'videoUrl': video_url,
'actualDurationMs': actual_duration_ms, 'actualDurationMs': actual_duration_ms,
@@ -310,7 +334,8 @@ class RenderSegmentVideoHandler(BaseHandler):
lut_file: Optional[str] = None, lut_file: Optional[str] = None,
overlay_file: Optional[str] = None, overlay_file: Optional[str] = None,
overlap_head_ms: int = 0, overlap_head_ms: int = 0,
overlap_tail_ms: int = 0 overlap_tail_ms: int = 0,
source_duration_sec: Optional[float] = None
) -> List[str]: ) -> List[str]:
""" """
构建 FFmpeg 渲染命令 构建 FFmpeg 渲染命令
@@ -325,6 +350,7 @@ class RenderSegmentVideoHandler(BaseHandler):
overlay_file: 叠加层文件路径(可选) overlay_file: 叠加层文件路径(可选)
overlap_head_ms: 头部 overlap 时长(毫秒) overlap_head_ms: 头部 overlap 时长(毫秒)
overlap_tail_ms: 尾部 overlap 时长(毫秒) overlap_tail_ms: 尾部 overlap 时长(毫秒)
source_duration_sec: 源视频实际时长(秒),用于检测时长不足
Returns: Returns:
FFmpeg 命令参数列表 FFmpeg 命令参数列表
@@ -347,10 +373,12 @@ class RenderSegmentVideoHandler(BaseHandler):
filters = self._build_video_filters( filters = self._build_video_filters(
render_spec=render_spec, render_spec=render_spec,
output_spec=output_spec, output_spec=output_spec,
duration_ms=duration_ms,
lut_file=lut_file, lut_file=lut_file,
overlay_file=overlay_file, overlay_file=overlay_file,
overlap_head_ms=overlap_head_ms, overlap_head_ms=overlap_head_ms,
overlap_tail_ms=overlap_tail_ms overlap_tail_ms=overlap_tail_ms,
source_duration_sec=source_duration_sec
) )
# 应用滤镜 # 应用滤镜
@@ -403,10 +431,12 @@ class RenderSegmentVideoHandler(BaseHandler):
self, self,
render_spec: RenderSpec, render_spec: RenderSpec,
output_spec: OutputSpec, output_spec: OutputSpec,
duration_ms: int,
lut_file: Optional[str] = None, lut_file: Optional[str] = None,
overlay_file: Optional[str] = None, overlay_file: Optional[str] = None,
overlap_head_ms: int = 0, overlap_head_ms: int = 0,
overlap_tail_ms: int = 0 overlap_tail_ms: int = 0,
source_duration_sec: Optional[float] = None
) -> str: ) -> str:
""" """
构建视频滤镜链 构建视频滤镜链
@@ -414,10 +444,12 @@ class RenderSegmentVideoHandler(BaseHandler):
Args: Args:
render_spec: 渲染规格 render_spec: 渲染规格
output_spec: 输出规格 output_spec: 输出规格
duration_ms: 目标时长(毫秒)
lut_file: LUT 文件路径 lut_file: LUT 文件路径
overlay_file: 叠加层文件路径(支持图片 png/jpg 和视频 mov) overlay_file: 叠加层文件路径(支持图片 png/jpg 和视频 mov)
overlap_head_ms: 头部 overlap 时长(毫秒) overlap_head_ms: 头部 overlap 时长(毫秒)
overlap_tail_ms: 尾部 overlap 时长(毫秒) overlap_tail_ms: 尾部 overlap 时长(毫秒)
source_duration_sec: 源视频实际时长(秒),用于检测时长不足
Returns: Returns:
滤镜字符串 滤镜字符串
@@ -496,20 +528,39 @@ class RenderSegmentVideoHandler(BaseHandler):
is_video_overlay=is_video_overlay, is_video_overlay=is_video_overlay,
overlap_head_ms=overlap_head_ms, overlap_head_ms=overlap_head_ms,
overlap_tail_ms=overlap_tail_ms, overlap_tail_ms=overlap_tail_ms,
use_hwdownload=bool(hwaccel_prefix) use_hwdownload=bool(hwaccel_prefix),
duration_ms=duration_ms,
render_spec=render_spec,
source_duration_sec=source_duration_sec
) )
# 6. 帧冻结(tpad)- 用于转场 overlap 区域 # 6. 帧冻结(tpad)- 用于转场 overlap 区域和时长不足补足
# 注意:tpad 必须在缩放之后应用 # 注意:tpad 必须在缩放之后应用
tpad_parts = [] tpad_parts = []
# 计算是否需要额外的尾部冻结(源视频时长不足)
extra_tail_freeze_sec = 0.0
if source_duration_sec is not None:
speed = float(render_spec.speed) if render_spec.speed else 1.0
if speed > 0:
# 计算变速后的有效时长
effective_duration_sec = source_duration_sec / speed
required_duration_sec = duration_ms / 1000.0
# 如果源视频时长不足,需要冻结最后一帧来补足
if effective_duration_sec < required_duration_sec:
extra_tail_freeze_sec = required_duration_sec - effective_duration_sec
if overlap_head_ms > 0: if overlap_head_ms > 0:
# 头部冻结:将第一帧冻结指定时长 # 头部冻结:将第一帧冻结指定时长
head_duration_sec = overlap_head_ms / 1000.0 head_duration_sec = overlap_head_ms / 1000.0
tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}") tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}")
if overlap_tail_ms > 0:
# 尾部冻结:将最后一帧冻结指定时长 # 尾部冻结:合并 overlap 和时长不足的冻结
tail_duration_sec = overlap_tail_ms / 1000.0 total_tail_freeze_sec = (overlap_tail_ms / 1000.0) + extra_tail_freeze_sec
tpad_parts.append(f"stop_mode=clone:stop_duration={tail_duration_sec}") if total_tail_freeze_sec > 0:
# 将最后一帧冻结指定时长
tpad_parts.append(f"stop_mode=clone:stop_duration={total_tail_freeze_sec}")
if tpad_parts: if tpad_parts:
filters.append(f"tpad={':'.join(tpad_parts)}") filters.append(f"tpad={':'.join(tpad_parts)}")
@@ -539,7 +590,10 @@ class RenderSegmentVideoHandler(BaseHandler):
is_video_overlay: bool = False, is_video_overlay: bool = False,
overlap_head_ms: int = 0, overlap_head_ms: int = 0,
overlap_tail_ms: int = 0, overlap_tail_ms: int = 0,
use_hwdownload: bool = False use_hwdownload: bool = False,
duration_ms: int = 0,
render_spec: Optional[RenderSpec] = None,
source_duration_sec: Optional[float] = None
) -> str: ) -> str:
""" """
构建包含特效的 filter_complex 滤镜图 构建包含特效的 filter_complex 滤镜图
@@ -557,6 +611,9 @@ class RenderSegmentVideoHandler(BaseHandler):
overlap_head_ms: 头部 overlap 时长 overlap_head_ms: 头部 overlap 时长
overlap_tail_ms: 尾部 overlap 时长 overlap_tail_ms: 尾部 overlap 时长
use_hwdownload: 是否使用了硬件加速解码(已在 base_filters 中包含 hwdownload) use_hwdownload: 是否使用了硬件加速解码(已在 base_filters 中包含 hwdownload)
duration_ms: 目标时长(毫秒)
render_spec: 渲染规格(用于获取变速参数)
source_duration_sec: 源视频实际时长(秒),用于检测时长不足
Returns: Returns:
filter_complex 格式的滤镜字符串 filter_complex 格式的滤镜字符串
@@ -615,14 +672,30 @@ class RenderSegmentVideoHandler(BaseHandler):
current_output = effect_output current_output = effect_output
effect_idx += 1 effect_idx += 1
# 帧冻结(tpad)- 用于转场 overlap 区域 # 帧冻结(tpad)- 用于转场 overlap 区域和时长不足补足
tpad_parts = [] tpad_parts = []
# 计算是否需要额外的尾部冻结(源视频时长不足)
extra_tail_freeze_sec = 0.0
if source_duration_sec is not None and render_spec is not None and duration_ms > 0:
speed = float(render_spec.speed) if render_spec.speed else 1.0
if speed > 0:
# 计算变速后的有效时长
effective_duration_sec = source_duration_sec / speed
required_duration_sec = duration_ms / 1000.0
# 如果源视频时长不足,需要冻结最后一帧来补足
if effective_duration_sec < required_duration_sec:
extra_tail_freeze_sec = required_duration_sec - effective_duration_sec
if overlap_head_ms > 0: if overlap_head_ms > 0:
head_duration_sec = overlap_head_ms / 1000.0 head_duration_sec = overlap_head_ms / 1000.0
tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}") tpad_parts.append(f"start_mode=clone:start_duration={head_duration_sec}")
if overlap_tail_ms > 0:
tail_duration_sec = overlap_tail_ms / 1000.0 # 尾部冻结:合并 overlap 和时长不足的冻结
tpad_parts.append(f"stop_mode=clone:stop_duration={tail_duration_sec}") total_tail_freeze_sec = (overlap_tail_ms / 1000.0) + extra_tail_freeze_sec
if total_tail_freeze_sec > 0:
tpad_parts.append(f"stop_mode=clone:stop_duration={total_tail_freeze_sec}")
if tpad_parts: if tpad_parts:
tpad_output = '[v_tpad]' tpad_output = '[v_tpad]'