From a679c55ae4fbc93c5e2c068682ddd72537a9f485 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 13:16:33 +0800 Subject: [PATCH 1/3] =?UTF-8?q?P1-1:=20=E4=B8=80=E9=94=AE=E7=94=9F?= =?UTF-8?q?=E6=88=90P1=E7=BA=A7=E6=95=88=E6=9E=9C=E5=B1=82=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20-=20clip=E7=BA=A7=E8=BD=AC=E5=9C=BA/=E6=BB=A4?= =?UTF-8?q?=E9=95=9C/=E7=89=87=E5=A4=B4=E7=89=87=E5=B0=BE=E4=BB=8E?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 _load_template_clip_configs() 从模板读取clip配置列表 - 新增 _apply_template_clip_effects() 按位置映射clip级效果(转场/滤镜/调速等) - 新增 _extract_intro_outro_from_clip_configs() 从intro/outro类型clip提取plan级片头片尾配置 - _build_plan_and_clips_from_task 增加 template_id 参数,自动注入效果层 - 素材多于模板配置时复用最后一个配置,失败降级不阻断 - 配套8个单元测试 --- apps/worker/worker_app/tasks/generation.py | 145 +++++++++++++++++++++ tests/unit/test_oneclick_gen_p0_fixes.py | 142 ++++++++++++++++++++ 2 files changed, 287 insertions(+) mode change 100644 => 100755 tests/unit/test_oneclick_gen_p0_fixes.py diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index e04941b17..44953d942 100755 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -178,10 +178,135 @@ class _VirtualClip: config: dict[str, Any] = field(default_factory=dict) +def _load_template_clip_configs(template_id: str) -> list: + """从数据库读取模板的片段配置列表。 + + 失败返回空列表,不阻断主流程。 + """ + if not template_id: + return [] + try: + from packages.adapters.sqlalchemy_impl.template_clip_config_repository import ( + SQLAlchemyTemplateClipConfigRepository, + ) + from worker_app.db import SessionLocal + + session = SessionLocal() + try: + repo = SQLAlchemyTemplateClipConfigRepository(session) + configs = repo.list_by_template(template_id, limit=200) + logger.info("读取模板片段配置: template_id=%s count=%d", template_id, len(configs)) + return configs + finally: + session.close() + except Exception as e: + logger.warning("读取模板片段配置失败,跳过效果层映射: template_id=%s error=%s", template_id, e) + return [] + + +def _extract_intro_outro_from_clip_configs(clip_configs: list) -> dict[str, Any]: + """从模板的 intro/outro 类型 clip_config 中提取 plan 级 intro_outro 配置。 + + UnifiedRenderService 已支持 plan.config.intro_outro 路径, + 这里把 intro/outro 片段配置转为统一格式注入。 + """ + intro_configs = [ + c for c in clip_configs if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) == "intro" + ] + outro_configs = [ + c for c in clip_configs if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) == "outro" + ] + + result: dict[str, Any] = {} + + if intro_configs: + intro = intro_configs[0] + intro_cfg = intro.config or {} + result["has_intro"] = True + result["intro_type"] = intro_cfg.get("intro_type", "text") + result["intro_duration"] = intro.default_duration or 3.0 + if intro.text_template: + result["intro_text"] = intro.text_template + # 透传额外配置 + for key in ("intro_text_color", "intro_bg_color", "intro_font_size", "intro_video_url", "intro_video_path"): + if key in intro_cfg: + result[key] = intro_cfg[key] + + if outro_configs: + outro = outro_configs[0] + outro_cfg = outro.config or {} + result["has_outro"] = True + result["outro_type"] = outro_cfg.get("outro_type", "text") + result["outro_duration"] = outro.default_duration or 3.0 + if outro.text_template: + result["outro_text"] = outro.text_template + for key in ("outro_text_color", "outro_bg_color", "outro_font_size", "outro_follow_text"): + if key in outro_cfg: + result[key] = outro_cfg[key] + + return result + + +def _apply_template_clip_effects( + clips: list[_VirtualClip], + clip_configs: list, + mode: str, +) -> None: + """将模板的 clip 级效果层映射到素材 clips 上(就地修改)。 + + 映射规则: + - 只对素材主体 clips 做映射(ONE_TAKE: main, PIP: main+overlay, VOICE_OVER: main, VOICE_PIP: background+b_roll) + - 从模板中筛选 main 类型的 clip_config 作为效果模板 + - 素材 clips 按顺序循环匹配模板 clip_config(素材多的话重复使用最后一个模板配置) + - 映射字段:transition_effect, config.color_grade, config.speed + """ + if not clip_configs or not clips: + return + + # 筛选 main 类型的模板配置(作为效果模板池) + main_configs = [ + c + for c in clip_configs + if (c.clip_type.value if hasattr(c.clip_type, "value") else c.clip_type) in ("main", "showcase", "b_roll") + ] + if not main_configs: + return + + # 确定需要映射的素材 clips(排除 corner_voice 等特殊层) + target_clips = [c for c in clips if c.clip_type not in ("corner_voice",)] + + for i, clip in enumerate(target_clips): + # 循环匹配:素材多了用最后一个模板配置 + cfg_idx = min(i, len(main_configs) - 1) + template_cfg = main_configs[cfg_idx] + + # 1. 转场效果 + transition = ( + template_cfg.transition_effect.value + if hasattr(template_cfg.transition_effect, "value") + else template_cfg.transition_effect + ) + if transition and transition != "cut": + clip.transition_effect = transition + + # 2. clip 级效果配置(滤镜、调速等) + template_clip_config = template_cfg.config or {} + if template_clip_config: + # 合并到 clip.config(保留已有配置如 role 等) + existing_config = clip.config or {} + # 需要从模板复制的效果层 key + effect_keys = ("color_grade", "speed", "playback_speed", "reverse", "chroma_key", "filter") + for key in effect_keys: + if key in template_clip_config: + existing_config[key] = template_clip_config[key] + clip.config = existing_config + + def _build_plan_and_clips_from_task( task_id: str, downloaded_paths: list[Path], mode: str, + template_id: str = "", ) -> tuple[_VirtualPlan, list[_VirtualClip], dict[str, Path]]: """根据模式和下载的素材路径,构建虚拟 plan + clips + asset_path_map。 @@ -269,6 +394,25 @@ def _build_plan_and_clips_from_task( ) ) + # ── P1: 模板效果层映射 ── + if template_id: + clip_configs = _load_template_clip_configs(template_id) + if clip_configs: + # 1. clip级效果层(转场、滤镜、调速等) + _apply_template_clip_effects(clips, clip_configs, mode) + + # 2. 片头片尾(从 intro/outro 类型 clip 提取 plan 级配置) + intro_outro_config = _extract_intro_outro_from_clip_configs(clip_configs) + if intro_outro_config: + plan_config = plan.config or {} + plan_config["intro_outro"] = intro_outro_config + plan.config = plan_config + logger.info( + "模板片头片尾配置已注入: has_intro=%s has_outro=%s", + intro_outro_config.get("has_intro", False), + intro_outro_config.get("has_outro", False), + ) + return plan, clips, asset_path_map @@ -1030,6 +1174,7 @@ def _render_video( task_id=task_id, downloaded_paths=downloaded_videos, mode=editing_mode.value, + template_id=template_id, ) total_duration = sum(c.duration for c in virtual_clips) diff --git a/tests/unit/test_oneclick_gen_p0_fixes.py b/tests/unit/test_oneclick_gen_p0_fixes.py old mode 100644 new mode 100755 index 9f259f484..f486d5734 --- a/tests/unit/test_oneclick_gen_p0_fixes.py +++ b/tests/unit/test_oneclick_gen_p0_fixes.py @@ -264,3 +264,145 @@ class TestP1Validations: with patch("worker_app.tasks.generation.SessionLocal", return_value=session): with pytest.raises(ValueError, match="模板不存在"): _validate_template_exists("tmpl_nonexistent") + + +# ── P1: 一键生成 clip 级效果层映射 ─────────────────────────────────────────── + + +class TestTemplateClipEffectMapping: + """P1: 模板 clip 级效果层映射到一键生成素材 clips.""" + + def _make_virtual_clip(self, idx: int, clip_type: str = "main", config: dict | None = None): + from dataclasses import dataclass, field + + @dataclass + class FakeClip: + id: str = f"vc_{idx:03d}" + plan_id: str = "task_001" + clip_type: str = clip_type + order: int = idx + asset_id: str = f"asset_{idx}" + duration: float = 5.0 + transition_effect: str = "cut" + config: dict = field(default_factory=dict) + + return FakeClip(config=config or {}) + + def _make_template_clip_config(self, clip_type: str = "main", transition: str = "cut", config: dict | None = None): + mock = MagicMock() + mock.clip_type = clip_type + mock.transition_effect = transition + mock.config = config or {} + mock.default_duration = 3.0 + mock.text_template = "" + return mock + + def test_transition_effect_mapped(self): + """转场效果正确映射到素材 clips.""" + from worker_app.tasks.generation import _apply_template_clip_effects + + clips = [self._make_virtual_clip(i) for i in range(3)] + clip_configs = [ + self._make_template_clip_config("main", transition="fade"), + self._make_template_clip_config("main", transition="dissolve"), + ] + + _apply_template_clip_effects(clips, clip_configs, "one_take") + + # 前两个按顺序映射,第三个用最后一个模板配置 + assert clips[0].transition_effect == "fade" + assert clips[1].transition_effect == "dissolve" + assert clips[2].transition_effect == "dissolve" # 复用最后一个 + + def test_color_grade_mapped(self): + """滤镜配置正确映射到 clip.config.color_grade.""" + from worker_app.tasks.generation import _apply_template_clip_effects + + clips = [self._make_virtual_clip(i) for i in range(2)] + clip_configs = [ + self._make_template_clip_config( + "main", config={"color_grade": {"enabled": True, "filter": "vintage", "brightness": 0.1}} + ), + ] + + _apply_template_clip_effects(clips, clip_configs, "one_take") + + assert clips[0].config["color_grade"]["filter"] == "vintage" + assert clips[0].config["color_grade"]["brightness"] == 0.1 + # 第二个素材复用第一个模板配置 + assert clips[1].config["color_grade"]["filter"] == "vintage" + + def test_existing_config_preserved(self): + """已有 clip.config 内容(如 role)被保留.""" + from worker_app.tasks.generation import _apply_template_clip_effects + + clips = [self._make_virtual_clip(0, config={"role": "b_roll"})] + clip_configs = [ + self._make_template_clip_config("main", config={"color_grade": {"enabled": True, "filter": "warm"}}), + ] + + _apply_template_clip_effects(clips, clip_configs, "voice_over") + + assert clips[0].config["role"] == "b_roll" # 保留原有配置 + assert clips[0].config["color_grade"]["filter"] == "warm" # 新增滤镜配置 + + def test_empty_clip_configs_no_change(self): + """空模板配置时 clips 保持不变.""" + from worker_app.tasks.generation import _apply_template_clip_effects + + clips = [self._make_virtual_clip(i) for i in range(2)] + _apply_template_clip_effects(clips, [], "one_take") + + assert clips[0].transition_effect == "cut" + assert clips[1].transition_effect == "cut" + + def test_cut_transition_not_overwritten(self): + """模板转场为 cut 时不覆盖(保持默认).""" + from worker_app.tasks.generation import _apply_template_clip_effects + + clips = [self._make_virtual_clip(0)] + clips[0].transition_effect = "fade" # 已有非默认值 + clip_configs = [ + self._make_template_clip_config("main", transition="cut"), + ] + + _apply_template_clip_effects(clips, clip_configs, "one_take") + + # 模板是 cut 时,保留原有值(避免无意义覆盖) + assert clips[0].transition_effect == "fade" + + def test_intro_outro_extracted(self): + """intro/outro 类型 clip_config 正确提取为 plan 级 intro_outro 配置.""" + from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs + + clip_configs = [ + self._make_template_clip_config("intro", config={"intro_type": "text", "intro_text_color": "#ffffff"}), + self._make_template_clip_config("main"), + self._make_template_clip_config("outro", config={"outro_type": "follow", "outro_follow_text": "关注我们"}), + ] + # 设置 intro/outro 的 text_template + clip_configs[0].text_template = "精彩视频" + clip_configs[0].default_duration = 2.5 + + result = _extract_intro_outro_from_clip_configs(clip_configs) + + assert result["has_intro"] is True + assert result["intro_type"] == "text" + assert result["intro_text"] == "精彩视频" + assert result["intro_duration"] == 2.5 + assert result["intro_text_color"] == "#ffffff" + assert result["has_outro"] is True + assert result["outro_type"] == "follow" + assert result["outro_follow_text"] == "关注我们" + + def test_intro_outro_empty_when_none(self): + """没有 intro/outro 时返回空 dict.""" + from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs + + clip_configs = [ + self._make_template_clip_config("main"), + self._make_template_clip_config("main"), + ] + + result = _extract_intro_outro_from_clip_configs(clip_configs) + assert result == {} -- 2.54.0 From f671dad9591fd35b01a3ee0faa3d71fdd2b49a52 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 13:24:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20isort=E5=AF=BC=E5=85=A5=E6=8E=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/worker/worker_app/tasks/generation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 44953d942..113ca70ea 100755 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -186,10 +186,11 @@ def _load_template_clip_configs(template_id: str) -> list: if not template_id: return [] try: + from worker_app.db import SessionLocal + from packages.adapters.sqlalchemy_impl.template_clip_config_repository import ( SQLAlchemyTemplateClipConfigRepository, ) - from worker_app.db import SessionLocal session = SessionLocal() try: -- 2.54.0 From 687a015357528d273217474db9e63cc8951f2ff3 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 17 Jul 2026 13:53:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20dataclass=E5=AD=97=E6=AE=B5=E5=90=8D?= =?UTF-8?q?=E9=81=AE=E8=94=BD=E5=87=BD=E6=95=B0=E5=8F=82=E6=95=B0=E5=AF=BC?= =?UTF-8?q?=E8=87=B4NameError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/unit/test_oneclick_gen_p0_fixes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_oneclick_gen_p0_fixes.py b/tests/unit/test_oneclick_gen_p0_fixes.py index f486d5734..1c4a3624d 100755 --- a/tests/unit/test_oneclick_gen_p0_fixes.py +++ b/tests/unit/test_oneclick_gen_p0_fixes.py @@ -275,11 +275,13 @@ class TestTemplateClipEffectMapping: def _make_virtual_clip(self, idx: int, clip_type: str = "main", config: dict | None = None): from dataclasses import dataclass, field + _clip_type_val = clip_type + @dataclass class FakeClip: id: str = f"vc_{idx:03d}" plan_id: str = "task_001" - clip_type: str = clip_type + clip_type: str = _clip_type_val order: int = idx asset_id: str = f"asset_{idx}" duration: float = 5.0 -- 2.54.0