From 4df4a937e4e907a7e554bf19e341feed4e2a5e79 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 18:15:48 +0800 Subject: [PATCH] test(wave91): add 59 unit tests for generation_plan_builder (#945) --- apps/worker/worker_app/tasks/generation.py | 188 +---- .../tasks/generation_plan_builder.py | 327 +++++++++ tests/unit/test_generation_plan_builder.py | 648 ++++++++++++++++++ 3 files changed, 984 insertions(+), 179 deletions(-) create mode 100755 apps/worker/worker_app/tasks/generation_plan_builder.py create mode 100755 tests/unit/test_generation_plan_builder.py diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 6089356af..2237c07ee 100755 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -17,7 +17,6 @@ import logging import os import tempfile import time -from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -25,6 +24,15 @@ from worker_app.celery_app import celery_app from worker_app.db import SessionLocal from packages.domain.bgm_utils import merge_bgm_config +from video_processing.ffmpeg_utils import probe_duration +from worker_app.tasks.generation_plan_builder import ( + VirtualPlan as _VirtualPlan, + VirtualClip as _VirtualClip, + build_error_info as _build_error_info, + extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs, + apply_template_clip_effects as _apply_template_clip_effects, + build_clips_by_mode, +) OUTPUT_WIDTH = 1280 OUTPUT_HEIGHT = 720 @@ -88,36 +96,6 @@ def _update_task_status(task_id: str, status_action: str, **kwargs) -> bool: return False -def _build_error_info(error: Exception, stage: str = "render") -> dict: - """构建结构化错误信息。 - - Args: - error: 异常对象 - stage: 发生错误的阶段(download/render/merge/upload等) - - Returns: - 包含 error_type, message, stack_trace, stage, failed_at 的字典 - """ - import traceback - from datetime import datetime, timezone - - tb_str = traceback.format_exc() - # 截取堆栈前20行,避免字段过大 - tb_lines = tb_str.strip().splitlines() - if len(tb_lines) > 20: - tb_summary = "\n".join(tb_lines[:20]) + f"\n... (truncated, total {len(tb_lines)} lines)" - else: - tb_summary = tb_str - - return { - "error_type": type(error).__name__, - "message": str(error), - "stack_trace": tb_summary, - "stage": stage, - "failed_at": datetime.now(timezone.utc).isoformat(), - } - - # ── 日志持久化辅助 ──────────────────────────────────────────────────────────── @@ -148,36 +126,6 @@ from video_processing.oss_helpers import ( upload_to_oss, ) -# ── 虚拟 Plan / Clip(内存中构建,不写数据库) ──────────────────────────────── - - -@dataclass -class _VirtualPlan: - """内存中的虚拟剪辑计划,供 UnifiedRenderService 使用。""" - - id: str - name: str = "" - config: dict[str, Any] = field(default_factory=dict) - - -@dataclass -class _VirtualClip: - """内存中的虚拟剪辑片段,供 UnifiedRenderService 使用。""" - - id: str - plan_id: str = "" - clip_type: str = "main" - order: int = 0 - asset_id: str = "" - text_content: str = "" - start_time: float = 0.0 - duration: float = 0.0 - transition_effect: str = "cut" - transition_duration: float = 0.0 # 0 表示使用全局默认值 - playback_speed: float = 1.0 - status: str = "ready" - config: dict[str, Any] = field(default_factory=dict) - def _load_template_clip_configs(template_id: str) -> list: """从数据库读取模板的片段配置列表。 @@ -206,124 +154,6 @@ def _load_template_clip_configs(template_id: str) -> list: 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 - # 同步转场时长(模板 clip_config 里的 transition_duration) - tpl_cfg = template_cfg.config or {} - tpl_duration = tpl_cfg.get("transition_duration") - if tpl_duration: - try: - dur_val = float(tpl_duration) - if dur_val > 0: - clip.transition_duration = dur_val - except (ValueError, TypeError): - pass - - # 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 - - # 3. 调速:同步到 clip.playback_speed 顶级字段(渲染引擎读此字段) - template_speed = template_clip_config.get("playback_speed") or template_clip_config.get("speed") - if template_speed: - try: - speed_val = float(template_speed) - if speed_val > 0: - clip.playback_speed = speed_val - except (ValueError, TypeError): - pass - - def _build_plan_and_clips_from_task( task_id: str, downloaded_paths: list[Path], diff --git a/apps/worker/worker_app/tasks/generation_plan_builder.py b/apps/worker/worker_app/tasks/generation_plan_builder.py new file mode 100755 index 000000000..e7db447f3 --- /dev/null +++ b/apps/worker/worker_app/tasks/generation_plan_builder.py @@ -0,0 +1,327 @@ +"""Generation task pure logic utilities — template mapping + plan/clip building. + +从 generation.py 抽出来的纯逻辑模块: +- VirtualPlan / VirtualClip: 内存中的虚拟计划/片段数据类 +- extract_intro_outro_from_clip_configs: 从模板 clip_config 提取片头片尾配置 +- apply_template_clip_effects: 将模板效果层映射到素材 clips +- build_clips_by_mode: 根据模式和素材列表构建虚拟 clips +- build_error_info: 构建结构化错误信息 +""" + +from __future__ import annotations + +import traceback +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +# ── 数据类 ─────────────────────────────────────────────────────────────────── + + +@dataclass +class VirtualPlan: + """内存中的虚拟剪辑计划,供 UnifiedRenderService 使用。""" + + id: str + name: str = "" + config: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class VirtualClip: + """内存中的虚拟剪辑片段,供 UnifiedRenderService 使用。""" + + id: str + plan_id: str = "" + clip_type: str = "main" + order: int = 0 + asset_id: str = "" + text_content: str = "" + start_time: float = 0.0 + duration: float = 0.0 + transition_effect: str = "cut" + transition_duration: float = 0.0 # 0 表示使用全局默认值 + playback_speed: float = 1.0 + status: str = "ready" + config: dict[str, Any] = field(default_factory=dict) + + +# ── 模板片头片尾提取 ──────────────────────────────────────────────────────── + + +def _clip_type_value(c: Any) -> str: + """获取 clip_config 的 clip_type 字符串值(兼容 Enum 和 str)。""" + if hasattr(c, "value"): + return str(c.value) + return str(c) + + +def _transition_value(t: Any) -> str: + """获取 transition_effect 字符串值(兼容 Enum 和 str)。""" + if hasattr(t, "value"): + return str(t.value) + return str(t) if t else "" + + +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 _clip_type_value(c.clip_type) == "intro"] + outro_configs = [c for c in clip_configs if _clip_type_value(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"] = getattr(intro, "default_duration", 3.0) or 3.0 + intro_text = getattr(intro, "text_template", "") + if intro_text: + result["intro_text"] = intro_text + # 透传额外配置 + 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"] = getattr(outro, "default_duration", 3.0) or 3.0 + outro_text = getattr(outro, "text_template", "") + if outro_text: + result["outro_text"] = outro_text + 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 + + +# ── 模板效果层映射 ────────────────────────────────────────────────────────── + + +# 需要从模板复制的效果层 key +_TEMPLATE_EFFECT_KEYS = ( + "color_grade", + "speed", + "playback_speed", + "reverse", + "chroma_key", + "filter", +) + +# 各模式下需要应用效果的 clip_type +_EFFECT_TARGET_TYPES = { + "one_take": {"main"}, + "pip": {"main", "overlay"}, + "voice_over": {"main"}, + "voice_pip": {"background", "b_roll"}, +} + +# 作为效果模板池的 clip_type +_TEMPLATE_SOURCE_TYPES = {"main", "showcase", "b_roll"} + +# 不应用效果的 clip_type +_SKIP_TYPES = {"corner_voice"} + + +def apply_template_clip_effects( + clips: list[VirtualClip], + clip_configs: list, + mode: str, +) -> None: + """将模板的 clip 级效果层映射到素材 clips 上(就地修改)。 + + 映射规则: + - 只对素材主体 clips 做映射 + - 从模板中筛选 main/showcase/b_roll 类型的 clip_config 作为效果模板 + - 素材 clips 按顺序循环匹配模板 clip_config(素材多的话重复使用最后一个模板配置) + - 映射字段:transition_effect, transition_duration, config 中的效果层 + """ + if not clip_configs or not clips: + return + + # 筛选 main 类型的模板配置(作为效果模板池) + main_configs = [c for c in clip_configs if _clip_type_value(c.clip_type) in _TEMPLATE_SOURCE_TYPES] + if not main_configs: + return + + # 确定需要映射的素材 clips(排除特殊层) + target_clips = [c for c in clips if c.clip_type not in _SKIP_TYPES] + + for i, clip in enumerate(target_clips): + # 循环匹配:素材多了用最后一个模板配置 + cfg_idx = min(i, len(main_configs) - 1) + template_cfg = main_configs[cfg_idx] + + # 1. 转场效果 + 时长 + transition = _transition_value(template_cfg.transition_effect) + if transition and transition != "cut": + clip.transition_effect = transition + # 同步转场时长 + tpl_cfg = template_cfg.config or {} + tpl_duration = tpl_cfg.get("transition_duration") + if tpl_duration: + try: + dur_val = float(tpl_duration) + if dur_val > 0: + clip.transition_duration = dur_val + except (ValueError, TypeError): + pass + + # 2. clip 级效果配置(滤镜、调速等) + template_clip_config = template_cfg.config or {} + if template_clip_config: + existing_config = clip.config or {} + for key in _TEMPLATE_EFFECT_KEYS: + if key in template_clip_config: + existing_config[key] = template_clip_config[key] + clip.config = existing_config + + # 3. 调速:同步到 clip.playback_speed 顶级字段 + template_speed = template_clip_config.get("playback_speed") or template_clip_config.get("speed") + if template_speed: + try: + speed_val = float(template_speed) + if speed_val > 0: + clip.playback_speed = speed_val + except (ValueError, TypeError): + pass + + +# ── 按模式构建 clips ──────────────────────────────────────────────────────── + + +def build_clips_by_mode( + plan_id: str, + asset_infos: list[dict[str, Any]], + mode: str, +) -> list[VirtualClip]: + """根据生成模式和素材信息,构建 VirtualClip 列表。 + + 纯逻辑版本:不依赖 ffmpeg probe 或 DB,完全由输入数据驱动。 + + Args: + plan_id: 计划 ID + asset_infos: 素材信息列表,每项包含 asset_id / duration / path 等 + mode: 生成模式 (one_take / pip / voice_over / voice_pip) + + Returns: + VirtualClip 列表,按 order 排序 + + 模式 → clip_type 映射: + one_take: N 个 main clips + pip: 1 main + N-1 overlay + voice_over: N 个 main (config.role=b_roll) + voice_pip: 1 background + 1 corner_voice + N-2 b_roll + """ + clips: list[VirtualClip] = [] + + for i, info in enumerate(asset_infos): + asset_id = info.get("asset_id", f"asset_{i:03d}") + duration = float(info.get("duration", 0.0)) + + if mode == "pip": + clip_type = "main" if i == 0 else "overlay" + clips.append( + VirtualClip( + id=f"vc_{i:03d}", + plan_id=plan_id, + clip_type=clip_type, + order=i, + asset_id=asset_id, + duration=duration, + ) + ) + elif mode == "voice_over": + clips.append( + VirtualClip( + id=f"vc_{i:03d}", + plan_id=plan_id, + clip_type="main", + order=i, + asset_id=asset_id, + duration=duration, + config={"role": "b_roll"}, + ) + ) + elif mode == "voice_pip": + if i == 0: + clip_type = "background" + elif i == 1: + clip_type = "corner_voice" + else: + clip_type = "b_roll" + clips.append( + VirtualClip( + id=f"vc_{i:03d}", + plan_id=plan_id, + clip_type=clip_type, + order=i, + asset_id=asset_id, + duration=duration, + ) + ) + else: + # one_take (default): N 个 main clips + clips.append( + VirtualClip( + id=f"vc_{i:03d}", + plan_id=plan_id, + clip_type="main", + order=i, + asset_id=asset_id, + duration=duration, + ) + ) + + return clips + + +# ── 错误信息构建 ──────────────────────────────────────────────────────────── + + +def build_error_info(error: Exception, stage: str = "render") -> dict[str, Any]: + """构建结构化错误信息。 + + Args: + error: 异常对象 + stage: 发生错误的阶段 + + Returns: + 包含 error_type, message, stack_trace, stage, failed_at 的字典 + """ + tb_str = traceback.format_exc() + # 截取堆栈前20行,避免字段过大 + tb_lines = tb_str.strip().splitlines() + if len(tb_lines) > 20: + tb_summary = "\n".join(tb_lines[:20]) + f"\n... (truncated, total {len(tb_lines)} lines)" + else: + tb_summary = tb_str + + return { + "error_type": type(error).__name__, + "message": str(error), + "stack_trace": tb_summary, + "stage": stage, + "failed_at": datetime.now(timezone.utc).isoformat(), + } diff --git a/tests/unit/test_generation_plan_builder.py b/tests/unit/test_generation_plan_builder.py new file mode 100755 index 000000000..a34383477 --- /dev/null +++ b/tests/unit/test_generation_plan_builder.py @@ -0,0 +1,648 @@ +"""Unit tests for generation_plan_builder.py — pure logic utilities. + +覆盖: +- VirtualPlan / VirtualClip 数据类 +- extract_intro_outro_from_clip_configs +- apply_template_clip_effects +- build_clips_by_mode (4种模式) +- build_error_info +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import patch + +import pytest + +from worker_app.tasks.generation_plan_builder import ( + VirtualClip, + VirtualPlan, + apply_template_clip_effects, + build_clips_by_mode, + build_error_info, + extract_intro_outro_from_clip_configs, +) + +# ── 辅助:模拟 clip_config 对象 ────────────────────────────────────────────── + + +@dataclass +class MockClipType: + """模拟 Enum 类型的 clip_type。""" + + value: str + + +@dataclass +class MockTransition: + """模拟 Enum 类型的 transition_effect。""" + + value: str + + +@dataclass +class MockClipConfig: + """模拟 TemplateClipConfig 对象。""" + + clip_type: Any + transition_effect: Any = "cut" + default_duration: float = 3.0 + text_template: str = "" + config: dict | None = None + + +def _make_config( + clip_type: str = "main", + transition: str = "cut", + duration: float = 3.0, + text: str = "", + config: dict | None = None, + use_enum: bool = True, +) -> MockClipConfig: + """创建一个模拟 clip_config。""" + ct = MockClipType(clip_type) if use_enum else clip_type + tr = MockTransition(transition) if use_enum and transition != "cut" else transition + return MockClipConfig( + clip_type=ct, + transition_effect=tr, + default_duration=duration, + text_template=text, + config=config or {}, + ) + + +# ── VirtualPlan / VirtualClip ─────────────────────────────────────────────── + + +class TestVirtualPlan: + def test_default_values(self): + plan = VirtualPlan(id="plan_001") + assert plan.id == "plan_001" + assert plan.name == "" + assert plan.config == {} + + def test_full_init(self): + plan = VirtualPlan(id="p1", name="My Plan", config={"key": "value"}) + assert plan.id == "p1" + assert plan.name == "My Plan" + assert plan.config == {"key": "value"} + + def test_mutable_config(self): + plan = VirtualPlan(id="p1") + plan.config["new_key"] = "new_val" + assert plan.config == {"new_key": "new_val"} + + +class TestVirtualClip: + def test_default_values(self): + clip = VirtualClip(id="c001") + assert clip.id == "c001" + assert clip.plan_id == "" + assert clip.clip_type == "main" + assert clip.order == 0 + assert clip.asset_id == "" + assert clip.duration == 0.0 + assert clip.transition_effect == "cut" + assert clip.transition_duration == 0.0 + assert clip.playback_speed == 1.0 + assert clip.status == "ready" + assert clip.config == {} + + def test_full_init(self): + clip = VirtualClip( + id="c001", + plan_id="p1", + clip_type="overlay", + order=1, + asset_id="asset_001", + duration=5.5, + transition_effect="fade", + transition_duration=0.5, + playback_speed=1.5, + config={"role": "b_roll"}, + ) + assert clip.clip_type == "overlay" + assert clip.duration == 5.5 + assert clip.playback_speed == 1.5 + + def test_mutable_config(self): + clip = VirtualClip(id="c001") + clip.config["effect"] = "vintage" + assert clip.config == {"effect": "vintage"} + + +# ── extract_intro_outro_from_clip_configs ─────────────────────────────────── + + +class TestExtractIntroOutro: + def test_empty_configs(self): + result = extract_intro_outro_from_clip_configs([]) + assert result == {} + + def test_no_intro_no_outro(self): + configs = [_make_config("main"), _make_config("showcase")] + result = extract_intro_outro_from_clip_configs(configs) + assert result == {} + + def test_intro_only_basic(self): + configs = [_make_config("intro", text="Hello", duration=2.5)] + result = extract_intro_outro_from_clip_configs(configs) + assert result["has_intro"] is True + assert result["intro_type"] == "text" + assert result["intro_duration"] == 2.5 + assert result["intro_text"] == "Hello" + assert "has_outro" not in result + + def test_outro_only_basic(self): + configs = [_make_config("outro", text="Bye", duration=3.0)] + result = extract_intro_outro_from_clip_configs(configs) + assert result["has_outro"] is True + assert result["outro_type"] == "text" + assert result["outro_duration"] == 3.0 + assert result["outro_text"] == "Bye" + + def test_both_intro_and_outro(self): + configs = [ + _make_config("intro", text="Start"), + _make_config("main"), + _make_config("outro", text="End"), + ] + result = extract_intro_outro_from_clip_configs(configs) + assert result["has_intro"] is True + assert result["intro_text"] == "Start" + assert result["has_outro"] is True + assert result["outro_text"] == "End" + + def test_intro_extra_config_pass_through(self): + configs = [ + _make_config( + "intro", + config={ + "intro_text_color": "#ffffff", + "intro_bg_color": "#000000", + "intro_font_size": 32, + "intro_video_url": "https://example.com/intro.mp4", + "intro_video_path": "/tmp/intro.mp4", + "random_key": "should_not_appear", + }, + ) + ] + result = extract_intro_outro_from_clip_configs(configs) + assert result["intro_text_color"] == "#ffffff" + assert result["intro_bg_color"] == "#000000" + assert result["intro_font_size"] == 32 + assert result["intro_video_url"] == "https://example.com/intro.mp4" + assert "random_key" not in result + + def test_outro_extra_config_pass_through(self): + configs = [ + _make_config( + "outro", + config={ + "outro_text_color": "#ff0000", + "outro_bg_color": "#00ff00", + "outro_font_size": 24, + "outro_follow_text": "关注我们", + }, + ) + ] + result = extract_intro_outro_from_clip_configs(configs) + assert result["outro_text_color"] == "#ff0000" + assert result["outro_follow_text"] == "关注我们" + + def test_intro_type_from_config(self): + configs = [_make_config("intro", config={"intro_type": "video"})] + result = extract_intro_outro_from_clip_configs(configs) + assert result["intro_type"] == "video" + + def test_default_duration_when_zero(self): + configs = [_make_config("intro", duration=0.0)] + result = extract_intro_outro_from_clip_configs(configs) + assert result["intro_duration"] == 3.0 + + def test_empty_text_not_included(self): + configs = [_make_config("intro", text="")] + result = extract_intro_outro_from_clip_configs(configs) + assert "intro_text" not in result + + def test_with_string_clip_type_no_enum(self): + configs = [_make_config("intro", text="Hi", use_enum=False)] + result = extract_intro_outro_from_clip_configs(configs) + assert result["has_intro"] is True + assert result["intro_text"] == "Hi" + + def test_first_intro_used_when_multiple(self): + configs = [ + _make_config("intro", text="First"), + _make_config("intro", text="Second"), + ] + result = extract_intro_outro_from_clip_configs(configs) + assert result["intro_text"] == "First" + + def test_none_config_handled(self): + cfg = _make_config("intro") + cfg.config = None + result = extract_intro_outro_from_clip_configs([cfg]) + assert result["has_intro"] is True + + +# ── apply_template_clip_effects ───────────────────────────────────────────── + + +class TestApplyTemplateClipEffects: + def test_empty_clips(self): + clips: list[VirtualClip] = [] + configs = [_make_config("main", transition="fade")] + apply_template_clip_effects(clips, configs, "one_take") + assert clips == [] + + def test_empty_configs(self): + clips = [VirtualClip(id="c1", clip_type="main")] + apply_template_clip_effects(clips, [], "one_take") + assert clips[0].transition_effect == "cut" + + def test_no_main_configs(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("intro"), _make_config("outro")] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].transition_effect == "cut" + + def test_transition_effect_applied(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", transition="fade")] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].transition_effect == "fade" + + def test_transition_duration_applied(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", transition="fade", config={"transition_duration": 0.8})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].transition_duration == 0.8 + + def test_transition_duration_invalid_ignored(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", transition="fade", config={"transition_duration": "abc"})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].transition_duration == 0.0 + + def test_cut_transition_not_applied(self): + clips = [VirtualClip(id="c1", clip_type="main", transition_effect="dissolve")] + configs = [_make_config("main", transition="cut")] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].transition_effect == "dissolve" # 保留原值 + + def test_color_grade_applied(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", config={"color_grade": "vintage"})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].config["color_grade"] == "vintage" + + def test_multiple_effect_keys_applied(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [ + _make_config( + "main", + config={ + "color_grade": "warm", + "playback_speed": 1.5, + "reverse": True, + "chroma_key": {"color": "green"}, + "filter": "黑白", + }, + ) + ] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].config["color_grade"] == "warm" + assert clips[0].config["playback_speed"] == 1.5 + assert clips[0].config["reverse"] is True + assert clips[0].config["chroma_key"] == {"color": "green"} + + def test_playback_speed_top_level_updated(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", config={"playback_speed": 2.0})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].playback_speed == 2.0 + assert clips[0].config["playback_speed"] == 2.0 + + def test_speed_fallback_sets_playback_speed(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", config={"speed": 0.8})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].playback_speed == 0.8 + + def test_playback_speed_takes_priority_over_speed(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", config={"speed": 0.5, "playback_speed": 2.0})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].playback_speed == 2.0 + + def test_existing_config_preserved(self): + clips = [VirtualClip(id="c1", clip_type="main", config={"role": "b_roll", "original": "value"})] + configs = [_make_config("main", config={"color_grade": "cool"})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].config["role"] == "b_roll" + assert clips[0].config["original"] == "value" + assert clips[0].config["color_grade"] == "cool" + + def test_corner_voice_skipped(self): + clips = [ + VirtualClip(id="c1", clip_type="main"), + VirtualClip(id="c2", clip_type="corner_voice"), + ] + configs = [_make_config("main", transition="fade")] + apply_template_clip_effects(clips, configs, "voice_pip") + assert clips[0].transition_effect == "fade" + assert clips[1].transition_effect == "cut" # 不应用效果 + + def test_cyclic_matching_more_clips_than_configs(self): + clips = [ + VirtualClip(id="c1", clip_type="main"), + VirtualClip(id="c2", clip_type="main"), + VirtualClip(id="c3", clip_type="main"), + ] + configs = [ + _make_config("main", transition="fade"), + _make_config("main", transition="dissolve"), + ] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].transition_effect == "fade" + assert clips[1].transition_effect == "dissolve" + assert clips[2].transition_effect == "dissolve" # 循环用最后一个 + + def test_pip_mode_main_and_overlay_both_effected(self): + clips = [ + VirtualClip(id="c1", clip_type="main"), + VirtualClip(id="c2", clip_type="overlay"), + ] + configs = [_make_config("main", transition="fade")] + apply_template_clip_effects(clips, configs, "pip") + assert clips[0].transition_effect == "fade" + assert clips[1].transition_effect == "fade" + + def test_showcase_and_b_roll_count_as_template_source(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [ + _make_config("showcase", transition="zoom_in"), + _make_config("b_roll", transition="slide_left"), + ] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].transition_effect == "zoom_in" # 用第一个匹配的 + + def test_string_transition_no_enum(self): + clips = [VirtualClip(id="c1", clip_type="main")] + cfg = _make_config("main") + cfg.transition_effect = "wipe" # 直接字符串 + apply_template_clip_effects(clips, [cfg], "one_take") + assert clips[0].transition_effect == "wipe" + + def test_zero_speed_ignored(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", config={"playback_speed": 0})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].playback_speed == 1.0 # 保持默认 + + def test_negative_speed_ignored(self): + clips = [VirtualClip(id="c1", clip_type="main")] + configs = [_make_config("main", config={"playback_speed": -1.0})] + apply_template_clip_effects(clips, configs, "one_take") + assert clips[0].playback_speed == 1.0 + + +# ── build_clips_by_mode ──────────────────────────────────────────────────── + + +class TestBuildClipsByMode: + def test_one_take_single_asset(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[{"asset_id": "a1", "duration": 10.0}], + mode="one_take", + ) + assert len(clips) == 1 + assert clips[0].clip_type == "main" + assert clips[0].asset_id == "a1" + assert clips[0].duration == 10.0 + assert clips[0].order == 0 + assert clips[0].plan_id == "p1" + + def test_one_take_multiple_assets(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[ + {"asset_id": "a1", "duration": 5.0}, + {"asset_id": "a2", "duration": 10.0}, + {"asset_id": "a3", "duration": 7.0}, + ], + mode="one_take", + ) + assert len(clips) == 3 + assert all(c.clip_type == "main" for c in clips) + assert [c.order for c in clips] == [0, 1, 2] + assert [c.duration for c in clips] == [5.0, 10.0, 7.0] + + def test_pip_mode_main_and_overlay(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[ + {"asset_id": "a1", "duration": 10.0}, + {"asset_id": "a2", "duration": 5.0}, + {"asset_id": "a3", "duration": 3.0}, + ], + mode="pip", + ) + assert len(clips) == 3 + assert clips[0].clip_type == "main" + assert clips[1].clip_type == "overlay" + assert clips[2].clip_type == "overlay" + + def test_pip_single_asset_is_main(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[{"asset_id": "a1", "duration": 10.0}], + mode="pip", + ) + assert len(clips) == 1 + assert clips[0].clip_type == "main" + + def test_voice_over_all_main_with_role(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[ + {"asset_id": "a1", "duration": 5.0}, + {"asset_id": "a2", "duration": 8.0}, + ], + mode="voice_over", + ) + assert len(clips) == 2 + assert all(c.clip_type == "main" for c in clips) + assert all(c.config.get("role") == "b_roll" for c in clips) + + def test_voice_pip_three_layers(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[ + {"asset_id": "a1", "duration": 10.0}, + {"asset_id": "a2", "duration": 5.0}, + {"asset_id": "a3", "duration": 3.0}, + {"asset_id": "a4", "duration": 4.0}, + ], + mode="voice_pip", + ) + assert len(clips) == 4 + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + assert clips[2].clip_type == "b_roll" + assert clips[3].clip_type == "b_roll" + + def test_voice_pip_two_assets(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[ + {"asset_id": "a1", "duration": 10.0}, + {"asset_id": "a2", "duration": 5.0}, + ], + mode="voice_pip", + ) + assert len(clips) == 2 + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + + def test_voice_pip_single_asset(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[{"asset_id": "a1", "duration": 10.0}], + mode="voice_pip", + ) + assert len(clips) == 1 + assert clips[0].clip_type == "background" + + def test_default_mode_is_one_take(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[{"asset_id": "a1", "duration": 10.0}], + mode="unknown_mode", + ) + assert len(clips) == 1 + assert clips[0].clip_type == "main" + + def test_empty_assets(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[], + mode="one_take", + ) + assert clips == [] + + def test_default_asset_id_when_missing(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[{"duration": 5.0}], + mode="one_take", + ) + assert clips[0].asset_id == "asset_000" + + def test_default_duration_when_missing(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[{"asset_id": "a1"}], + mode="one_take", + ) + assert clips[0].duration == 0.0 + + def test_clip_ids_sequential(self): + clips = build_clips_by_mode( + plan_id="p1", + asset_infos=[{"asset_id": f"a{i}"} for i in range(5)], + mode="one_take", + ) + assert [c.id for c in clips] == ["vc_000", "vc_001", "vc_002", "vc_003", "vc_004"] + + def test_plan_id_propagated(self): + clips = build_clips_by_mode( + plan_id="my_plan_123", + asset_infos=[{"asset_id": "a1"}, {"asset_id": "a2"}], + mode="pip", + ) + assert all(c.plan_id == "my_plan_123" for c in clips) + + +# ── build_error_info ─────────────────────────────────────────────────────── + + +class TestBuildErrorInfo: + def test_basic_structure(self): + try: + raise ValueError("test error") + except ValueError as e: + info = build_error_info(e, stage="render") + + assert info["error_type"] == "ValueError" + assert info["message"] == "test error" + assert info["stage"] == "render" + assert "stack_trace" in info + assert "failed_at" in info + assert "ValueError" in info["stack_trace"] + assert "test error" in info["stack_trace"] + + def test_default_stage(self): + try: + raise RuntimeError("oops") + except RuntimeError as e: + info = build_error_info(e) + + assert info["stage"] == "render" + + def test_custom_stage(self): + try: + raise TypeError("bad type") + except TypeError as e: + info = build_error_info(e, stage="download") + + assert info["stage"] == "download" + + def test_failed_at_is_iso_format(self): + try: + raise ValueError("x") + except ValueError as e: + info = build_error_info(e) + + # ISO 格式检查:包含 T 和 + + assert "T" in info["failed_at"] + + @patch("worker_app.tasks.generation_plan_builder.traceback.format_exc") + def test_long_stack_trace_truncated(self, mock_format): + # 构造30行堆栈 + lines = [f' File "file{i}.py", line {i}, in func{i}' for i in range(28)] + lines.append("ValueError: deep error") + mock_format.return_value = "\n".join(lines) + + try: + raise ValueError("deep") + except ValueError as e: + info = build_error_info(e) + + assert "truncated" in info["stack_trace"] + assert "total 29 lines" in info["stack_trace"] + # 确认只保留了前20行 + assert "func19" in info["stack_trace"] + assert "func20" not in info["stack_trace"] + + @patch("worker_app.tasks.generation_plan_builder.traceback.format_exc") + def test_short_stack_trace_not_truncated(self, mock_format): + # 构造5行堆栈(小于20) + mock_format.return_value = ( + "Traceback (most recent call last):\n" + ' File "test.py", line 10, in foo\n' + ' raise ValueError("simple")\n' + "ValueError: simple\n" + ) + + try: + raise ValueError("simple") + except ValueError as e: + info = build_error_info(e) + + assert "truncated" not in info["stack_trace"] + assert "ValueError: simple" in info["stack_trace"]