Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae4fad67c3 | |||
| 0655fb3e9f |
@@ -29,47 +29,33 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
)
|
||||
from packages.domain.edit_plan import EditPlanStatus
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
from packages.domain.video_filter_builder import (
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_OUTPUT_HEIGHT,
|
||||
DEFAULT_OUTPUT_WIDTH,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
ClipFilterChain,
|
||||
build_clip_filter,
|
||||
build_concat_filter as _build_concat_filter_func,
|
||||
build_filter_complex as _build_filter_complex,
|
||||
build_xfade_filter as _build_xfade_filter_func,
|
||||
chain_filters as _chain_filters_func,
|
||||
has_audio as _has_audio_func,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
# ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
|
||||
# 实际定义已迁移至 packages/domain/video_filter_builder.py
|
||||
|
||||
DEFAULT_OUTPUT_WIDTH = 1280
|
||||
DEFAULT_OUTPUT_HEIGHT = 720
|
||||
DEFAULT_FPS = 25
|
||||
DEFAULT_CODEC = "libx264"
|
||||
DEFAULT_CRF = 23
|
||||
DEFAULT_PRESET = "medium"
|
||||
|
||||
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
|
||||
_XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
TransitionEffect.FADE: "fade",
|
||||
TransitionEffect.SLIDE_LEFT: "slideleft",
|
||||
TransitionEffect.SLIDE_RIGHT: "slideright",
|
||||
TransitionEffect.DISSOLVE: "dissolve",
|
||||
TransitionEffect.WIPE: "wipeleft",
|
||||
}
|
||||
|
||||
# 转场默认时长(秒)
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
|
||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClipFilterChain:
|
||||
"""单个片段的滤镜链描述。"""
|
||||
|
||||
clip_id: str
|
||||
input_index: int
|
||||
video_label: str
|
||||
audio_label: str | None
|
||||
filters: list[str]
|
||||
duration: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComposeCommand:
|
||||
"""完整的 FFmpeg 合成命令描述。"""
|
||||
@@ -401,62 +387,8 @@ class VideoComposeService:
|
||||
output_height: int,
|
||||
fps: int,
|
||||
) -> ClipFilterChain:
|
||||
"""为单个片段构建滤镜链。
|
||||
|
||||
滤镜顺序:
|
||||
1. scale — 等比缩放到目标分辨率(保证覆盖)
|
||||
2. crop — 居中裁剪到目标分辨率
|
||||
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
|
||||
4. setpts — 重置时间戳 + 偏移
|
||||
5. trim — 视频时长裁剪
|
||||
6. atrim — 音频时长裁剪(如有音频流)
|
||||
"""
|
||||
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
|
||||
start = clip.start_time
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# 1. scale: 等比缩放(保持比例,不裁剪)
|
||||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
|
||||
|
||||
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
|
||||
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
|
||||
|
||||
# 3. format: 统一像素格式为 yuv420p(H.264 标准格式,concat 要求所有输入像素格式一致)
|
||||
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
|
||||
filters.append("format=yuv420p")
|
||||
|
||||
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
|
||||
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
|
||||
if fps and fps > 0:
|
||||
filters.append(f"fps={fps}")
|
||||
|
||||
# 3. setpts: 重置时间戳
|
||||
if start > 0:
|
||||
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
|
||||
else:
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 4. trim: 视频时长
|
||||
filters.append(f"trim=0:{duration}")
|
||||
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
|
||||
|
||||
video_label = f"v{input_index}"
|
||||
|
||||
# 5. 音频标签:仅当片段类型可能有音频时才设置
|
||||
# title/subtitle 是纯文字/图片卡片,没有音频流
|
||||
clip_type = clip.clip_type.lower() if clip.clip_type else ""
|
||||
has_audio_stream = clip_type not in ("title", "subtitle")
|
||||
audio_label = f"a{input_index}" if has_audio_stream else None
|
||||
|
||||
return ClipFilterChain(
|
||||
clip_id=clip.id,
|
||||
input_index=input_index,
|
||||
video_label=video_label,
|
||||
audio_label=audio_label,
|
||||
filters=filters,
|
||||
duration=duration,
|
||||
)
|
||||
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
|
||||
return build_clip_filter(clip, input_index, output_width, output_height, fps)
|
||||
|
||||
@staticmethod
|
||||
def _build_filter_complex(
|
||||
@@ -466,102 +398,30 @@ class VideoComposeService:
|
||||
transition_duration: float,
|
||||
transitions: list[str],
|
||||
) -> tuple[str, float]:
|
||||
"""构建完整的 filter_complex 字符串。
|
||||
|
||||
策略:
|
||||
- 单片段:直接输出
|
||||
- 多片段 + 全 cut:使用 concat 滤镜(高效)
|
||||
- 多片段 + 有转场:使用 xfade 滤镜链
|
||||
|
||||
返回 (filter_complex_string, estimated_total_duration)。
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
|
||||
if n == 0:
|
||||
return "", 0.0
|
||||
|
||||
# ── 单片段 ─────────────────────────────────────────────────────
|
||||
if n == 1:
|
||||
chain = clip_chains[0]
|
||||
filter_str = _chain_filters(chain.filters, chain.video_label)
|
||||
# 音频
|
||||
if chain.audio_label:
|
||||
filter_str += f";[0:a]{chain.audio_label}"
|
||||
total_duration = chain.duration
|
||||
return filter_str, total_duration
|
||||
|
||||
# ── 检查是否有转场 ─────────────────────────────────────────────
|
||||
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
||||
|
||||
if not has_transitions:
|
||||
return _build_concat_filter(clip_chains)
|
||||
|
||||
# ── 有转场:使用 xfade ─────────────────────────────────────────
|
||||
return _build_xfade_filter(
|
||||
clip_chains=clip_chains,
|
||||
transition_duration=transition_duration,
|
||||
transitions=transitions,
|
||||
)
|
||||
"""向后兼容:委托给 video_filter_builder.build_filter_complex。"""
|
||||
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
|
||||
|
||||
@staticmethod
|
||||
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||||
"""是否有任何片段包含音频流。"""
|
||||
return any(c.audio_label is not None for c in clip_chains)
|
||||
"""向后兼容:委托给 video_filter_builder.has_audio。"""
|
||||
return _has_audio_func(clip_chains)
|
||||
|
||||
|
||||
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
||||
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
|
||||
# 实际实现已迁移至 packages/domain/video_filter_builder.py
|
||||
# 保留此处别名以兼容现有测试与调用方
|
||||
|
||||
|
||||
def _chain_filters(filters: list[str], output_label: str) -> str:
|
||||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
|
||||
filter_body = ",".join(filters)
|
||||
return f"[0:v]{filter_body}[{output_label}]"
|
||||
"""向后兼容:委托给 video_filter_builder.chain_filters。"""
|
||||
return _chain_filters_func(filters, output_label)
|
||||
|
||||
|
||||
def _build_concat_filter(
|
||||
clip_chains: list[ClipFilterChain],
|
||||
) -> tuple[str, float]:
|
||||
"""构建 concat 滤镜(无转场,高效拼接)。
|
||||
|
||||
格式:
|
||||
[0:v]filters[v0]; [1:v]filters[v1]; ...
|
||||
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
parts: list[str] = []
|
||||
total_duration = 0.0
|
||||
|
||||
# 每个片段的滤镜链
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
filter_body = ",".join(chain.filters)
|
||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||
total_duration += chain.duration
|
||||
|
||||
# concat 滤镜
|
||||
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
|
||||
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
|
||||
parts.append(concat_filter)
|
||||
|
||||
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
|
||||
audio_parts: list[str] = []
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
if chain.audio_label:
|
||||
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
|
||||
audio_filters = [
|
||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||
f"atrim=0:{chain.duration}",
|
||||
"asetpts=PTS-STARTPTS",
|
||||
]
|
||||
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
|
||||
|
||||
if audio_parts:
|
||||
parts.extend(audio_parts)
|
||||
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
|
||||
audio_count = sum(1 for c in clip_chains if c.audio_label)
|
||||
if audio_count > 0:
|
||||
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
|
||||
|
||||
return ";".join(parts), total_duration
|
||||
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
|
||||
return _build_concat_filter_func(clip_chains)
|
||||
|
||||
|
||||
def _build_xfade_filter(
|
||||
@@ -569,80 +429,5 @@ def _build_xfade_filter(
|
||||
transition_duration: float,
|
||||
transitions: list[str],
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链。
|
||||
|
||||
每两个相邻片段之间插入 xfade 转场。
|
||||
offset = 前一个片段的累积时长 - 转场时长。
|
||||
|
||||
格式(2 片段):
|
||||
[0:v]filters[v0]; [1:v]filters[v1];
|
||||
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
|
||||
|
||||
格式(3+ 片段):
|
||||
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
parts: list[str] = []
|
||||
total_duration = 0.0
|
||||
|
||||
# 每个片段的滤镜链
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
filter_body = ",".join(chain.filters)
|
||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||
total_duration += chain.duration
|
||||
|
||||
# xfade 链
|
||||
if n == 1:
|
||||
# 单片段不需要 xfade
|
||||
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
|
||||
return ";".join(parts), total_duration
|
||||
|
||||
# 计算每个转场的 offset
|
||||
cumulative = 0.0
|
||||
prev_label = clip_chains[0].video_label
|
||||
|
||||
for i in range(1, n):
|
||||
cumulative += clip_chains[i - 1].duration
|
||||
offset = max(0.0, cumulative - transition_duration * i)
|
||||
|
||||
# 获取转场类型
|
||||
transition = transitions[i] if i < len(transitions) else "cut"
|
||||
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
|
||||
|
||||
if i == n - 1:
|
||||
# 最后一个转场,输出到 [outv]
|
||||
out_label = "outv"
|
||||
else:
|
||||
out_label = f"xf{i}"
|
||||
|
||||
parts.append(
|
||||
f"[{prev_label}][{clip_chains[i].video_label}]"
|
||||
f"xfade=transition={xfade_transition}"
|
||||
f":duration={transition_duration}"
|
||||
f":offset={offset:.3f}"
|
||||
f"[{out_label}]"
|
||||
)
|
||||
prev_label = out_label
|
||||
|
||||
# 总时长需要减去转场重叠部分
|
||||
total_duration -= transition_duration * (n - 1)
|
||||
|
||||
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
|
||||
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
|
||||
if len(audio_chains_with_label) >= 2:
|
||||
normalized_audio_labels: list[str] = []
|
||||
for chain, _ in audio_chains_with_label:
|
||||
norm_label = f"anorm_{chain.video_label}"
|
||||
audio_filters = [
|
||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||
f"atrim=0:{chain.duration}",
|
||||
"asetpts=PTS-STARTPTS",
|
||||
]
|
||||
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
|
||||
normalized_audio_labels.append(norm_label)
|
||||
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
|
||||
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
|
||||
elif len(audio_chains_with_label) == 1:
|
||||
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
|
||||
|
||||
return ";".join(parts), max(0.0, total_duration)
|
||||
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
|
||||
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
|
||||
|
||||
@@ -25,6 +25,12 @@ from worker_app.celery_app import celery_app
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.domain.bgm_utils import merge_bgm_config
|
||||
from packages.domain.template_effect_mapper import (
|
||||
VirtualClip as _VirtualClip,
|
||||
VirtualPlan as _VirtualPlan,
|
||||
apply_template_clip_effects as _apply_template_clip_effects,
|
||||
extract_intro_outro_from_clip_configs as _extract_intro_outro_from_clip_configs,
|
||||
)
|
||||
|
||||
OUTPUT_WIDTH = 1280
|
||||
OUTPUT_HEIGHT = 720
|
||||
@@ -148,35 +154,7 @@ 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)
|
||||
# ── 模板 clip_config 加载(需DB,保留在此) ─────────────────────────────────
|
||||
|
||||
|
||||
def _load_template_clip_configs(template_id: str) -> list:
|
||||
@@ -206,124 +184,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],
|
||||
|
||||
Executable
+211
@@ -0,0 +1,211 @@
|
||||
"""模板效果映射器 — 从模板 clip_config 提取并应用效果层的纯逻辑。
|
||||
|
||||
从 worker_app/tasks/generation.py 抽离的纯函数集合,专门负责:
|
||||
- 从模板 clip_config 中提取 intro/outro 配置
|
||||
- 将模板的 clip 级效果层(转场、滤镜、调速等)映射到素材 clips 上
|
||||
|
||||
所有函数均为纯函数,不依赖数据库或外部 IO。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualClip:
|
||||
"""内存中的虚拟剪辑片段,供渲染服务使用。"""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualPlan:
|
||||
"""内存中的虚拟剪辑计划,供渲染服务使用。"""
|
||||
|
||||
id: str
|
||||
name: str = ""
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
# ── 辅助工具 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clip_type_value(clip_type: Any) -> str:
|
||||
"""获取 clip_type 的字符串值(兼容 Enum 和 字符串)。"""
|
||||
if hasattr(clip_type, "value"):
|
||||
return clip_type.value # type: ignore[no-any-return]
|
||||
return clip_type # type: ignore[no-any-return]
|
||||
|
||||
|
||||
# ── intro/outro 配置提取 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def extract_intro_outro_from_clip_configs(clip_configs: list) -> dict[str, Any]:
|
||||
"""从模板的 intro/outro 类型 clip_config 中提取 plan 级 intro_outro 配置。
|
||||
|
||||
从 intro 类型 clip_config 提取:
|
||||
- has_intro / intro_type / intro_duration / intro_text
|
||||
- intro_text_color / intro_bg_color / intro_font_size
|
||||
- intro_video_url / intro_video_path
|
||||
|
||||
从 outro 类型 clip_config 提取:
|
||||
- has_outro / outro_type / outro_duration / outro_text
|
||||
- outro_text_color / outro_bg_color / outro_font_size / outro_follow_text
|
||||
|
||||
Args:
|
||||
clip_configs: 模板 clip_config 列表
|
||||
|
||||
Returns:
|
||||
intro_outro 配置字典(可直接写入 plan.config.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"] = 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
|
||||
|
||||
|
||||
# ── 模板效果映射 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# 需要从模板复制到 clip 的效果层 key
|
||||
_EFFECT_KEYS = (
|
||||
"color_grade",
|
||||
"speed",
|
||||
"playback_speed",
|
||||
"reverse",
|
||||
"chroma_key",
|
||||
"filter",
|
||||
)
|
||||
|
||||
# 作为效果模板池的 clip_type
|
||||
_TEMPLATE_TYPES = ("main", "showcase", "b_roll")
|
||||
|
||||
# 排除的 clip_type(不应用效果映射)
|
||||
_EXCLUDE_TYPES = ("corner_voice",)
|
||||
|
||||
|
||||
def apply_template_clip_effects(
|
||||
clips: list[VirtualClip],
|
||||
clip_configs: list,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""将模板的 clip 级效果层映射到素材 clips 上(就地修改)。
|
||||
|
||||
映射规则:
|
||||
1. 从模板中筛选 main/showcase/b_roll 类型的 clip_config 作为效果模板池
|
||||
2. 素材 clips 排除 corner_voice 等特殊类型
|
||||
3. 素材 clips 按顺序循环匹配模板 clip_config(素材多了用最后一个模板)
|
||||
4. 映射内容:
|
||||
- 转场效果 + 转场时长(transition_effect / transition_duration)
|
||||
- clip 级效果配置(color_grade / speed / playback_speed / reverse / chroma_key / filter)
|
||||
- 调速同步到 clip.playback_speed 顶级字段
|
||||
|
||||
Args:
|
||||
clips: 素材片段列表(VirtualClip,就地修改)
|
||||
clip_configs: 模板 clip_config 列表
|
||||
mode: 剪辑模式(保留参数,供未来扩展)
|
||||
"""
|
||||
if not clip_configs or not clips:
|
||||
return
|
||||
|
||||
# 筛选 main/showcase/b_roll 类型的模板配置(作为效果模板池)
|
||||
main_configs = [c for c in clip_configs if _clip_type_value(c.clip_type) in _TEMPLATE_TYPES]
|
||||
if not main_configs:
|
||||
return
|
||||
|
||||
# 确定需要映射的素材 clips(排除特殊类型)
|
||||
target_clips = [c for c in clips if c.clip_type not in _EXCLUDE_TYPES]
|
||||
|
||||
for i, clip in enumerate(target_clips):
|
||||
# 循环匹配:素材多了用最后一个模板配置
|
||||
cfg_idx = min(i, len(main_configs) - 1)
|
||||
template_cfg = main_configs[cfg_idx]
|
||||
|
||||
# 1. 转场效果 + 时长
|
||||
transition = _clip_type_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:
|
||||
# 合并到 clip.config(保留已有配置如 role 等)
|
||||
existing_config = clip.config or {}
|
||||
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
|
||||
Executable
+375
@@ -0,0 +1,375 @@
|
||||
"""视频滤镜构建器 — FFmpeg filter_complex 纯逻辑层。
|
||||
|
||||
从 video_compose_service.py 抽离的纯函数集合,专门负责 FFmpeg 滤镜链的构建,
|
||||
不依赖数据库、不做 IO,便于单元测试。
|
||||
|
||||
主要职责:
|
||||
- 单片段滤镜链构建(scale / pad / format / fps / setpts / trim)
|
||||
- concat 滤镜构建(无转场高效拼接)
|
||||
- xfade 转场滤镜链构建(fade / slide / dissolve / wipe)
|
||||
- 完整 filter_complex 策略选择与组装
|
||||
- 音频流判断与音频滤镜归一化
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
|
||||
|
||||
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
DEFAULT_OUTPUT_WIDTH = 1280
|
||||
DEFAULT_OUTPUT_HEIGHT = 720
|
||||
DEFAULT_FPS = 25
|
||||
|
||||
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
|
||||
XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||
TransitionEffect.FADE: "fade",
|
||||
TransitionEffect.SLIDE_LEFT: "slideleft",
|
||||
TransitionEffect.SLIDE_RIGHT: "slideright",
|
||||
TransitionEffect.DISSOLVE: "dissolve",
|
||||
TransitionEffect.WIPE: "wipeleft",
|
||||
}
|
||||
|
||||
# 转场默认时长(秒)
|
||||
DEFAULT_TRANSITION_DURATION = 0.5
|
||||
|
||||
# 默认片段时长(当 clip.duration <= 0 时使用)
|
||||
DEFAULT_CLIP_DURATION = 5.0
|
||||
|
||||
|
||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClipFilterChain:
|
||||
"""单个片段的滤镜链描述。"""
|
||||
|
||||
clip_id: str
|
||||
input_index: int
|
||||
video_label: str
|
||||
audio_label: str | None
|
||||
filters: list[str]
|
||||
duration: float
|
||||
|
||||
|
||||
# ── 单片段滤镜链 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_clip_filter(
|
||||
clip: "EditPlanClip",
|
||||
input_index: int,
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
fps: int,
|
||||
) -> ClipFilterChain:
|
||||
"""为单个片段构建滤镜链。
|
||||
|
||||
滤镜顺序:
|
||||
1. scale — 等比缩放到目标分辨率(保证覆盖,不裁剪内容)
|
||||
2. pad — 居中+留黑边到目标分辨率(保持原始比例)
|
||||
3. format — 统一像素格式为 yuv420p(concat 要求像素格式一致)
|
||||
4. fps — 统一帧率(concat 要求所有输入帧率一致)
|
||||
5. setpts — 重置时间戳 + 起始偏移
|
||||
6. trim — 视频时长裁剪 + 重置 PTS
|
||||
|
||||
Args:
|
||||
clip: 剪辑计划片段
|
||||
input_index: 输入流索引(对应第几个 -i)
|
||||
output_width: 输出宽度(像素)
|
||||
output_height: 输出高度(像素)
|
||||
fps: 输出帧率
|
||||
|
||||
Returns:
|
||||
ClipFilterChain 描述对象
|
||||
"""
|
||||
duration = clip.duration if clip.duration > 0 else DEFAULT_CLIP_DURATION
|
||||
start = clip.start_time
|
||||
|
||||
filters: list[str] = []
|
||||
|
||||
# 1. scale: 等比缩放(保持比例,不裁剪)
|
||||
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
|
||||
|
||||
# 2. pad: 居中+留黑边到目标分辨率
|
||||
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
|
||||
|
||||
# 3. format: 统一像素格式为 yuv420p
|
||||
filters.append("format=yuv420p")
|
||||
|
||||
# 4. fps: 统一帧率
|
||||
if fps and fps > 0:
|
||||
filters.append(f"fps={fps}")
|
||||
|
||||
# 5. setpts: 重置时间戳 + 偏移
|
||||
if start > 0:
|
||||
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
|
||||
else:
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
# 6. trim: 视频时长 + 重置 PTS
|
||||
filters.append(f"trim=0:{duration}")
|
||||
filters.append("setpts=PTS-STARTPTS")
|
||||
|
||||
video_label = f"v{input_index}"
|
||||
|
||||
# 音频标签:title/subtitle 是纯文字/图片卡片,没有音频流
|
||||
clip_type = clip.clip_type.lower() if clip.clip_type else ""
|
||||
has_audio_stream = clip_type not in ("title", "subtitle")
|
||||
audio_label = f"a{input_index}" if has_audio_stream else None
|
||||
|
||||
return ClipFilterChain(
|
||||
clip_id=clip.id,
|
||||
input_index=input_index,
|
||||
video_label=video_label,
|
||||
audio_label=audio_label,
|
||||
filters=filters,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
# ── 滤镜串联工具 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def chain_filters(filters: list[str], output_label: str, input_label: str = "0:v") -> str:
|
||||
"""将滤镜列表串联为 FFmpeg 滤镜字符串。
|
||||
|
||||
Args:
|
||||
filters: 滤镜表达式列表
|
||||
output_label: 输出标签名(不含方括号)
|
||||
input_label: 输入标签(默认 "0:v")
|
||||
|
||||
Returns:
|
||||
形如 "[0:v]scale=1280:720,fps=25[v0]" 的字符串
|
||||
"""
|
||||
filter_body = ",".join(filters)
|
||||
return f"[{input_label}]{filter_body}[{output_label}]"
|
||||
|
||||
|
||||
# ── 音频判断 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||||
"""是否有任何片段包含音频流。"""
|
||||
return any(c.audio_label is not None for c in clip_chains)
|
||||
|
||||
|
||||
# ── concat 滤镜 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_concat_filter(
|
||||
clip_chains: list[ClipFilterChain],
|
||||
) -> tuple[str, float]:
|
||||
"""构建 concat 滤镜(无转场,高效拼接)。
|
||||
|
||||
视频:每个片段先应用各自滤镜链,再用 concat 滤镜拼接
|
||||
音频:先 aformat 归一化(48000Hz/stereo/fltp)再 concat,
|
||||
避免不同采样率/声道导致 concat 失败
|
||||
|
||||
Args:
|
||||
clip_chains: 各片段的滤镜链描述
|
||||
|
||||
Returns:
|
||||
(filter_complex_string, estimated_total_duration)
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
if n == 0:
|
||||
return "", 0.0
|
||||
|
||||
parts: list[str] = []
|
||||
total_duration = 0.0
|
||||
|
||||
# 每个片段的视频滤镜链
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
filter_body = ",".join(chain.filters)
|
||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||
total_duration += chain.duration
|
||||
|
||||
# 视频 concat 滤镜
|
||||
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
|
||||
parts.append(f"{concat_inputs}concat=n={n}:v=1:a=0[outv]")
|
||||
|
||||
# 音频:归一化 + concat
|
||||
_append_audio_concat(parts, clip_chains)
|
||||
|
||||
return ";".join(parts), total_duration
|
||||
|
||||
|
||||
# ── xfade 转场滤镜 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_xfade_filter(
|
||||
clip_chains: list[ClipFilterChain],
|
||||
transition_duration: float,
|
||||
transitions: list[str],
|
||||
) -> tuple[str, float]:
|
||||
"""构建 xfade 转场滤镜链。
|
||||
|
||||
每两个相邻片段之间插入 xfade 转场。
|
||||
offset = 前一个片段的累积时长 - 转场时长。
|
||||
|
||||
视频转场支持:fade / slideleft / slideright / dissolve / wipeleft
|
||||
|
||||
Args:
|
||||
clip_chains: 各片段的滤镜链描述
|
||||
transition_duration: 转场时长(秒)
|
||||
transitions: 每个片段对应的转场效果列表(索引对应片段)
|
||||
|
||||
Returns:
|
||||
(filter_complex_string, estimated_total_duration)
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
if n == 0:
|
||||
return "", 0.0
|
||||
|
||||
parts: list[str] = []
|
||||
total_duration = 0.0
|
||||
|
||||
# 每个片段的视频滤镜链
|
||||
for idx, chain in enumerate(clip_chains):
|
||||
filter_body = ",".join(chain.filters)
|
||||
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||
total_duration += chain.duration
|
||||
|
||||
# 单片段:直接 copy 输出(无音频,与原实现保持一致)
|
||||
if n == 1:
|
||||
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
|
||||
return ";".join(parts), total_duration
|
||||
|
||||
# xfade 链式转场
|
||||
cumulative = 0.0
|
||||
prev_label = clip_chains[0].video_label
|
||||
|
||||
for i in range(1, n):
|
||||
cumulative += clip_chains[i - 1].duration
|
||||
offset = max(0.0, cumulative - transition_duration * i)
|
||||
|
||||
# 获取转场类型
|
||||
transition = transitions[i] if i < len(transitions) else "cut"
|
||||
xfade_transition = XFADE_TRANSITION_MAP.get(transition, "fade")
|
||||
|
||||
out_label = "outv" if i == n - 1 else f"xf{i}"
|
||||
|
||||
parts.append(
|
||||
f"[{prev_label}][{clip_chains[i].video_label}]"
|
||||
f"xfade=transition={xfade_transition}"
|
||||
f":duration={transition_duration}"
|
||||
f":offset={offset:.3f}"
|
||||
f"[{out_label}]"
|
||||
)
|
||||
prev_label = out_label
|
||||
|
||||
# 总时长减去转场重叠部分
|
||||
total_duration -= transition_duration * (n - 1)
|
||||
total_duration = max(0.0, total_duration)
|
||||
|
||||
# 音频:xfade 路径下的音频处理
|
||||
# 注意:使用 chain.audio_label 作为输入标签(与原实现保持一致)
|
||||
audio_chains = [c for c in clip_chains if c.audio_label]
|
||||
if len(audio_chains) >= 2:
|
||||
normalized_labels: list[str] = []
|
||||
for chain in audio_chains:
|
||||
norm_label = f"anorm_{chain.video_label}"
|
||||
audio_filters = [
|
||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||
f"atrim=0:{chain.duration}",
|
||||
"asetpts=PTS-STARTPTS",
|
||||
]
|
||||
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
|
||||
normalized_labels.append(norm_label)
|
||||
audio_inputs = "".join(f"[{label}]" for label in normalized_labels)
|
||||
parts.append(f"{audio_inputs}concat=n={len(normalized_labels)}:v=0:a=1[outa]")
|
||||
elif len(audio_chains) == 1:
|
||||
parts.append(f"[{audio_chains[0].audio_label}]acopy[outa]")
|
||||
|
||||
return ";".join(parts), total_duration
|
||||
|
||||
|
||||
# ── 完整 filter_complex 构建(策略选择) ────────────────────────────────────
|
||||
|
||||
|
||||
def build_filter_complex(
|
||||
clip_chains: list[ClipFilterChain],
|
||||
output_width: int,
|
||||
output_height: int,
|
||||
transition_duration: float,
|
||||
transitions: list[str],
|
||||
) -> tuple[str, float]:
|
||||
"""构建完整的 filter_complex 字符串(策略自动选择)。
|
||||
|
||||
策略:
|
||||
- 空列表:返回空字符串 + 0 时长
|
||||
- 单片段:直接输出(scale+pad+fps+trim 单链)
|
||||
- 多片段 + 全 cut:使用 concat 滤镜(高效)
|
||||
- 多片段 + 有转场:使用 xfade 滤镜链
|
||||
|
||||
Args:
|
||||
clip_chains: 各片段的滤镜链描述
|
||||
output_width: 输出宽度(目前单片段策略不使用,保留参数一致性)
|
||||
output_height: 输出高度(同上)
|
||||
transition_duration: 转场时长(秒)
|
||||
transitions: 每个片段对应的转场效果列表
|
||||
|
||||
Returns:
|
||||
(filter_complex_string, estimated_total_duration)
|
||||
"""
|
||||
n = len(clip_chains)
|
||||
|
||||
if n == 0:
|
||||
return "", 0.0
|
||||
|
||||
# 单片段
|
||||
if n == 1:
|
||||
chain = clip_chains[0]
|
||||
filter_str = chain_filters(chain.filters, chain.video_label)
|
||||
# 音频直通
|
||||
if chain.audio_label:
|
||||
filter_str += f";[0:a]{chain.audio_label}"
|
||||
total_duration = chain.duration
|
||||
return filter_str, total_duration
|
||||
|
||||
# 检查是否有转场
|
||||
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
||||
|
||||
if not has_transitions:
|
||||
return build_concat_filter(clip_chains)
|
||||
|
||||
# 有转场:使用 xfade
|
||||
return build_xfade_filter(
|
||||
clip_chains=clip_chains,
|
||||
transition_duration=transition_duration,
|
||||
transitions=transitions,
|
||||
)
|
||||
|
||||
|
||||
# ── 内部辅助:音频处理 ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _append_audio_concat(parts: list[str], clip_chains: list[ClipFilterChain]) -> None:
|
||||
"""追加音频归一化 + concat 滤镜链到 parts(concat 路径)。
|
||||
|
||||
与原实现保持一致:归一化输出标签复用 chain.audio_label,
|
||||
concat 直接使用 audio_label 作为输入。
|
||||
"""
|
||||
audio_chains = [c for c in clip_chains if c.audio_label]
|
||||
if not audio_chains:
|
||||
return
|
||||
|
||||
# 先 aformat 归一化,输出到 chain.audio_label
|
||||
for chain in audio_chains:
|
||||
audio_filters = [
|
||||
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||
f"atrim=0:{chain.duration}",
|
||||
"asetpts=PTS-STARTPTS",
|
||||
]
|
||||
parts.append(f"[{chain.input_index}:a]{','.join(audio_filters)}[{chain.audio_label}]")
|
||||
|
||||
# concat 滤镜(使用 audio_label 作为输入)
|
||||
audio_inputs = "".join(f"[{c.audio_label}]" for c in audio_chains)
|
||||
parts.append(f"{audio_inputs}concat=n={len(audio_chains)}:v=0:a=1[outa]")
|
||||
Executable
+700
@@ -0,0 +1,700 @@
|
||||
"""template_effect_mapper 单元测试 — 模板效果映射纯逻辑层。
|
||||
|
||||
覆盖:
|
||||
- VirtualClip / VirtualPlan 数据类
|
||||
- extract_intro_outro_from_clip_configs: intro/outro 配置提取
|
||||
- apply_template_clip_effects: 效果层映射
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from packages.domain.template_effect_mapper import (
|
||||
VirtualClip,
|
||||
VirtualPlan,
|
||||
apply_template_clip_effects,
|
||||
extract_intro_outro_from_clip_configs,
|
||||
)
|
||||
|
||||
# ── Mock ClipConfig ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MockClipConfig:
|
||||
"""模拟 TemplateClipConfig 对象。"""
|
||||
|
||||
clip_type: Any = "main"
|
||||
config: dict[str, Any] | None = None
|
||||
default_duration: float = 3.0
|
||||
text_template: str | None = None
|
||||
transition_effect: Any = "cut"
|
||||
|
||||
|
||||
# ── VirtualClip / VirtualPlan 数据类测试 ────────────────────────────────────
|
||||
|
||||
|
||||
class TestVirtualClip(unittest.TestCase):
|
||||
"""VirtualClip 数据类测试。"""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确。"""
|
||||
clip = VirtualClip(id="c1")
|
||||
self.assertEqual(clip.id, "c1")
|
||||
self.assertEqual(clip.plan_id, "")
|
||||
self.assertEqual(clip.clip_type, "main")
|
||||
self.assertEqual(clip.order, 0)
|
||||
self.assertEqual(clip.asset_id, "")
|
||||
self.assertEqual(clip.text_content, "")
|
||||
self.assertEqual(clip.start_time, 0.0)
|
||||
self.assertEqual(clip.duration, 0.0)
|
||||
self.assertEqual(clip.transition_effect, "cut")
|
||||
self.assertEqual(clip.transition_duration, 0.0)
|
||||
self.assertEqual(clip.playback_speed, 1.0)
|
||||
self.assertEqual(clip.status, "ready")
|
||||
self.assertEqual(clip.config, {})
|
||||
|
||||
def test_mutable(self):
|
||||
"""VirtualClip 是可变 dataclass(非 frozen)。"""
|
||||
clip = VirtualClip(id="c1", duration=5.0)
|
||||
clip.duration = 10.0
|
||||
self.assertEqual(clip.duration, 10.0)
|
||||
|
||||
def test_config_independent(self):
|
||||
"""每个 clip 的 config 是独立的 dict。"""
|
||||
clip1 = VirtualClip(id="c1")
|
||||
clip2 = VirtualClip(id="c2")
|
||||
clip1.config["key"] = "value"
|
||||
self.assertNotIn("key", clip2.config)
|
||||
|
||||
|
||||
class TestVirtualPlan(unittest.TestCase):
|
||||
"""VirtualPlan 数据类测试。"""
|
||||
|
||||
def test_default_values(self):
|
||||
"""默认值正确。"""
|
||||
plan = VirtualPlan(id="p1")
|
||||
self.assertEqual(plan.id, "p1")
|
||||
self.assertEqual(plan.name, "")
|
||||
self.assertEqual(plan.config, {})
|
||||
|
||||
def test_config_independent(self):
|
||||
"""每个 plan 的 config 是独立的 dict。"""
|
||||
plan1 = VirtualPlan(id="p1")
|
||||
plan2 = VirtualPlan(id="p2")
|
||||
plan1.config["key"] = "value"
|
||||
self.assertNotIn("key", plan2.config)
|
||||
|
||||
|
||||
# ── extract_intro_outro_from_clip_configs 测试 ──────────────────────────────
|
||||
|
||||
|
||||
class TestExtractIntroOutro(unittest.TestCase):
|
||||
"""extract_intro_outro_from_clip_configs 测试。"""
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表返回空 dict。"""
|
||||
result = extract_intro_outro_from_clip_configs([])
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_no_intro_no_outro(self):
|
||||
"""只有 main 类型,没有 intro/outro。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="main"),
|
||||
_MockClipConfig(clip_type="showcase"),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_intro_only_basic(self):
|
||||
"""只有 intro:基本字段。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="intro", default_duration=2.5),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertTrue(result["has_intro"])
|
||||
self.assertEqual(result["intro_type"], "text") # 默认
|
||||
self.assertEqual(result["intro_duration"], 2.5)
|
||||
self.assertNotIn("intro_text", result)
|
||||
self.assertNotIn("has_outro", result)
|
||||
|
||||
def test_intro_with_text_template(self):
|
||||
"""intro 带 text_template。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="intro",
|
||||
text_template="欢迎观看",
|
||||
),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result["intro_text"], "欢迎观看")
|
||||
|
||||
def test_intro_default_duration_zero(self):
|
||||
"""intro default_duration 为 0 时使用默认 3.0。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="intro", default_duration=0),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result["intro_duration"], 3.0)
|
||||
|
||||
def test_intro_with_config_extra_fields(self):
|
||||
"""intro config 中的额外字段被透传。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="intro",
|
||||
config={
|
||||
"intro_type": "video",
|
||||
"intro_text_color": "#ffffff",
|
||||
"intro_bg_color": "#000000",
|
||||
"intro_font_size": 48,
|
||||
"intro_video_url": "https://example.com/intro.mp4",
|
||||
"intro_video_path": "/tmp/intro.mp4",
|
||||
},
|
||||
),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result["intro_type"], "video")
|
||||
self.assertEqual(result["intro_text_color"], "#ffffff")
|
||||
self.assertEqual(result["intro_bg_color"], "#000000")
|
||||
self.assertEqual(result["intro_font_size"], 48)
|
||||
self.assertEqual(result["intro_video_url"], "https://example.com/intro.mp4")
|
||||
self.assertEqual(result["intro_video_path"], "/tmp/intro.mp4")
|
||||
|
||||
def test_outro_only_basic(self):
|
||||
"""只有 outro:基本字段。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="outro", default_duration=4.0),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertTrue(result["has_outro"])
|
||||
self.assertEqual(result["outro_type"], "text")
|
||||
self.assertEqual(result["outro_duration"], 4.0)
|
||||
self.assertNotIn("has_intro", result)
|
||||
|
||||
def test_outro_with_text_template(self):
|
||||
"""outro 带 text_template。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="outro",
|
||||
text_template="感谢观看",
|
||||
),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result["outro_text"], "感谢观看")
|
||||
|
||||
def test_outro_with_config_extra_fields(self):
|
||||
"""outro config 中的额外字段被透传。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="outro",
|
||||
config={
|
||||
"outro_type": "subscribe",
|
||||
"outro_text_color": "#ffffff",
|
||||
"outro_bg_color": "#333333",
|
||||
"outro_font_size": 36,
|
||||
"outro_follow_text": "关注我们",
|
||||
},
|
||||
),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result["outro_type"], "subscribe")
|
||||
self.assertEqual(result["outro_text_color"], "#ffffff")
|
||||
self.assertEqual(result["outro_bg_color"], "#333333")
|
||||
self.assertEqual(result["outro_font_size"], 36)
|
||||
self.assertEqual(result["outro_follow_text"], "关注我们")
|
||||
|
||||
def test_both_intro_and_outro(self):
|
||||
"""同时有 intro 和 outro。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="intro", default_duration=2.0),
|
||||
_MockClipConfig(clip_type="main"),
|
||||
_MockClipConfig(clip_type="outro", default_duration=3.0),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertTrue(result["has_intro"])
|
||||
self.assertEqual(result["intro_duration"], 2.0)
|
||||
self.assertTrue(result["has_outro"])
|
||||
self.assertEqual(result["outro_duration"], 3.0)
|
||||
|
||||
def test_multiple_intros_uses_first(self):
|
||||
"""多个 intro 只用第一个。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="intro", default_duration=2.0, text_template="第一"),
|
||||
_MockClipConfig(clip_type="intro", default_duration=5.0, text_template="第二"),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result["intro_duration"], 2.0)
|
||||
self.assertEqual(result["intro_text"], "第一")
|
||||
|
||||
def test_multiple_outros_uses_first(self):
|
||||
"""多个 outro 只用第一个。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="outro", default_duration=3.0, text_template="end1"),
|
||||
_MockClipConfig(clip_type="outro", default_duration=6.0, text_template="end2"),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertEqual(result["outro_duration"], 3.0)
|
||||
self.assertEqual(result["outro_text"], "end1")
|
||||
|
||||
def test_intro_config_none(self):
|
||||
"""intro.config 为 None 时正常工作。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="intro", config=None),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertTrue(result["has_intro"])
|
||||
self.assertEqual(result["intro_type"], "text") # 默认
|
||||
|
||||
def test_clip_type_enum_value(self):
|
||||
"""clip_type 是 Enum 时通过 .value 获取。"""
|
||||
|
||||
class _EnumType:
|
||||
value = "intro"
|
||||
|
||||
configs = [
|
||||
_MockClipConfig(clip_type=_EnumType(), default_duration=2.0),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertTrue(result["has_intro"])
|
||||
self.assertEqual(result["intro_duration"], 2.0)
|
||||
|
||||
def test_irrelevant_config_fields_ignored(self):
|
||||
"""intro/outro config 中非预期字段不被透传。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="intro",
|
||||
config={"random_key": "should_not_appear", "intro_type": "text"},
|
||||
),
|
||||
]
|
||||
result = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertNotIn("random_key", result)
|
||||
|
||||
|
||||
# ── apply_template_clip_effects 测试 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApplyTemplateClipEffects(unittest.TestCase):
|
||||
"""apply_template_clip_effects 效果层映射测试。"""
|
||||
|
||||
def test_empty_clips(self):
|
||||
"""空 clips 列表:不报错、不修改。"""
|
||||
configs = [_MockClipConfig(clip_type="main")]
|
||||
clips: list[VirtualClip] = []
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips, [])
|
||||
|
||||
def test_empty_configs(self):
|
||||
"""空 configs 列表:不修改 clips。"""
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, [], "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "cut")
|
||||
self.assertEqual(clips[0].playback_speed, 1.0)
|
||||
self.assertEqual(clips[0].config, {})
|
||||
|
||||
def test_no_main_configs(self):
|
||||
"""没有 main/showcase/b_roll 类型的 config:不修改 clips。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="intro",
|
||||
config={"color_grade": "warm"},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "cut")
|
||||
self.assertEqual(clips[0].config, {})
|
||||
|
||||
def test_transition_effect_applied(self):
|
||||
"""转场效果正确映射。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="main", transition_effect="fade"),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "fade")
|
||||
|
||||
def test_transition_duration_applied(self):
|
||||
"""转场时长从 config 中读取并映射。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
transition_effect="dissolve",
|
||||
config={"transition_duration": 0.8},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "dissolve")
|
||||
self.assertEqual(clips[0].transition_duration, 0.8)
|
||||
|
||||
def test_transition_duration_invalid_string(self):
|
||||
"""转场时长是无效字符串时忽略。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
transition_effect="fade",
|
||||
config={"transition_duration": "abc"},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_duration, 0.0)
|
||||
|
||||
def test_transition_duration_zero_ignored(self):
|
||||
"""转场时长 <= 0 时忽略。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
transition_effect="fade",
|
||||
config={"transition_duration": 0},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_duration, 0.0)
|
||||
|
||||
def test_cut_transition_skipped(self):
|
||||
"""cut 转场不覆盖默认值。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="main", transition_effect="cut"),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0, transition_effect="fade")]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "fade") # 保持原值
|
||||
|
||||
def test_color_grade_applied(self):
|
||||
"""color_grade 效果层映射。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"color_grade": "vivid"},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].config["color_grade"], "vivid")
|
||||
|
||||
def test_playback_speed_applied_to_config_and_field(self):
|
||||
"""playback_speed 同时映射到 config 和顶级字段。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"playback_speed": 1.5},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].config["playback_speed"], 1.5)
|
||||
self.assertEqual(clips[0].playback_speed, 1.5)
|
||||
|
||||
def test_speed_key_also_applies(self):
|
||||
"""speed key 也能设置 playback_speed 顶级字段。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"speed": 2.0},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].playback_speed, 2.0)
|
||||
self.assertEqual(clips[0].config["speed"], 2.0)
|
||||
|
||||
def test_playback_speed_takes_priority_over_speed(self):
|
||||
"""playback_speed 比 speed 优先级高。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"playback_speed": 1.2, "speed": 2.0},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].playback_speed, 1.2)
|
||||
|
||||
def test_speed_invalid_ignored(self):
|
||||
"""无效 speed 值忽略。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"speed": "fast"},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].playback_speed, 1.0)
|
||||
|
||||
def test_speed_zero_ignored(self):
|
||||
"""speed <= 0 时忽略。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"speed": 0},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].playback_speed, 1.0)
|
||||
|
||||
def test_chroma_key_and_filter_applied(self):
|
||||
"""chroma_key 和 filter 效果层映射。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={
|
||||
"chroma_key": {"color": "green", "tolerance": 0.1},
|
||||
"filter": "vintage",
|
||||
},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].config["chroma_key"]["color"], "green")
|
||||
self.assertEqual(clips[0].config["filter"], "vintage")
|
||||
|
||||
def test_reverse_effect_applied(self):
|
||||
"""reverse 效果层映射。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"reverse": True},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertTrue(clips[0].config["reverse"])
|
||||
|
||||
def test_existing_config_preserved(self):
|
||||
"""已有 config 字段被保留,效果层合并。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"color_grade": "warm", "speed": 1.5},
|
||||
),
|
||||
]
|
||||
clips = [
|
||||
VirtualClip(
|
||||
id="c1",
|
||||
duration=5.0,
|
||||
config={"role": "b_roll", "existing_key": "value"},
|
||||
)
|
||||
]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].config["role"], "b_roll")
|
||||
self.assertEqual(clips[0].config["existing_key"], "value")
|
||||
self.assertEqual(clips[0].config["color_grade"], "warm")
|
||||
self.assertEqual(clips[0].config["speed"], 1.5)
|
||||
|
||||
def test_corner_voice_excluded(self):
|
||||
"""corner_voice 类型的 clip 不应用效果映射。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
transition_effect="fade",
|
||||
config={"color_grade": "warm"},
|
||||
),
|
||||
]
|
||||
clips = [
|
||||
VirtualClip(id="c1", clip_type="main", duration=5.0),
|
||||
VirtualClip(id="c2", clip_type="corner_voice", duration=3.0),
|
||||
]
|
||||
apply_template_clip_effects(clips, configs, "VOICE_PIP")
|
||||
# main 类型被修改
|
||||
self.assertEqual(clips[0].transition_effect, "fade")
|
||||
self.assertEqual(clips[0].config.get("color_grade"), "warm")
|
||||
# corner_voice 不被修改
|
||||
self.assertEqual(clips[1].transition_effect, "cut")
|
||||
self.assertEqual(clips[1].config, {})
|
||||
|
||||
def test_showcase_and_b_roll_clip_types_used(self):
|
||||
"""showcase 和 b_roll 类型的 clip_config 也作为模板池。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="showcase",
|
||||
transition_effect="slideleft",
|
||||
),
|
||||
_MockClipConfig(
|
||||
clip_type="b_roll",
|
||||
transition_effect="dissolve",
|
||||
),
|
||||
]
|
||||
clips = [
|
||||
VirtualClip(id="c1", duration=5.0),
|
||||
VirtualClip(id="c2", duration=5.0),
|
||||
]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "slideleft")
|
||||
self.assertEqual(clips[1].transition_effect, "dissolve")
|
||||
|
||||
def test_cyclic_matching_more_clips_than_configs(self):
|
||||
"""素材比模板多时,最后一个模板复用。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"color_grade": "warm"},
|
||||
),
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"color_grade": "cool"},
|
||||
),
|
||||
]
|
||||
clips = [
|
||||
VirtualClip(id="c1", duration=5.0),
|
||||
VirtualClip(id="c2", duration=5.0),
|
||||
VirtualClip(id="c3", duration=5.0),
|
||||
VirtualClip(id="c4", duration=5.0),
|
||||
]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].config["color_grade"], "warm")
|
||||
self.assertEqual(clips[1].config["color_grade"], "cool")
|
||||
# 素材3和4都复用最后一个模板(cool)
|
||||
self.assertEqual(clips[2].config["color_grade"], "cool")
|
||||
self.assertEqual(clips[3].config["color_grade"], "cool")
|
||||
|
||||
def test_enum_transition_effect(self):
|
||||
"""transition_effect 是 Enum 时正确处理。"""
|
||||
|
||||
class _EnumEffect:
|
||||
value = "fade"
|
||||
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="main", transition_effect=_EnumEffect()),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "fade")
|
||||
|
||||
def test_config_none_handled(self):
|
||||
"""clip_config.config 为 None 时正常处理。"""
|
||||
configs = [
|
||||
_MockClipConfig(clip_type="main", transition_effect="fade", config=None),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].transition_effect, "fade")
|
||||
# config 为 None 时不应有 config 写入
|
||||
self.assertEqual(clips[0].config, {})
|
||||
|
||||
def test_clips_modified_in_place(self):
|
||||
"""clips 是就地修改的。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
transition_effect="dissolve",
|
||||
config={"color_grade": "warm"},
|
||||
),
|
||||
]
|
||||
clips = [VirtualClip(id="c1", duration=5.0)]
|
||||
result = apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(clips[0].transition_effect, "dissolve")
|
||||
|
||||
def test_three_clips_three_configs_matching(self):
|
||||
"""三素材三模板一一对应。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"color_grade": "warm"},
|
||||
transition_effect="fade",
|
||||
),
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"color_grade": "cool"},
|
||||
transition_effect="dissolve",
|
||||
),
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
config={"color_grade": "vintage"},
|
||||
transition_effect="wipeleft",
|
||||
),
|
||||
]
|
||||
clips = [
|
||||
VirtualClip(id="c1", duration=5.0),
|
||||
VirtualClip(id="c2", duration=5.0),
|
||||
VirtualClip(id="c3", duration=5.0),
|
||||
]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
self.assertEqual(clips[0].config["color_grade"], "warm")
|
||||
self.assertEqual(clips[0].transition_effect, "fade")
|
||||
self.assertEqual(clips[1].config["color_grade"], "cool")
|
||||
self.assertEqual(clips[1].transition_effect, "dissolve")
|
||||
self.assertEqual(clips[2].config["color_grade"], "vintage")
|
||||
self.assertEqual(clips[2].transition_effect, "wipeleft")
|
||||
|
||||
|
||||
# ── 集成测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEndToEndMapping(unittest.TestCase):
|
||||
"""端到端集成测试:intro_outro 提取 + 效果映射完整流程。"""
|
||||
|
||||
def test_full_template_pipeline(self):
|
||||
"""完整模板流水线:intro + main + outro,效果映射正确。"""
|
||||
configs = [
|
||||
_MockClipConfig(
|
||||
clip_type="intro",
|
||||
default_duration=2.0,
|
||||
text_template="精彩开始",
|
||||
config={"intro_type": "text", "intro_text_color": "#ffffff"},
|
||||
),
|
||||
_MockClipConfig(
|
||||
clip_type="main",
|
||||
transition_effect="fade",
|
||||
config={
|
||||
"color_grade": "warm",
|
||||
"playback_speed": 1.2,
|
||||
"transition_duration": 0.6,
|
||||
},
|
||||
),
|
||||
_MockClipConfig(
|
||||
clip_type="b_roll",
|
||||
transition_effect="dissolve",
|
||||
config={"color_grade": "vivid", "speed": 1.5},
|
||||
),
|
||||
_MockClipConfig(
|
||||
clip_type="outro",
|
||||
default_duration=3.0,
|
||||
text_template="谢谢观看",
|
||||
config={"outro_type": "subscribe", "outro_follow_text": "关注+点赞"},
|
||||
),
|
||||
]
|
||||
|
||||
# 效果映射
|
||||
clips = [
|
||||
VirtualClip(id="c1", clip_type="main", duration=5.0),
|
||||
VirtualClip(id="c2", clip_type="main", duration=4.0),
|
||||
VirtualClip(id="c3", clip_type="main", duration=3.0),
|
||||
]
|
||||
apply_template_clip_effects(clips, configs, "ONE_TAKE")
|
||||
|
||||
# 第一个片段用第一个 main 模板
|
||||
self.assertEqual(clips[0].transition_effect, "fade")
|
||||
self.assertEqual(clips[0].transition_duration, 0.6)
|
||||
self.assertEqual(clips[0].playback_speed, 1.2)
|
||||
self.assertEqual(clips[0].config["color_grade"], "warm")
|
||||
|
||||
# 第二个片段用第二个 b_roll 模板
|
||||
self.assertEqual(clips[1].transition_effect, "dissolve")
|
||||
self.assertEqual(clips[1].playback_speed, 1.5)
|
||||
self.assertEqual(clips[1].config["color_grade"], "vivid")
|
||||
|
||||
# 第三个片段复用最后一个模板
|
||||
self.assertEqual(clips[2].transition_effect, "dissolve")
|
||||
self.assertEqual(clips[2].playback_speed, 1.5)
|
||||
|
||||
# intro/outro 提取
|
||||
intro_outro = extract_intro_outro_from_clip_configs(configs)
|
||||
self.assertTrue(intro_outro["has_intro"])
|
||||
self.assertEqual(intro_outro["intro_duration"], 2.0)
|
||||
self.assertEqual(intro_outro["intro_text"], "精彩开始")
|
||||
self.assertEqual(intro_outro["intro_text_color"], "#ffffff")
|
||||
self.assertTrue(intro_outro["has_outro"])
|
||||
self.assertEqual(intro_outro["outro_duration"], 3.0)
|
||||
self.assertEqual(intro_outro["outro_text"], "谢谢观看")
|
||||
self.assertEqual(intro_outro["outro_follow_text"], "关注+点赞")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+855
@@ -0,0 +1,855 @@
|
||||
"""video_filter_builder 单元测试 — FFmpeg 滤镜构建纯逻辑层。
|
||||
|
||||
覆盖:
|
||||
- ClipFilterChain 数据类
|
||||
- 常量与映射表
|
||||
- build_clip_filter:单片段滤镜链
|
||||
- chain_filters:滤镜串联工具
|
||||
- has_audio:音频流判断
|
||||
- build_concat_filter:concat 滤镜
|
||||
- build_xfade_filter:xfade 转场滤镜
|
||||
- build_filter_complex:策略选择(空/单片段/concat/xfade)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||
from packages.domain.template_clip_config import TransitionEffect
|
||||
from packages.domain.video_filter_builder import (
|
||||
DEFAULT_CLIP_DURATION,
|
||||
DEFAULT_FPS,
|
||||
DEFAULT_OUTPUT_HEIGHT,
|
||||
DEFAULT_OUTPUT_WIDTH,
|
||||
DEFAULT_TRANSITION_DURATION,
|
||||
ClipFilterChain,
|
||||
XFADE_TRANSITION_MAP,
|
||||
build_clip_filter,
|
||||
build_concat_filter,
|
||||
build_filter_complex,
|
||||
build_xfade_filter,
|
||||
chain_filters,
|
||||
has_audio,
|
||||
)
|
||||
|
||||
# ── 辅助:构造 EditPlanClip ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_clip(
|
||||
clip_id: str = "clip-1",
|
||||
duration: float = 5.0,
|
||||
start_time: float = 0.0,
|
||||
clip_type: str = "video",
|
||||
asset_id: str | None = "asset-1",
|
||||
) -> EditPlanClip:
|
||||
"""构造一个测试用 EditPlanClip。"""
|
||||
return EditPlanClip(
|
||||
id=clip_id,
|
||||
plan_id="plan-1",
|
||||
asset_id=asset_id,
|
||||
clip_type=clip_type,
|
||||
duration=duration,
|
||||
start_time=start_time,
|
||||
order=0,
|
||||
status=EditPlanClipStatus.READY,
|
||||
)
|
||||
|
||||
|
||||
# ── ClipFilterChain 数据类测试 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClipFilterChain(unittest.TestCase):
|
||||
"""ClipFilterChain 数据类测试。"""
|
||||
|
||||
def test_immutable(self):
|
||||
"""ClipFilterChain 是 frozen dataclass,不可修改。"""
|
||||
chain = ClipFilterChain(
|
||||
clip_id="c1",
|
||||
input_index=0,
|
||||
video_label="v0",
|
||||
audio_label="a0",
|
||||
filters=["scale=1280:720"],
|
||||
duration=5.0,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
chain.duration = 10.0 # type: ignore[misc]
|
||||
|
||||
def test_fields(self):
|
||||
"""所有字段正确存储。"""
|
||||
chain = ClipFilterChain(
|
||||
clip_id="c1",
|
||||
input_index=2,
|
||||
video_label="v2",
|
||||
audio_label=None,
|
||||
filters=["fps=25", "trim=0:3"],
|
||||
duration=3.0,
|
||||
)
|
||||
self.assertEqual(chain.clip_id, "c1")
|
||||
self.assertEqual(chain.input_index, 2)
|
||||
self.assertEqual(chain.video_label, "v2")
|
||||
self.assertIsNone(chain.audio_label)
|
||||
self.assertEqual(chain.filters, ["fps=25", "trim=0:3"])
|
||||
self.assertEqual(chain.duration, 3.0)
|
||||
|
||||
|
||||
# ── 常量测试 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestConstants(unittest.TestCase):
|
||||
"""常量与映射表测试。"""
|
||||
|
||||
def test_default_output_size(self):
|
||||
"""默认输出分辨率 1280x720。"""
|
||||
self.assertEqual(DEFAULT_OUTPUT_WIDTH, 1280)
|
||||
self.assertEqual(DEFAULT_OUTPUT_HEIGHT, 720)
|
||||
|
||||
def test_default_fps(self):
|
||||
"""默认帧率 25。"""
|
||||
self.assertEqual(DEFAULT_FPS, 25)
|
||||
|
||||
def test_default_transition_duration(self):
|
||||
"""默认转场时长 0.5 秒。"""
|
||||
self.assertEqual(DEFAULT_TRANSITION_DURATION, 0.5)
|
||||
|
||||
def test_default_clip_duration(self):
|
||||
"""默认片段时长 5 秒。"""
|
||||
self.assertEqual(DEFAULT_CLIP_DURATION, 5.0)
|
||||
|
||||
def test_xfade_transition_map_keys(self):
|
||||
"""xfade 映射包含所有转场类型。"""
|
||||
self.assertIn(TransitionEffect.FADE, XFADE_TRANSITION_MAP)
|
||||
self.assertIn(TransitionEffect.SLIDE_LEFT, XFADE_TRANSITION_MAP)
|
||||
self.assertIn(TransitionEffect.SLIDE_RIGHT, XFADE_TRANSITION_MAP)
|
||||
self.assertIn(TransitionEffect.DISSOLVE, XFADE_TRANSITION_MAP)
|
||||
self.assertIn(TransitionEffect.WIPE, XFADE_TRANSITION_MAP)
|
||||
|
||||
def test_xfade_transition_map_values(self):
|
||||
"""xfade 映射值为 FFmpeg 合法 transition 名称。"""
|
||||
self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.FADE], "fade")
|
||||
self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.SLIDE_LEFT], "slideleft")
|
||||
self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.SLIDE_RIGHT], "slideright")
|
||||
self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.DISSOLVE], "dissolve")
|
||||
self.assertEqual(XFADE_TRANSITION_MAP[TransitionEffect.WIPE], "wipeleft")
|
||||
|
||||
|
||||
# ── chain_filters 测试 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestChainFilters(unittest.TestCase):
|
||||
"""chain_filters 滤镜串联工具测试。"""
|
||||
|
||||
def test_single_filter(self):
|
||||
"""单个滤镜。"""
|
||||
result = chain_filters(["scale=1280:720"], "v0")
|
||||
self.assertEqual(result, "[0:v]scale=1280:720[v0]")
|
||||
|
||||
def test_multiple_filters(self):
|
||||
"""多个滤镜用逗号串联。"""
|
||||
result = chain_filters(["scale=1280:720", "fps=25", "trim=0:5"], "v1")
|
||||
self.assertEqual(result, "[0:v]scale=1280:720,fps=25,trim=0:5[v1]")
|
||||
|
||||
def test_empty_filters(self):
|
||||
"""空滤镜列表。"""
|
||||
result = chain_filters([], "v0")
|
||||
self.assertEqual(result, "[0:v][v0]")
|
||||
|
||||
def test_custom_input_label(self):
|
||||
"""自定义输入标签。"""
|
||||
result = chain_filters(["fps=30"], "out", input_label="v0")
|
||||
self.assertEqual(result, "[v0]fps=30[out]")
|
||||
|
||||
|
||||
# ── has_audio 测试 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHasAudio(unittest.TestCase):
|
||||
"""has_audio 音频流判断测试。"""
|
||||
|
||||
def test_all_have_audio(self):
|
||||
"""所有片段都有音频。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", "a1", [], 3.0),
|
||||
]
|
||||
self.assertTrue(has_audio(chains))
|
||||
|
||||
def test_some_have_audio(self):
|
||||
"""部分片段有音频。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
]
|
||||
self.assertTrue(has_audio(chains))
|
||||
|
||||
def test_none_have_audio(self):
|
||||
"""没有片段有音频。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
]
|
||||
self.assertFalse(has_audio(chains))
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表返回 False。"""
|
||||
self.assertFalse(has_audio([]))
|
||||
|
||||
|
||||
# ── build_clip_filter 测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildClipFilter(unittest.TestCase):
|
||||
"""build_clip_filter 单片段滤镜链测试。"""
|
||||
|
||||
def test_basic_video_clip(self):
|
||||
"""普通视频片段生成完整滤镜链。"""
|
||||
clip = _make_clip(duration=5.0, clip_type="video")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
|
||||
self.assertEqual(chain.clip_id, "clip-1")
|
||||
self.assertEqual(chain.input_index, 0)
|
||||
self.assertEqual(chain.video_label, "v0")
|
||||
self.assertEqual(chain.audio_label, "a0")
|
||||
self.assertEqual(chain.duration, 5.0)
|
||||
# 应有 7 个滤镜:scale, pad, format, fps, setpts, trim, setpts
|
||||
self.assertEqual(len(chain.filters), 7)
|
||||
|
||||
def test_filter_order(self):
|
||||
"""滤镜顺序:scale → pad → format → fps → setpts → trim → setpts。"""
|
||||
clip = _make_clip(duration=3.0, clip_type="video")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
|
||||
self.assertTrue(chain.filters[0].startswith("scale="))
|
||||
self.assertTrue(chain.filters[1].startswith("pad="))
|
||||
self.assertEqual(chain.filters[2], "format=yuv420p")
|
||||
self.assertTrue(chain.filters[3].startswith("fps="))
|
||||
self.assertTrue(chain.filters[4].startswith("setpts="))
|
||||
self.assertTrue(chain.filters[5].startswith("trim="))
|
||||
self.assertEqual(chain.filters[6], "setpts=PTS-STARTPTS")
|
||||
|
||||
def test_scale_force_original_aspect_ratio(self):
|
||||
"""scale 使用 decrease 保持比例。"""
|
||||
clip = _make_clip(duration=5.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIn("force_original_aspect_ratio=decrease", chain.filters[0])
|
||||
|
||||
def test_pad_centered_black(self):
|
||||
"""pad 居中 + 黑边。"""
|
||||
clip = _make_clip(duration=5.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIn("(ow-iw)/2:(oh-ih)/2:black", chain.filters[1])
|
||||
|
||||
def test_format_yuv420p(self):
|
||||
"""像素格式统一为 yuv420p。"""
|
||||
clip = _make_clip(duration=5.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertEqual(chain.filters[2], "format=yuv420p")
|
||||
|
||||
def test_custom_resolution(self):
|
||||
"""自定义输出分辨率。"""
|
||||
clip = _make_clip(duration=5.0)
|
||||
chain = build_clip_filter(clip, 0, 1920, 1080, 30)
|
||||
self.assertIn("scale=1920:1080", chain.filters[0])
|
||||
self.assertIn("pad=1920:1080", chain.filters[1])
|
||||
self.assertEqual(chain.filters[3], "fps=30")
|
||||
|
||||
def test_zero_duration_uses_default(self):
|
||||
"""duration <= 0 时使用默认时长。"""
|
||||
clip = _make_clip(duration=0.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertEqual(chain.duration, DEFAULT_CLIP_DURATION)
|
||||
self.assertIn(f"trim=0:{DEFAULT_CLIP_DURATION}", chain.filters[5])
|
||||
|
||||
def test_negative_duration_uses_default(self):
|
||||
"""负时长也使用默认时长。"""
|
||||
clip = _make_clip(duration=-1.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertEqual(chain.duration, DEFAULT_CLIP_DURATION)
|
||||
|
||||
def test_start_time_offset(self):
|
||||
"""start_time > 0 时 setpts 带偏移。"""
|
||||
clip = _make_clip(duration=3.0, start_time=2.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIn("PTS-STARTPTS+2.0/TB", chain.filters[4])
|
||||
|
||||
def test_zero_start_time_no_offset(self):
|
||||
"""start_time = 0 时 setpts 不带偏移。"""
|
||||
clip = _make_clip(duration=3.0, start_time=0.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertEqual(chain.filters[4], "setpts=PTS-STARTPTS")
|
||||
|
||||
def test_title_clip_no_audio(self):
|
||||
"""title 类型片段没有音频。"""
|
||||
clip = _make_clip(clip_type="title")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIsNone(chain.audio_label)
|
||||
|
||||
def test_subtitle_clip_no_audio(self):
|
||||
"""subtitle 类型片段没有音频。"""
|
||||
clip = _make_clip(clip_type="subtitle")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIsNone(chain.audio_label)
|
||||
|
||||
def test_video_clip_has_audio(self):
|
||||
"""video 类型片段有音频。"""
|
||||
clip = _make_clip(clip_type="video")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertEqual(chain.audio_label, "a0")
|
||||
|
||||
def test_image_clip_has_audio(self):
|
||||
"""image 类型片段有音频标签(可能有BGM)。"""
|
||||
clip = _make_clip(clip_type="image")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIsNotNone(chain.audio_label)
|
||||
|
||||
def test_clip_type_case_insensitive(self):
|
||||
"""clip_type 大小写不敏感。"""
|
||||
clip = _make_clip(clip_type="TITLE")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIsNone(chain.audio_label)
|
||||
|
||||
def test_empty_clip_type_has_audio(self):
|
||||
"""空 clip_type 默认有音频。"""
|
||||
clip = _make_clip(clip_type="")
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIsNotNone(chain.audio_label)
|
||||
|
||||
def test_input_index_reflected_in_labels(self):
|
||||
"""input_index 反映在 video_label 和 audio_label 中。"""
|
||||
clip = _make_clip()
|
||||
chain = build_clip_filter(clip, 3, 1280, 720, 25)
|
||||
self.assertEqual(chain.video_label, "v3")
|
||||
self.assertEqual(chain.audio_label, "a3")
|
||||
|
||||
def test_zero_fps_skipped(self):
|
||||
"""fps = 0 时跳过 fps 滤镜。"""
|
||||
clip = _make_clip(duration=5.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 0)
|
||||
# 少了 fps 滤镜:scale, pad, format, setpts, trim, setpts = 6个
|
||||
self.assertEqual(len(chain.filters), 6)
|
||||
self.assertFalse(any(f.startswith("fps=") for f in chain.filters))
|
||||
|
||||
def test_negative_fps_skipped(self):
|
||||
"""fps < 0 时也跳过 fps 滤镜。"""
|
||||
clip = _make_clip(duration=5.0)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, -1)
|
||||
self.assertEqual(len(chain.filters), 6)
|
||||
|
||||
def test_trim_uses_duration(self):
|
||||
"""trim 时长等于 clip.duration。"""
|
||||
clip = _make_clip(duration=7.5)
|
||||
chain = build_clip_filter(clip, 0, 1280, 720, 25)
|
||||
self.assertIn("trim=0:7.5", chain.filters[5])
|
||||
|
||||
|
||||
# ── build_concat_filter 测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildConcatFilter(unittest.TestCase):
|
||||
"""build_concat_filter 拼接滤镜测试。"""
|
||||
|
||||
def test_empty_clips(self):
|
||||
"""空列表返回空字符串和 0 时长。"""
|
||||
filter_str, duration = build_concat_filter([])
|
||||
self.assertEqual(filter_str, "")
|
||||
self.assertEqual(duration, 0.0)
|
||||
|
||||
def test_single_clip_no_audio(self):
|
||||
"""单片段无音频:视频滤镜 + concat(n=1)。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, ["scale=1280:720"], 5.0),
|
||||
]
|
||||
filter_str, duration = build_concat_filter(chains)
|
||||
|
||||
self.assertIn("[0:v]scale=1280:720[v0]", filter_str)
|
||||
self.assertIn("[v0]concat=n=1:v=1:a=0[outv]", filter_str)
|
||||
self.assertEqual(duration, 5.0)
|
||||
# 没有音频相关
|
||||
self.assertNotIn("[outa]", filter_str)
|
||||
|
||||
def test_single_clip_with_audio(self):
|
||||
"""单片段有音频:视频 + 音频归一化 + concat。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", ["scale=1280:720"], 5.0),
|
||||
]
|
||||
filter_str, duration = build_concat_filter(chains)
|
||||
|
||||
self.assertIn("[0:v]scale=1280:720[v0]", filter_str)
|
||||
self.assertIn("[v0]concat=n=1:v=1:a=0[outv]", filter_str)
|
||||
# 音频归一化
|
||||
self.assertIn("[0:a]aformat=sample_rates=48000", filter_str)
|
||||
self.assertIn("stereo:sample_fmts=fltp", filter_str)
|
||||
self.assertIn("atrim=0:5.0", filter_str)
|
||||
self.assertIn("[a0]", filter_str)
|
||||
# 音频 concat(n=1)
|
||||
self.assertIn("[a0]concat=n=1:v=0:a=1[outa]", filter_str)
|
||||
self.assertEqual(duration, 5.0)
|
||||
|
||||
def test_two_clips_no_audio(self):
|
||||
"""两片段无音频:两个视频滤镜 + concat(n=2)。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, ["fps=25"], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0),
|
||||
]
|
||||
filter_str, duration = build_concat_filter(chains)
|
||||
|
||||
self.assertIn("[0:v]fps=25[v0]", filter_str)
|
||||
self.assertIn("[1:v]fps=25[v1]", filter_str)
|
||||
self.assertIn("[v0][v1]concat=n=2:v=1:a=0[outv]", filter_str)
|
||||
self.assertEqual(duration, 8.0)
|
||||
|
||||
def test_two_clips_with_audio(self):
|
||||
"""两片段都有音频:视频 concat + 音频归一化 + 音频 concat。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", "a1", ["fps=25"], 3.0),
|
||||
]
|
||||
filter_str, duration = build_concat_filter(chains)
|
||||
|
||||
# 视频
|
||||
self.assertIn("[v0][v1]concat=n=2:v=1:a=0[outv]", filter_str)
|
||||
# 音频归一化
|
||||
self.assertIn(
|
||||
"[0:a]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:5.0,asetpts=PTS-STARTPTS[a0]",
|
||||
filter_str,
|
||||
)
|
||||
self.assertIn(
|
||||
"[1:a]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:3.0,asetpts=PTS-STARTPTS[a1]",
|
||||
filter_str,
|
||||
)
|
||||
# 音频 concat
|
||||
self.assertIn("[a0][a1]concat=n=2:v=0:a=1[outa]", filter_str)
|
||||
self.assertEqual(duration, 8.0)
|
||||
|
||||
def test_mixed_audio_some_none(self):
|
||||
"""部分有音频部分没有:只有有音频的片段参与音频 concat。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0),
|
||||
ClipFilterChain("c3", 2, "v2", "a2", ["fps=25"], 4.0),
|
||||
]
|
||||
filter_str, duration = build_concat_filter(chains)
|
||||
|
||||
# 视频 concat 有 3 个输入
|
||||
self.assertIn("[v0][v1][v2]concat=n=3:v=1:a=0[outv]", filter_str)
|
||||
# 音频 concat 只有 2 个输入
|
||||
self.assertIn("[a0][a2]concat=n=2:v=0:a=1[outa]", filter_str)
|
||||
# 片段 1 没有音频归一化
|
||||
self.assertNotIn("[1:a]", filter_str)
|
||||
self.assertEqual(duration, 12.0)
|
||||
|
||||
def test_three_clips_total_duration(self):
|
||||
"""三片段总时长为各片段之和。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 2.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
ClipFilterChain("c3", 2, "v2", None, [], 4.0),
|
||||
]
|
||||
_, duration = build_concat_filter(chains)
|
||||
self.assertEqual(duration, 9.0)
|
||||
|
||||
def test_audio_format_normalization(self):
|
||||
"""音频归一化包含 aformat/atrim/asetpts。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
|
||||
]
|
||||
filter_str, _ = build_concat_filter(chains)
|
||||
|
||||
self.assertIn("aformat=sample_rates=48000", filter_str)
|
||||
self.assertIn("channel_layouts=stereo", filter_str)
|
||||
self.assertIn("sample_fmts=fltp", filter_str)
|
||||
self.assertIn("atrim=0:5.0", filter_str)
|
||||
self.assertIn("asetpts=PTS-STARTPTS", filter_str)
|
||||
|
||||
def test_filter_parts_separated_by_semicolon(self):
|
||||
"""各滤镜部分用分号分隔。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", ["fps=25"], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", "a1", ["fps=25"], 3.0),
|
||||
]
|
||||
filter_str, _ = build_concat_filter(chains)
|
||||
parts = filter_str.split(";")
|
||||
# 2 视频 + 2 音频归一化 + 1 视频 concat + 1 音频 concat = 6
|
||||
self.assertEqual(len(parts), 6)
|
||||
|
||||
|
||||
# ── build_xfade_filter 测试 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildXfadeFilter(unittest.TestCase):
|
||||
"""build_xfade_filter 转场滤镜测试。"""
|
||||
|
||||
def test_empty_clips(self):
|
||||
"""空列表返回空字符串和 0 时长。"""
|
||||
filter_str, duration = build_xfade_filter([], 0.5, [])
|
||||
self.assertEqual(filter_str, "")
|
||||
self.assertEqual(duration, 0.0)
|
||||
|
||||
def test_single_clip_no_audio(self):
|
||||
"""单片段无音频:视频滤镜 + copy。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, ["scale=1280:720"], 5.0),
|
||||
]
|
||||
filter_str, duration = build_xfade_filter(chains, 0.5, ["fade"])
|
||||
|
||||
self.assertIn("[0:v]scale=1280:720[v0]", filter_str)
|
||||
self.assertIn("[v0]copy[outv]", filter_str)
|
||||
self.assertEqual(duration, 5.0)
|
||||
# 单片段 xfade 没有音频输出
|
||||
self.assertNotIn("[outa]", filter_str)
|
||||
|
||||
def test_single_clip_with_audio(self):
|
||||
"""单片段有音频:xfade 路径下单片段不输出音频(与原实现一致)。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", ["scale=1280:720"], 5.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["fade"])
|
||||
# 单片段 xfade 没有音频输出
|
||||
self.assertNotIn("[outa]", filter_str)
|
||||
self.assertNotIn("acopy", filter_str)
|
||||
|
||||
def test_two_clips_fade_transition(self):
|
||||
"""两片段 fade 转场:xfade 滤镜结构正确。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, ["fps=25"], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, ["fps=25"], 3.0),
|
||||
]
|
||||
filter_str, duration = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
|
||||
# 两个视频滤镜链
|
||||
self.assertIn("[0:v]fps=25[v0]", filter_str)
|
||||
self.assertIn("[1:v]fps=25[v1]", filter_str)
|
||||
# xfade 转场
|
||||
self.assertIn("xfade=transition=fade", filter_str)
|
||||
self.assertIn(":duration=0.5", filter_str)
|
||||
self.assertIn("[outv]", filter_str)
|
||||
# 总时长 = 5 + 3 - 0.5 = 7.5
|
||||
self.assertAlmostEqual(duration, 7.5)
|
||||
|
||||
def test_two_clips_offset_calculation(self):
|
||||
"""转场 offset = 第一个片段时长 - 转场时长。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
|
||||
# offset = 5.0 - 0.5 * 1 = 4.5
|
||||
self.assertIn(":offset=4.500", filter_str)
|
||||
|
||||
def test_three_clips_chain(self):
|
||||
"""三片段链式转场:两个 xfade,中间用 xf1 标签。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 4.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
ClipFilterChain("c3", 2, "v2", None, [], 5.0),
|
||||
]
|
||||
filter_str, duration = build_xfade_filter(chains, 0.5, ["cut", "fade", "dissolve"])
|
||||
|
||||
# 第一个转场输出到 xf1
|
||||
self.assertIn("[xf1]", filter_str)
|
||||
# 第二个转场输出到 outv
|
||||
self.assertIn("[xf1][v2]xfade=transition=dissolve", filter_str)
|
||||
self.assertIn("[outv]", filter_str)
|
||||
# 总时长 = 4 + 3 + 5 - 0.5 * 2 = 11.0
|
||||
self.assertAlmostEqual(duration, 11.0)
|
||||
|
||||
def test_three_clips_offsets(self):
|
||||
"""三片段两个转场的 offset 计算正确。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 4.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
ClipFilterChain("c3", 2, "v2", None, [], 5.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade", "slideleft"])
|
||||
|
||||
# 第一个 offset = 4.0 - 0.5*1 = 3.5
|
||||
# 第二个 offset = (4.0+3.0) - 0.5*2 = 7.0 - 1.0 = 6.0
|
||||
self.assertIn(":offset=3.500", filter_str)
|
||||
self.assertIn(":offset=6.000", filter_str)
|
||||
|
||||
def test_all_transition_types(self):
|
||||
"""所有转场类型都能正确映射。"""
|
||||
transitions = [
|
||||
(TransitionEffect.FADE, "fade"),
|
||||
(TransitionEffect.SLIDE_LEFT, "slideleft"),
|
||||
(TransitionEffect.SLIDE_RIGHT, "slideright"),
|
||||
(TransitionEffect.DISSOLVE, "dissolve"),
|
||||
(TransitionEffect.WIPE, "wipeleft"),
|
||||
]
|
||||
for effect, expected_name in transitions:
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 3.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 2.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", effect])
|
||||
self.assertIn(
|
||||
f"xfade=transition={expected_name}",
|
||||
filter_str,
|
||||
f"Transition {effect} should map to {expected_name}",
|
||||
)
|
||||
|
||||
def test_unknown_transition_defaults_to_fade(self):
|
||||
"""未知转场类型默认使用 fade。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 3.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 2.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "nonexistent"])
|
||||
self.assertIn("xfade=transition=fade", filter_str)
|
||||
|
||||
def test_cut_still_uses_fade(self):
|
||||
"""cut 类型在 xfade 路径下也映射为 fade(因为走了 xfade 分支)。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 3.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 2.0),
|
||||
]
|
||||
# 只要有一个非 cut 就走 xfade,cut 的那个也用 fade 作为默认
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["fade", "cut"])
|
||||
# 第二个转场是 cut,默认用 fade
|
||||
self.assertIn("xfade=transition=fade", filter_str)
|
||||
|
||||
def test_transitions_shorter_than_clips(self):
|
||||
"""transitions 列表比 clip 短时,超出部分默认 cut→fade。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 2.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 2.0),
|
||||
ClipFilterChain("c3", 2, "v2", None, [], 2.0),
|
||||
]
|
||||
# 只给 1 个 transition(索引0),索引1和2会越界
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["fade"])
|
||||
# 应该有两个 xfade,都用 fade(第二个是默认值)
|
||||
self.assertEqual(filter_str.count("xfade=transition=fade"), 2)
|
||||
|
||||
def test_zero_transition_duration(self):
|
||||
"""转场时长为 0 时不减时长。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
]
|
||||
_, duration = build_xfade_filter(chains, 0.0, ["cut", "fade"])
|
||||
self.assertAlmostEqual(duration, 8.0)
|
||||
|
||||
def test_total_duration_not_negative(self):
|
||||
"""总时长不会为负数。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 0.1),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 0.1),
|
||||
]
|
||||
_, duration = build_xfade_filter(chains, 10.0, ["cut", "fade"])
|
||||
self.assertGreaterEqual(duration, 0.0)
|
||||
|
||||
def test_two_clips_with_audio_normalize_and_concat(self):
|
||||
"""两片段都有音频:归一化 + concat。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", "a1", [], 3.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
|
||||
# 音频归一化(注意:xfade 路径用 audio_label 作为输入,与原实现一致)
|
||||
self.assertIn(
|
||||
"[a0]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:5.0,asetpts=PTS-STARTPTS[anorm_v0]",
|
||||
filter_str,
|
||||
)
|
||||
self.assertIn(
|
||||
"[a1]aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp,atrim=0:3.0,asetpts=PTS-STARTPTS[anorm_v1]",
|
||||
filter_str,
|
||||
)
|
||||
# 音频 concat
|
||||
self.assertIn("[anorm_v0][anorm_v1]concat=n=2:v=0:a=1[outa]", filter_str)
|
||||
|
||||
def test_single_audio_in_xfade_acopy(self):
|
||||
"""xfade 路径下只有一个音频片段时直接 acopy。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", "a0", [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
|
||||
self.assertIn("[a0]acopy[outa]", filter_str)
|
||||
# 没有音频归一化
|
||||
self.assertNotIn("aformat", filter_str)
|
||||
self.assertNotIn("concat=n=", filter_str)
|
||||
|
||||
def test_no_audio_in_xfade(self):
|
||||
"""xfade 路径下都没有音频时没有 outa。"""
|
||||
chains = [
|
||||
ClipFilterChain("c1", 0, "v0", None, [], 5.0),
|
||||
ClipFilterChain("c2", 1, "v1", None, [], 3.0),
|
||||
]
|
||||
filter_str, _ = build_xfade_filter(chains, 0.5, ["cut", "fade"])
|
||||
self.assertNotIn("[outa]", filter_str)
|
||||
self.assertNotIn("acopy", filter_str)
|
||||
|
||||
|
||||
# ── build_filter_complex 测试 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildFilterComplex(unittest.TestCase):
|
||||
"""build_filter_complex 策略选择测试。"""
|
||||
|
||||
def _chain(self, idx: int, has_audio: bool = True) -> ClipFilterChain:
|
||||
return ClipFilterChain(
|
||||
clip_id=f"c{idx}",
|
||||
input_index=idx,
|
||||
video_label=f"v{idx}",
|
||||
audio_label=f"a{idx}" if has_audio else None,
|
||||
filters=["fps=25"],
|
||||
duration=3.0,
|
||||
)
|
||||
|
||||
def test_empty_clips(self):
|
||||
"""空列表返回空字符串和 0 时长。"""
|
||||
filter_str, duration = build_filter_complex([], 1280, 720, 0.5, [])
|
||||
self.assertEqual(filter_str, "")
|
||||
self.assertEqual(duration, 0.0)
|
||||
|
||||
def test_single_clip_direct_output(self):
|
||||
"""单片段:直接输出单链滤镜。"""
|
||||
chains = [self._chain(0)]
|
||||
filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, [])
|
||||
|
||||
self.assertIn("[0:v]fps=25[v0]", filter_str)
|
||||
self.assertNotIn("concat", filter_str)
|
||||
self.assertNotIn("xfade", filter_str)
|
||||
self.assertEqual(duration, 3.0)
|
||||
|
||||
def test_single_clip_audio_passthrough(self):
|
||||
"""单片段有音频:音频直通标签。"""
|
||||
chains = [self._chain(0, has_audio=True)]
|
||||
filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, [])
|
||||
# 单片段音频:[0:a]a0(直通标签)
|
||||
self.assertIn("[0:a]a0", filter_str)
|
||||
|
||||
def test_single_clip_no_audio(self):
|
||||
"""单片段无音频:没有音频部分。"""
|
||||
chains = [self._chain(0, has_audio=False)]
|
||||
filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, [])
|
||||
self.assertNotIn("[0:a]", filter_str)
|
||||
self.assertNotIn("[outa]", filter_str)
|
||||
|
||||
def test_multiple_all_cut_uses_concat(self):
|
||||
"""多片段 + 全 cut:使用 concat 滤镜。"""
|
||||
chains = [self._chain(0), self._chain(1), self._chain(2)]
|
||||
transitions = [TransitionEffect.CUT, TransitionEffect.CUT, TransitionEffect.CUT]
|
||||
filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, transitions)
|
||||
|
||||
self.assertIn("concat=n=3:v=1:a=0[outv]", filter_str)
|
||||
self.assertNotIn("xfade", filter_str)
|
||||
self.assertEqual(duration, 9.0)
|
||||
|
||||
def test_multiple_one_transition_uses_xfade(self):
|
||||
"""多片段 + 有一个非 cut 转场:使用 xfade。"""
|
||||
chains = [self._chain(0), self._chain(1)]
|
||||
transitions = [TransitionEffect.CUT, TransitionEffect.FADE]
|
||||
filter_str, duration = build_filter_complex(chains, 1280, 720, 0.5, transitions)
|
||||
|
||||
self.assertIn("xfade=transition=fade", filter_str)
|
||||
self.assertNotIn("concat=n=2:v=1:a=0", filter_str)
|
||||
self.assertAlmostEqual(duration, 5.5) # 3 + 3 - 0.5
|
||||
|
||||
def test_string_cut_value(self):
|
||||
"""字符串 'cut' 也被识别为无转场。"""
|
||||
chains = [self._chain(0), self._chain(1)]
|
||||
transitions = ["cut", "cut"]
|
||||
filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions)
|
||||
self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str)
|
||||
self.assertNotIn("xfade", filter_str)
|
||||
|
||||
def test_mixed_cut_and_transition(self):
|
||||
"""混合 cut 和转场:走 xfade 路径。"""
|
||||
chains = [self._chain(0), self._chain(1), self._chain(2)]
|
||||
transitions = ["cut", TransitionEffect.FADE, "cut"]
|
||||
filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions)
|
||||
self.assertIn("xfade", filter_str)
|
||||
|
||||
def test_all_dissolve_transition(self):
|
||||
"""全部 dissolve 转场。"""
|
||||
chains = [self._chain(0), self._chain(1)]
|
||||
transitions = [TransitionEffect.CUT, TransitionEffect.DISSOLVE]
|
||||
filter_str, _ = build_filter_complex(chains, 1280, 720, 0.5, transitions)
|
||||
self.assertIn("xfade=transition=dissolve", filter_str)
|
||||
|
||||
|
||||
# ── 集成测试:build_clip_filter + build_filter_complex 端到端 ────────────────
|
||||
|
||||
|
||||
class TestEndToEndFilterBuilding(unittest.TestCase):
|
||||
"""端到端集成测试:从 EditPlanClip 到完整 filter_complex。"""
|
||||
|
||||
def test_two_video_clips_concat(self):
|
||||
"""两个视频片段走 concat 路径的完整流程。"""
|
||||
clip1 = _make_clip("c1", duration=5.0, clip_type="video")
|
||||
clip2 = _make_clip("c2", duration=3.0, clip_type="video")
|
||||
|
||||
chain1 = build_clip_filter(clip1, 0, 1280, 720, 25)
|
||||
chain2 = build_clip_filter(clip2, 1, 1280, 720, 25)
|
||||
|
||||
filter_str, duration = build_filter_complex(
|
||||
[chain1, chain2],
|
||||
1280,
|
||||
720,
|
||||
0.5,
|
||||
[TransitionEffect.CUT, TransitionEffect.CUT],
|
||||
)
|
||||
|
||||
# 有两个视频滤镜链
|
||||
self.assertIn("[0:v]", filter_str)
|
||||
self.assertIn("[1:v]", filter_str)
|
||||
# concat 输出
|
||||
self.assertIn("[outv]", filter_str)
|
||||
self.assertIn("[outa]", filter_str)
|
||||
# 总时长
|
||||
self.assertAlmostEqual(duration, 8.0)
|
||||
# 结构:2视频 + 2音频 + 1视频concat + 1音频concat = 6 段
|
||||
self.assertEqual(len(filter_str.split(";")), 6)
|
||||
|
||||
def test_two_clips_with_xfade(self):
|
||||
"""两个片段走 xfade 转场的完整流程。"""
|
||||
clip1 = _make_clip("c1", duration=5.0, clip_type="video")
|
||||
clip2 = _make_clip("c2", duration=4.0, clip_type="video")
|
||||
|
||||
chain1 = build_clip_filter(clip1, 0, 1920, 1080, 30)
|
||||
chain2 = build_clip_filter(clip2, 1, 1920, 1080, 30)
|
||||
|
||||
filter_str, duration = build_filter_complex(
|
||||
[chain1, chain2],
|
||||
1920,
|
||||
1080,
|
||||
0.5,
|
||||
[TransitionEffect.CUT, TransitionEffect.FADE],
|
||||
)
|
||||
|
||||
self.assertIn("xfade=transition=fade:duration=0.5", filter_str)
|
||||
self.assertIn("[outv]", filter_str)
|
||||
# 总时长减去转场
|
||||
self.assertAlmostEqual(duration, 8.5) # 5 + 4 - 0.5
|
||||
|
||||
def test_title_plus_video(self):
|
||||
"""title 片段(无音频)+ video 片段(有音频)。"""
|
||||
clip1 = _make_clip("c1", duration=2.0, clip_type="title")
|
||||
clip2 = _make_clip("c2", duration=5.0, clip_type="video")
|
||||
|
||||
chain1 = build_clip_filter(clip1, 0, 1280, 720, 25)
|
||||
chain2 = build_clip_filter(clip2, 1, 1280, 720, 25)
|
||||
|
||||
# title 无音频,video 有音频
|
||||
self.assertIsNone(chain1.audio_label)
|
||||
self.assertIsNotNone(chain2.audio_label)
|
||||
|
||||
# concat 路径
|
||||
filter_str, _ = build_filter_complex(
|
||||
[chain1, chain2],
|
||||
1280,
|
||||
720,
|
||||
0.5,
|
||||
[TransitionEffect.CUT, TransitionEffect.CUT],
|
||||
)
|
||||
# 音频 concat 只有 1 个输入(片段2)
|
||||
self.assertIn("[a1]concat=n=1:v=0:a=1[outa]", filter_str)
|
||||
self.assertNotIn("[a0]", filter_str)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user