From 28e7931388164b9b66ceacc64ca48fa81f384dbb Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 18 Jul 2026 13:07:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B8=B2=E6=9F=93=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=B8=89=E9=87=8D=E4=BF=AE=E5=A4=8D=20-=20legacy=20fps?= =?UTF-8?q?=E6=BB=A4=E9=95=9C=20+=20=E6=A8=AA=E5=B1=8F=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=20+=20legacy=E5=88=86=E8=BE=A8=E7=8E=87=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## P0: legacy路径fps滤镜缺失导致concat失败 - _build_clip_filter有fps参数但从未使用 - concat要求所有输入帧率一致,不同帧率必崩 - 修复:在crop后setpts前增加fps滤镜,统一到目标帧率 ## P1: 自动选素材无横屏过滤 - 只过滤了编码格式,完全没有横竖屏判断 - 横屏素材混入竖屏剪辑,裁剪后画面不可用 - 修复:width > height的横屏素材过滤掉 - 兼容:正方形放过,尺寸未知放过,metadata兜底 ## P2: legacy路径不读取plan配置的分辨率 - 硬编码1280x720,忽略plan.config.export.resolution - 导致模板配置的1080x1920不生效 - 修复:从plan.config读取resolution和fps传给build_compose_command 55个单元测试全过 --- apps/api/app/api/routes/_helpers.py | 24 +++- .../api/app/services/video_compose_service.py | 14 ++- .../worker_app/tasks/edit_plan_generation.py | 33 ++++- tests/unit/test_auto_select_assets.py | 115 ++++++++++++++++++ tests/unit/test_video_compose_service.py | 27 ++++ 5 files changed, 204 insertions(+), 9 deletions(-) mode change 100644 => 100755 apps/api/app/services/video_compose_service.py mode change 100644 => 100755 tests/unit/test_auto_select_assets.py mode change 100644 => 100755 tests/unit/test_video_compose_service.py diff --git a/apps/api/app/api/routes/_helpers.py b/apps/api/app/api/routes/_helpers.py index 1046c714c..651ecb348 100755 --- a/apps/api/app/api/routes/_helpers.py +++ b/apps/api/app/api/routes/_helpers.py @@ -98,7 +98,7 @@ def auto_select_video_assets( # 过滤不支持的编码格式(HEVC/VP9/AV1 等会导致渲染失败) # 优先读 asset.codec 字段,其次从 metadata 里取(兼容存量数据) - filtered_videos = [] + codec_filtered = [] skipped_codec = 0 for a in ready_videos: codec = (a.codec or "").lower() @@ -107,11 +107,31 @@ def auto_select_video_assets( if codec and codec in UNSUPPORTED_CODECS: skipped_codec += 1 continue - filtered_videos.append(a) + codec_filtered.append(a) if skipped_codec and logger: logger.warning("自动选素材: 跳过 %d 个不支持编码的素材", skipped_codec) + # 过滤横屏素材(只保留竖屏/正方形) + # 移动端短视频场景默认竖屏,横屏素材裁剪后画面不可用 + filtered_videos = [] + skipped_landscape = 0 + for a in codec_filtered: + width = a.width if hasattr(a, "width") and a.width else 0 + height = a.height if hasattr(a, "height") and a.height else 0 + if not width or not height: + # 从 metadata 兜底 + if a.metadata and isinstance(a.metadata, dict): + width = int(a.metadata.get("width", 0) or 0) + height = int(a.metadata.get("height", 0) or 0) + if width and height and width > height: + skipped_landscape += 1 + continue + filtered_videos.append(a) + + if skipped_landscape and logger: + logger.warning("自动选素材: 跳过 %d 个横屏素材", skipped_landscape) + if not filtered_videos: if logger: logger.warning("自动选素材: 素材库 %s 无可用视频素材", video_lib.name) diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py old mode 100644 new mode 100755 index 88b149373..21ee1c4a9 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -219,7 +219,7 @@ class VideoComposeService: 根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。 滤镜链逻辑: - - 每个片段:scale → crop → setpts → trim → atrim + - 每个片段:scale → crop → fps → setpts → trim → atrim - 多片段之间:concat 滤镜 或 xfade 转场 - 最终输出:-map '[outv]' -map '[outa]'(如有音频) """ @@ -406,9 +406,10 @@ class VideoComposeService: 滤镜顺序: 1. scale — 等比缩放到目标分辨率(保证覆盖) 2. crop — 居中裁剪到目标分辨率 - 3. setpts — 重置时间戳 + 偏移 - 4. trim — 视频时长裁剪 - 5. atrim — 音频时长裁剪(如有音频流) + 3. fps — 统一输出帧率(concat 要求所有输入帧率一致) + 4. setpts — 重置时间戳 + 偏移 + 5. trim — 视频时长裁剪 + 6. atrim — 音频时长裁剪(如有音频流) """ duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒 start = clip.start_time @@ -421,6 +422,11 @@ class VideoComposeService: # 2. crop: 居中裁剪 filters.append(f"crop={output_width}:{output_height}") + # 2.5 fps: 统一帧率(concat 要求所有输入帧率一致) + # 放在 crop 之后、setpts 之前,确保分辨率和帧率都已统一 + if fps and fps > 0: + filters.append(f"fps={fps}") + # 3. setpts: 重置时间戳 if start > 0: filters.append(f"setpts=PTS-STARTPTS+{start}/TB") diff --git a/apps/worker/worker_app/tasks/edit_plan_generation.py b/apps/worker/worker_app/tasks/edit_plan_generation.py index 5b06c702a..38a24718b 100755 --- a/apps/worker/worker_app/tasks/edit_plan_generation.py +++ b/apps/worker/worker_app/tasks/edit_plan_generation.py @@ -332,7 +332,34 @@ def _render_with_legacy( # 构建 FFmpeg 命令 output_dir = os.environ.get("VIDEO_OUTPUT_DIR", str(tmpdir_path)) output_path = Path(output_dir) / f"{plan_id}.mp4" - compose_cmd = compose_svc.build_compose_command(plan_id, str(output_path)) + + # 从 plan.config.export 读取输出分辨率,兼容 plan 自定义配置 + plan_config = plan.config or {} + export_config = plan_config.get("export", {}) or {} + output_width = OUTPUT_WIDTH + output_height = OUTPUT_HEIGHT + resolution = export_config.get("resolution", "") + if resolution and "x" in resolution: + try: + w_str, h_str = resolution.lower().split("x", 1) + output_width = int(w_str) + output_height = int(h_str) + except (ValueError, TypeError): + pass + + fps = export_config.get("fps", 25) + try: + fps = int(fps) + except (ValueError, TypeError): + fps = 25 + + compose_cmd = compose_svc.build_compose_command( + plan_id, + str(output_path), + output_width=output_width, + output_height=output_height, + fps=fps, + ) logger.info("执行 FFmpeg (legacy): plan_id=%s", plan_id) try: @@ -364,8 +391,8 @@ def _render_with_legacy( storage_key=storage_key, duration=duration, file_size=file_size, - width=OUTPUT_WIDTH, - height=OUTPUT_HEIGHT, + width=output_width, + height=output_height, rendered_clip_ids=rendered_clip_ids, failed_clip_ids=failed_clip_ids, generation_task_id=generation_task_id, diff --git a/tests/unit/test_auto_select_assets.py b/tests/unit/test_auto_select_assets.py old mode 100644 new mode 100755 index 647f93137..c068be609 --- a/tests/unit/test_auto_select_assets.py +++ b/tests/unit/test_auto_select_assets.py @@ -311,3 +311,118 @@ class TestAutoSelectVideoAssetsCodecFilter: ) assert result == ["newest", "middle", "oldest"] + + def test_landscape_videos_filtered_out(self): + """横屏视频(width > height)被过滤掉.""" + from app.api.routes._helpers import auto_select_video_assets + + assets = [ + _make_asset("portrait1", codec="h264", width=1080, height=1920), + _make_asset("landscape1", codec="h264", width=1920, height=1080), + _make_asset("portrait2", codec="h264", width=720, height=1280), + _make_asset("landscape2", codec="h264", width=1280, height=720), + ] + asset_repo = _make_mock_repo(assets) + lib_repo = _make_mock_lib_repo() + + result = auto_select_video_assets( + project_id="proj-1", + asset_library_repo=lib_repo, + asset_repo=asset_repo, + ) + + assert set(result) == {"portrait1", "portrait2"} + assert len(result) == 2 + + def test_square_videos_allowed(self): + """正方形视频(width == height)不被过滤.""" + from app.api.routes._helpers import auto_select_video_assets + + assets = [ + _make_asset("square", codec="h264", width=720, height=720), + _make_asset("portrait", codec="h264", width=1080, height=1920), + ] + asset_repo = _make_mock_repo(assets) + lib_repo = _make_mock_lib_repo() + + result = auto_select_video_assets( + project_id="proj-1", + asset_library_repo=lib_repo, + asset_repo=asset_repo, + ) + + assert "square" in result + assert "portrait" in result + + def test_unknown_size_not_filtered(self): + """尺寸未知的素材不被过滤(避免误杀存量数据).""" + from app.api.routes._helpers import auto_select_video_assets + + assets = [ + _make_asset("unknown", codec="h264", width=0, height=0), + _make_asset("portrait", codec="h264", width=1080, height=1920), + ] + asset_repo = _make_mock_repo(assets) + lib_repo = _make_mock_lib_repo() + + result = auto_select_video_assets( + project_id="proj-1", + asset_library_repo=lib_repo, + asset_repo=asset_repo, + ) + + assert "unknown" in result + assert "portrait" in result + + def test_metadata_size_fallback(self): + """width/height字段为空时从metadata兜底读取.""" + from app.api.routes._helpers import auto_select_video_assets + + assets = [ + _make_asset( + "landscape_in_meta", + codec="h264", + width=0, + height=0, + metadata={"width": 1920, "height": 1080}, + ), + _make_asset( + "portrait_in_meta", + codec="h264", + width=0, + height=0, + metadata={"width": 1080, "height": 1920}, + ), + ] + asset_repo = _make_mock_repo(assets) + lib_repo = _make_mock_lib_repo() + + result = auto_select_video_assets( + project_id="proj-1", + asset_library_repo=lib_repo, + asset_repo=asset_repo, + ) + + assert "landscape_in_meta" not in result + assert "portrait_in_meta" in result + + def test_codec_and_landscape_combined_filter(self): + """编码过滤 + 横屏过滤同时生效.""" + from app.api.routes._helpers import auto_select_video_assets + + assets = [ + _make_asset("good", codec="h264", width=1080, height=1920), + _make_asset("bad_codec", codec="hevc", width=1080, height=1920), + _make_asset("bad_landscape", codec="h264", width=1920, height=1080), + _make_asset("bad_both", codec="hevc", width=1920, height=1080), + ] + asset_repo = _make_mock_repo(assets) + lib_repo = _make_mock_lib_repo() + + result = auto_select_video_assets( + project_id="proj-1", + asset_library_repo=lib_repo, + asset_repo=asset_repo, + ) + + assert result == ["good"] diff --git a/tests/unit/test_video_compose_service.py b/tests/unit/test_video_compose_service.py old mode 100644 new mode 100755 index f7202ae6c..0e8ae547c --- a/tests/unit/test_video_compose_service.py +++ b/tests/unit/test_video_compose_service.py @@ -357,6 +357,33 @@ class TestBuildComposeCommand(TestCase): self.assertIn("crop=", filter_text) self.assertIn("trim=", filter_text) + def test_filter_chain_contains_fps(self): + """滤镜链包含 fps 滤镜,用于统一帧率避免 concat 失败。""" + plan = _StubPlan() + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4", fps=25) + + chain = cmd.clip_chains[0] + filter_text = ",".join(chain.filters) + # fps 必须在 crop 之后、setpts 之前 + crop_idx = filter_text.index("crop=") + fps_idx = filter_text.index("fps=25") + setpts_idx = filter_text.index("setpts=") + self.assertGreater(fps_idx, crop_idx, "fps 应该在 crop 之后") + self.assertLess(fps_idx, setpts_idx, "fps 应该在 setpts 之前") + + def test_fps_zero_or_none_skips_fps_filter(self): + """fps 为 0 或负值时不添加 fps 滤镜。""" + plan = _StubPlan() + clips = [_make_ready_clip(plan_id=plan.id)] + svc = _make_service(plan, clips) + cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4", fps=0) + + chain = cmd.clip_chains[0] + filter_text = ",".join(chain.filters) + self.assertNotIn("fps=", filter_text) + def test_start_time_offset(self): """片段 start_time > 0 时生成 setpts 偏移。""" plan = _StubPlan() -- 2.54.0