test(wave91): add 59 unit tests for generation_plan_builder (#945)
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
This commit is contained in:
@@ -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],
|
||||
|
||||
+327
@@ -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(),
|
||||
}
|
||||
Executable
+648
@@ -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"]
|
||||
Reference in New Issue
Block a user