4aeb1d5b66
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m41s
CI/CD Pipeline / Integration Tests (push) Successful in 3m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m35s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m27s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 6m31s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 9m14s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 4m10s
CI/CD Pipeline / Unit Tests (push) Successful in 17m43s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m28s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m4s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 6m2s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
fix(render): 修复生成视频时长短于模板要求时长 (#1614)
191 lines
6.5 KiB
Python
Executable File
191 lines
6.5 KiB
Python
Executable File
"""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 应相对于累积输出时长
|
||
# offset = 累积输出中,转场开始的时间点
|
||
# = first_input_dur - transition_duration
|
||
# 这样每个转场之间的"纯内容"时长等于原始 clip 时长
|
||
offset = max(0.0, first_input_dur - transition_duration)
|
||
|
||
# 安全钳制: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)
|