feat(phase8): 任务 2.09 — VideoComposeService FFmpeg 视频合成编排 #159

Merged
xiaoxia merged 3 commits from feature/phase8-task209-video-compose into develop 2026-07-01 22:23:05 +08:00
Owner

任务 2.09: VideoComposeService

变更内容

  • VideoComposeService — FFmpeg 视频合成编排服务
    • validate_compose() — 合成前校验
    • build_compose_command() — 生成完整 FFmpeg filter_complex 命令
    • build_single_clip_command() — 单片段预览命令
    • get_compose_status() — 合成状态摘要查询

技术实现

  • 逐片段滤镜链: scale → crop → setpts → trim → atrim
  • 两种合成策略: concat 滤镜(cut)和 xfade 滤镜链(fade/slide/dissolve/wipe)
  • 29 个单元测试全部通过

文件

  • apps/api/app/services/video_compose_service.py
  • apps/api/app/services/__init__.py
  • tests/unit/test_video_compose_service.py
## 任务 2.09: VideoComposeService ### 变更内容 - VideoComposeService — FFmpeg 视频合成编排服务 - validate_compose() — 合成前校验 - build_compose_command() — 生成完整 FFmpeg filter_complex 命令 - build_single_clip_command() — 单片段预览命令 - get_compose_status() — 合成状态摘要查询 ### 技术实现 - 逐片段滤镜链: scale → crop → setpts → trim → atrim - 两种合成策略: concat 滤镜(cut)和 xfade 滤镜链(fade/slide/dissolve/wipe) - 29 个单元测试全部通过 ### 文件 - apps/api/app/services/video_compose_service.py - apps/api/app/services/__init__.py - tests/unit/test_video_compose_service.py
Author
Owner

代码审查报告 — PR #159(任务 2.09 VideoComposeService)

审查范围: 3 个文件,+1200/-0

  • video_compose_service.py(628 行):FFmpeg 视频合成编排服务
  • __init__.py(7 行):模块导出
  • test_video_compose_service.py(565 行):29 个单元测试

审查结论:有条件通过(1 P0 + 2 P1 + 4 P2)

整体架构设计合理:服务层只负责命令生成与校验,不直接执行 FFmpeg,职责清晰。filter_complex 生成逻辑覆盖了 concat 和 xfade 两种策略,测试覆盖充分。但存在一个关键功能性 Bug和若干需要修复的问题。


P0 — 必须修复

P0-1:_has_audio 始终返回 True,导致无音频输入时 FFmpeg 报错

_build_clip_filteraudio_label 被无条件设置为 f"a{input_index}"(非 None),而 _has_audio 的判断逻辑是:

@staticmethod
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
    return any(c.audio_label is not None for c in clip_chains)

由于 audio_label 永远不会是 None_has_audio 始终返回 True,导致 build_compose_command 始终输出 -map '[outa]'。如果输入素材没有音频流,FFmpeg 会因找不到 [outa] 流而执行失败。

建议修复:_build_clip_filter 中根据实际条件判断是否有音频流(例如通过 probe 输入文件,或通过 EditPlanClip 的某个字段标识),无音频时设置 audio_label=None。或者至少将 _has_audio 的判断逻辑改为基于实际音频流存在性,而非 label 是否为 None。


P1 — 建议修复

P1-1:concat 模式下 _build_concat_filterClipFilterChain.filters 的音频滤镜未被使用

_build_clip_filterfilters 列表中定义了 atrim=0:{duration}asetpts=PTS-STARTPTS,但在 _build_concat_filter 中,这些滤镜只通过 [{idx}:v]{filter_body} 应用于视频流。音频处理完全独立重写:

audio_parts.append(
    f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]"
)

这意味着 ClipFilterChain.filters 中定义的音频滤镜是死代码,永远不会被使用。如果未来需要修改音频处理逻辑(如添加音量归一化),修改 filters 列表不会生效,容易造成维护困惑。

建议:ClipFilterChain 拆分为 video_filtersaudio_filters 两个独立字段,或在 _build_concat_filter 中直接复用 chain.filters 中的音频部分,避免重复定义。

P1-2:_build_xfade_filtertransitions 参数索引语义不一致

_build_xfade_filter 中:

transition = transitions[i] if i < len(transitions) else "cut"

transitions 来自 [c.transition_effect for c in ready_clips],即每个 clip 的转场效果。但循环中 i 从 1 开始,transitions[i] 取的是第 i 个 clip 的转场效果,语义上是"第 i 个片段到下一个片段的转场"。实际上 transitions[0](第一个片段的转场效果)从未被使用。

虽然功能上可以工作,但语义不直观:用户可能认为 clips[0].transition_effect 控制 clip0→clip1 的转场,实际代码用的是 clips[1].transition_effect

建议: 要么明确文档说明 transitions[i] 表示"片段 i 到片段 i+1 的转场",要么将索引调整为 transitions[i-1] 使语义更自然。


P2 — 可选优化

P2-1:start_time 缺少负值校验

_build_clip_filter 中直接使用 clip.start_time 构建 setpts 滤镜:

if start > 0:
    filters.append(f"setpts=PTS-STARTPTS+{start}/TB")

start < 0 时(EditPlanClip.create() 虽然校验了 start_time >= 0,但直接修改属性可绕过),会生成 setpts=PTS-STARTPTS+-2.0/TB,FFmpeg 可能解析异常。建议在 _build_clip_filter 中增加 max(0, start) 保护。

P2-2:transition_duration 缺少边界校验

未校验 transition_duration 的范围。如果传入极大值(如 9999),xfade 的 offset 会被 max(0.0, ...) 钳制到 0,但 duration 计算会产生负数总时长。虽然最后有 max(0.0, total_duration) 兜底,但建议在入口处校验 0 < transition_duration < min_clip_duration

P2-3:output_pathinput_paths 无路径合法性校验

虽然使用 list[str] 传给 subprocess.run 避免了 shell 注入,但未校验路径合理性(如是否包含非法字符、是否在允许目录内)。建议增加基础的路径合法性检查,防止意外覆盖系统文件或读取敏感文件。

P2-4:缺少 FFmpeg 可用性预检

build_compose_command 假设 FFmpeg 已安装,但未做任何可用性检查。建议在 validate_compose 中增加 shutil.which("ffmpeg") 检查,提前报错而非等到 Worker 执行时才失败。


👍 亮点

  • 命令构建使用 list 而非 shell 字符串:天然避免 shell 注入风险
  • 数值型参数(duration、fps、crf 等)通过 int/float 类型约束:无法注入恶意字符串
  • 转场映射使用白名单_XFADE_TRANSITION_MAP 限制了合法的转场类型,未知类型 fallback 到 "fade"
  • 测试覆盖全面:29 个测试覆盖了校验逻辑、命令生成、辅助函数、边界条件,质量较高
  • dataclass 使用规范ClipFilterChainComposeCommandComposeValidation 结构清晰,frozen=True 保证不可变性

总结: 核心的命令注入防护做得很好(list 模式 + 白名单映射 + 数值约束),但 _has_audio 始终返回 True 会导致无音频素材合成失败,这是功能性 Bug,建议优先修复。其余 P1/P2 问题不阻塞合并但建议后续迭代处理。

## 代码审查报告 — PR #159(任务 2.09 VideoComposeService) **审查范围:** 3 个文件,+1200/-0 - `video_compose_service.py`(628 行):FFmpeg 视频合成编排服务 - `__init__.py`(7 行):模块导出 - `test_video_compose_service.py`(565 行):29 个单元测试 --- ### ✅ 审查结论:**有条件通过**(1 P0 + 2 P1 + 4 P2) 整体架构设计合理:服务层只负责命令生成与校验,不直接执行 FFmpeg,职责清晰。filter_complex 生成逻辑覆盖了 concat 和 xfade 两种策略,测试覆盖充分。但存在一个**关键功能性 Bug**和若干需要修复的问题。 --- ### P0 — 必须修复 **P0-1:`_has_audio` 始终返回 True,导致无音频输入时 FFmpeg 报错** `_build_clip_filter` 中 `audio_label` 被无条件设置为 `f"a{input_index}"`(非 None),而 `_has_audio` 的判断逻辑是: ```python @staticmethod def _has_audio(clip_chains: list[ClipFilterChain]) -> bool: return any(c.audio_label is not None for c in clip_chains) ``` 由于 `audio_label` 永远不会是 `None`,`_has_audio` 始终返回 `True`,导致 `build_compose_command` 始终输出 `-map '[outa]'`。如果输入素材没有音频流,FFmpeg 会因找不到 `[outa]` 流而执行失败。 **建议修复:** 在 `_build_clip_filter` 中根据实际条件判断是否有音频流(例如通过 probe 输入文件,或通过 `EditPlanClip` 的某个字段标识),无音频时设置 `audio_label=None`。或者至少将 `_has_audio` 的判断逻辑改为基于实际音频流存在性,而非 label 是否为 None。 --- ### P1 — 建议修复 **P1-1:concat 模式下 `_build_concat_filter` 中 `ClipFilterChain.filters` 的音频滤镜未被使用** `_build_clip_filter` 在 `filters` 列表中定义了 `atrim=0:{duration}` 和 `asetpts=PTS-STARTPTS`,但在 `_build_concat_filter` 中,这些滤镜只通过 `[{idx}:v]{filter_body}` 应用于视频流。音频处理完全独立重写: ```python audio_parts.append( f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]" ) ``` 这意味着 `ClipFilterChain.filters` 中定义的音频滤镜是**死代码**,永远不会被使用。如果未来需要修改音频处理逻辑(如添加音量归一化),修改 `filters` 列表不会生效,容易造成维护困惑。 **建议:** 将 `ClipFilterChain` 拆分为 `video_filters` 和 `audio_filters` 两个独立字段,或在 `_build_concat_filter` 中直接复用 `chain.filters` 中的音频部分,避免重复定义。 **P1-2:`_build_xfade_filter` 的 `transitions` 参数索引语义不一致** 在 `_build_xfade_filter` 中: ```python transition = transitions[i] if i < len(transitions) else "cut" ``` `transitions` 来自 `[c.transition_effect for c in ready_clips]`,即每个 clip 的转场效果。但循环中 `i` 从 1 开始,`transitions[i]` 取的是第 i 个 clip 的转场效果,语义上是"第 i 个片段到下一个片段的转场"。实际上 `transitions[0]`(第一个片段的转场效果)从未被使用。 虽然功能上可以工作,但语义不直观:用户可能认为 `clips[0].transition_effect` 控制 clip0→clip1 的转场,实际代码用的是 `clips[1].transition_effect`。 **建议:** 要么明确文档说明 `transitions[i]` 表示"片段 i 到片段 i+1 的转场",要么将索引调整为 `transitions[i-1]` 使语义更自然。 --- ### P2 — 可选优化 **P2-1:`start_time` 缺少负值校验** `_build_clip_filter` 中直接使用 `clip.start_time` 构建 setpts 滤镜: ```python if start > 0: filters.append(f"setpts=PTS-STARTPTS+{start}/TB") ``` 当 `start < 0` 时(`EditPlanClip.create()` 虽然校验了 `start_time >= 0`,但直接修改属性可绕过),会生成 `setpts=PTS-STARTPTS+-2.0/TB`,FFmpeg 可能解析异常。建议在 `_build_clip_filter` 中增加 `max(0, start)` 保护。 **P2-2:`transition_duration` 缺少边界校验** 未校验 `transition_duration` 的范围。如果传入极大值(如 9999),xfade 的 offset 会被 `max(0.0, ...)` 钳制到 0,但 duration 计算会产生负数总时长。虽然最后有 `max(0.0, total_duration)` 兜底,但建议在入口处校验 `0 < transition_duration < min_clip_duration`。 **P2-3:`output_path` 和 `input_paths` 无路径合法性校验** 虽然使用 `list[str]` 传给 `subprocess.run` 避免了 shell 注入,但未校验路径合理性(如是否包含非法字符、是否在允许目录内)。建议增加基础的路径合法性检查,防止意外覆盖系统文件或读取敏感文件。 **P2-4:缺少 FFmpeg 可用性预检** `build_compose_command` 假设 FFmpeg 已安装,但未做任何可用性检查。建议在 `validate_compose` 中增加 `shutil.which("ffmpeg")` 检查,提前报错而非等到 Worker 执行时才失败。 --- ### 👍 亮点 - **命令构建使用 list 而非 shell 字符串**:天然避免 shell 注入风险 - **数值型参数(duration、fps、crf 等)通过 int/float 类型约束**:无法注入恶意字符串 - **转场映射使用白名单**:`_XFADE_TRANSITION_MAP` 限制了合法的转场类型,未知类型 fallback 到 "fade" - **测试覆盖全面**:29 个测试覆盖了校验逻辑、命令生成、辅助函数、边界条件,质量较高 - **dataclass 使用规范**:`ClipFilterChain`、`ComposeCommand`、`ComposeValidation` 结构清晰,frozen=True 保证不可变性 --- **总结:** 核心的命令注入防护做得很好(list 模式 + 白名单映射 + 数值约束),但 `_has_audio` 始终返回 True 会导致无音频素材合成失败,这是功能性 Bug,建议优先修复。其余 P1/P2 问题不阻塞合并但建议后续迭代处理。
xiaoxia force-pushed feature/phase8-task209-video-compose from e71df81393 to 17fc46c662 2026-07-01 20:58:34 +08:00 Compare
xiaoxia force-pushed feature/phase8-task209-video-compose from 17fc46c662 to 03e561154b 2026-07-01 21:31:43 +08:00 Compare
xiaoxia force-pushed feature/phase8-task209-video-compose from 03e561154b to 51f5a960f1 2026-07-01 21:33:06 +08:00 Compare
xiaoxia merged commit 87865bc418 into develop 2026-07-01 22:23:05 +08:00
Sign in to join this conversation.