Files
xiaoxia-saas/packages/domain/xfade_builder.py
T
CI Bot 637f18f220
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 24s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m25s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 47s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m26s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m59s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m36s
AI Code Review / AI Code Review (pull_request) Successful in 1m49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m16s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m4s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m25s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m57s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m29s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m20s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 21s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 1229h11m32s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 1229h12m11s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 1229h12m17s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 1229h12m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1229h16m15s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 1229h16m16s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 1229h16m19s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 1229h16m17s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 1229h35m31s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 1229h35m33s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 1229h49m2s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 1229h45m0s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 1230h8m18s
test(wave121): 抽离xfade_builder领域模型 + 77单测
- 从 ffmpeg_utils.py 抽离 xfade 转场纯逻辑到 packages/domain/xfade_builder.py
- ffmpeg_utils.py: 414→308行 (-26%)
- 新增 77 个单测,覆盖常量/chain_filters/resolve_xfade_transition/build_xfade_filter_chain
- 原有 166 个测试无回归
- 保持向后兼容:ffmpeg_utils 模块保留全部公开接口作为薄包装
2026-07-27 09:28:08 +08:00

188 lines
6.3 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""XFade 转场滤镜构建 — 纯逻辑,无 FFmpeg 依赖.
抽离自 apps/worker/video_processing/ffmpeg_utils.py,包含:
- xfade 转场效果名称映射
- 滤镜链串联工具
- xfade 转场滤镜链构建(带时长钳制)
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_TRANSITION_DURATION = 0.5
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
# "cut" 为特殊值:硬切,不使用 xfade(由调用方特殊处理)
XFADE_TRANSITION_MAP: dict[str, str] = {
# 基础
"fade": "fade",
"dissolve": "dissolve",
"crossfade": "dissolve",
"crossdissolve": "dissolve",
# 滑入系列
"slideleft": "slideleft",
"slide_left": "slideleft",
"slideright": "slideright",
"slide_right": "slideright",
"slideup": "slideup",
"slide_up": "slideup",
"slidedown": "slidedown",
"slide_down": "slidedown",
"slide": "slideleft", # 默认向左滑
# 缩放
"zoom": "zoomin",
"zoomin": "zoomin",
"zoomout": "zoomout",
# 擦除系列
"wipe": "wipeleft", # 默认向左擦
"wipeleft": "wipeleft",
"wiperight": "wiperight",
"wipeup": "wipeup",
"wipedown": "wipedown",
# 特殊效果
"circlecrop": "circlecrop",
"circle": "circlecrop",
"rectcrop": "rectcrop",
"rect": "rectcrop",
}
# 所有支持的转场效果名称(用户侧输入)
SUPPORTED_TRANSITIONS: set[str] = set(XFADE_TRANSITION_MAP.keys())
# 所有 FFmpeg xfade transition 名称(输出侧)
XFade_TRANSITION_NAMES: set[str] = set(XFADE_TRANSITION_MAP.values())
# ── 工具函数 ─────────────────────────────────────────────────────────────────
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串.
例:chain_filters(["scale=1280:720", "fps=25"], "v0")
→ "[0:v]scale=1280:720,fps=25[v0]"
Args:
filters: 滤镜字符串列表
output_label: 输出标签(不带方括号)
input_label: 输入标签(不带方括号),默认 "0:v"
Returns:
完整的滤镜字符串
"""
filter_body = ",".join(filters)
return f"[{input_label}]{filter_body}[{output_label}]"
def resolve_xfade_transition(transition_name: Any) -> str:
"""将转场效果名称映射为 FFmpeg xfade transition 名称.
支持 TransitionEffect 枚举值和字符串名称,未知值回退到 "fade"。
Args:
transition_name: 转场名称(字符串或带 .value 属性的枚举)
Returns:
FFmpeg xfade transition 名称
"""
# 兼容 TransitionEffect 枚举(有 .value 属性)
if hasattr(transition_name, "value"):
transition_name = transition_name.value
return XFADE_TRANSITION_MAP.get(transition_name, "fade")
# ── xfade 滤镜链构建 ─────────────────────────────────────────────────────────
def build_xfade_filter_chain(
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链.
对每步 xfade 自动钳制 transition duration,确保
``offset + td ≤ first_input_duration``,避免 FFmpeg exit 234。
Args:
clip_durations: 每个片段的时长(必须与 trim 后的实际时长一致)
clip_video_labels: 每个片段的视频流标签(如 "v0", "v1")
transitions: 每个片段对应的转场效果(第一个片段的转场被忽略)
transition_duration: 转场时长(秒)
output_label: 最终输出标签
Returns:
(filter_string, estimated_total_duration)
"""
n = len(clip_durations)
parts: list[str] = []
if n == 0:
return "", 0.0
if n == 1:
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
return ";".join(parts), clip_durations[0]
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
cumulative = 0.0
prev_label = clip_video_labels[0]
total_transition = 0.0 # 累计已使用的转场时长
for i in range(1, n):
cumulative += clip_durations[i - 1]
# 当前 xfade 的第一个输入时长
if i == 1:
first_input_dur = clip_durations[0]
else:
first_input_dur = cumulative - total_transition
# 原始 offset 计算
offset = max(0.0, cumulative - transition_duration * i)
# 安全钳制:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
safe_td = min(transition_duration, available)
# 同时不能超过剩余总时长
remaining = max(0.0, sum(clip_durations) - cumulative)
safe_td = min(safe_td, remaining)
# 同时不能超过当前第二个输入(单个片段)的时长
safe_td = min(safe_td, clip_durations[i])
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = resolve_xfade_transition(transition)
if i == n - 1:
out_label = output_label
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_video_labels[i]}]"
f"xfade=transition={xfade_transition}"
f":duration={safe_td:.3f}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
total_transition += safe_td
# 总时长减去转场重叠部分
total_duration = sum(clip_durations) - total_transition
return ";".join(parts), max(0.0, total_duration)