Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia-bot 7dbff690cd feat: 转场特效引擎 — TransitionEngine + 14种转场预设 + 时长边界校验 + 降级策略
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m14s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 3m11s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m32s
2026-07-14 10:59:17 +08:00
12 changed files with 958 additions and 6 deletions
@@ -0,0 +1,34 @@
"""add transition_duration to edit_plan_clips
Revision ID: 039_transition_duration
Revises: 038_error_retry
Create Date: 2026-07-14 09:00:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "039_transition_duration"
down_revision = "038_error_retry"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plan_clips",
sa.Column(
"transition_duration",
sa.Float(),
nullable=False,
server_default="0.0",
),
)
def downgrade() -> None:
op.drop_column("edit_plan_clips", "transition_duration")
+2
View File
@@ -146,6 +146,7 @@ class AIRecommendClipItem(BaseModel):
text_content: str = Field(default="", description="文字内容")
duration: float = Field(..., ge=0.0, description="片段时长(秒)")
transition_effect: str = Field(default="cut", description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长(秒),0 表示使用默认值")
asset_id: str = Field(default="", description="关联素材 ID")
start_time: float = Field(default=0.0, ge=0.0, description="素材截取起始时间(秒)")
config: dict[str, Any] = Field(default_factory=dict, description="片段额外配置")
@@ -209,6 +210,7 @@ class _PlanClipItem(BaseModel):
start_time: float
duration: float
transition_effect: str
transition_duration: float
status: str
config: Optional[dict[str, Any]] = None
created_at: datetime
+1
View File
@@ -210,6 +210,7 @@ def generate_from_template(
start_time=c.start_time,
duration=c.duration,
transition_effect=c.transition_effect,
transition_duration=c.transition_duration,
status=c.status.value if hasattr(c.status, "value") else c.status,
config=c.config,
created_at=c.created_at,
+6
View File
@@ -281,6 +281,7 @@ class EditPlanService:
start_time: float = 0.0,
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""创建片段
@@ -301,6 +302,7 @@ class EditPlanService:
start_time=start_time,
duration=duration,
transition_effect=transition_effect,
transition_duration=transition_duration,
config=config,
)
created = self._clip_repo.create(clip)
@@ -324,6 +326,7 @@ class EditPlanService:
start_time: Optional[float] = None,
duration: Optional[float] = None,
transition_effect: Optional[str] = None,
transition_duration: Optional[float] = None,
config: Optional[dict[str, Any]] = None,
) -> EditPlanClip:
"""更新片段
@@ -346,6 +349,9 @@ class EditPlanService:
transition_effect=(
transition_effect.strip() if transition_effect is not None else existing.transition_effect
),
transition_duration=(
transition_duration if transition_duration is not None else existing.transition_duration
),
status=existing.status,
config=config if config is not None else existing.config,
created_at=existing.created_at,
+21 -2
View File
@@ -25,8 +25,14 @@ DEFAULT_FPS = 25
# 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",
@@ -35,9 +41,22 @@ XFADE_TRANSITION_MAP: dict[str, str] = {
"slide_up": "slideup",
"slidedown": "slidedown",
"slide_down": "slidedown",
"dissolve": "dissolve",
"wipe": "wipeleft",
"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",
}
DEFAULT_TRANSITION_DURATION = 0.5
+381
View File
@@ -0,0 +1,381 @@
"""转场特效引擎 — Phase 8 智能增强.
基于 FFmpeg xfade 滤镜的统一转场抽象层,提供:
1. 转场类型枚举与预设管理
2. 转场配置解析与边界校验
3. 降级策略(不支持的转场自动 fallback 到硬切)
4. xfade 滤镜链构建(封装底层 ffmpeg_utils
新增转场只需在 TransitionType 中加一项 + 在 XFADE_TRANSITION_MAP 中映射。
"""
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from enum import Enum
class StrEnum(str, Enum):
pass
from video_processing.ffmpeg_utils import build_xfade_filter_chain
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# 转场时长范围(秒)
MIN_TRANSITION_DURATION = 0.3
MAX_TRANSITION_DURATION = 2.0
DEFAULT_TRANSITION_DURATION = 0.5
# 硬切(无转场)
CUT_TRANSITION = "cut"
# ── 转场类型枚举 ──────────────────────────────────────────────────────────────
class TransitionType(StrEnum):
"""支持的转场效果类型.
每种类型对应 FFmpeg xfade filter 的一个 transition 值。
新增转场只需在此添加一项,并在 _FFMPEG_XFADE_MAP 中映射。
"""
# 硬切(无转场效果,直接拼接)
CUT = "cut"
# 淡入淡出(最常用,默认 fallback)
FADE = "fade"
# 溶解(交叉溶解)
DISSOLVE = "dissolve"
# 滑入系列
SLIDE_LEFT = "slideleft"
SLIDE_RIGHT = "slideright"
SLIDE_UP = "slideup"
SLIDE_DOWN = "slidedown"
# 缩放
ZOOM = "zoom"
# 擦除系列
WIPE_LEFT = "wipeleft"
WIPE_RIGHT = "wiperight"
WIPE_UP = "wipeup"
WIPE_DOWN = "wipedown"
# 圆形扩散
CIRCLE_CROP = "circlecrop"
# 矩形覆盖
RECT_CROP = "rectcrop"
@classmethod
def all_supported(cls) -> list[str]:
"""返回所有支持的转场类型名称列表."""
return [t.value for t in cls if t != cls.CUT]
@classmethod
def is_supported(cls, name: str) -> bool:
"""检查转场类型是否支持(不区分大小写和下划线)."""
normalized = _normalize_transition_name(name)
return normalized in _NAME_TO_ENUM_MAP
# ── 名称 → 枚举 映射(支持多种别名)──────────────────────────────────────────
def _normalize_transition_name(name: str) -> str:
"""标准化转场名称:小写 + 去下划线."""
return name.lower().replace("_", "").replace("-", "")
# 构建别名映射
_NAME_TO_ENUM_MAP: dict[str, TransitionType] = {}
for _t in TransitionType:
_NAME_TO_ENUM_MAP[_normalize_transition_name(_t.value)] = _t
# 额外的别名
_ALIASES: dict[str, TransitionType] = {
"dissolve": TransitionType.DISSOLVE,
"crossfade": TransitionType.DISSOLVE,
"crossdissolve": TransitionType.DISSOLVE,
"fadein": TransitionType.FADE,
"fadeout": TransitionType.FADE,
"fadeblack": TransitionType.FADE,
"slide": TransitionType.SLIDE_LEFT, # 默认向左滑
"wipe": TransitionType.WIPE_LEFT, # 默认向左擦
"zoomin": TransitionType.ZOOM,
"zoomout": TransitionType.ZOOM,
"circle": TransitionType.CIRCLE_CROP,
"rect": TransitionType.RECT_CROP,
}
for _alias, _type in _ALIASES.items():
_key = _normalize_transition_name(_alias)
if _key not in _NAME_TO_ENUM_MAP:
_NAME_TO_ENUM_MAP[_key] = _type
# ── TransitionType → FFmpeg xfade transition 名称映射 ─────────────────────────
_FFMPEG_XFADE_MAP: dict[TransitionType, str] = {
TransitionType.FADE: "fade",
TransitionType.DISSOLVE: "dissolve",
TransitionType.SLIDE_LEFT: "slideleft",
TransitionType.SLIDE_RIGHT: "slideright",
TransitionType.SLIDE_UP: "slideup",
TransitionType.SLIDE_DOWN: "slidedown",
TransitionType.ZOOM: "zoomin",
TransitionType.WIPE_LEFT: "wipeleft",
TransitionType.WIPE_RIGHT: "wiperight",
TransitionType.WIPE_UP: "wipeup",
TransitionType.WIPE_DOWN: "wipedown",
TransitionType.CIRCLE_CROP: "circlecrop",
TransitionType.RECT_CROP: "rectcrop",
}
# ── 转场配置 ──────────────────────────────────────────────────────────────────
@dataclass(slots=True)
class TransitionConfig:
"""转场效果配置.
Attributes:
effect: 转场效果名称(见 TransitionType
duration: 转场时长(秒),范围 0.3~2.0,默认 0.5
"""
effect: str = CUT_TRANSITION
duration: float = DEFAULT_TRANSITION_DURATION
@classmethod
def parse(cls, effect: str | None = None, duration: float | None = None) -> "TransitionConfig":
"""解析并验证转场配置,自动处理边界和降级.
Args:
effect: 转场效果名称(None 或空则使用默认 cut)
duration: 转场时长(None 则使用默认值)
Returns:
验证后的 TransitionConfig
"""
# 处理 effect
final_effect = CUT_TRANSITION
if effect and effect.strip():
effect_clean = effect.strip()
if TransitionType.is_supported(effect_clean):
final_effect = _resolve_transition_enum(effect_clean).value
elif effect_clean.lower() == CUT_TRANSITION:
final_effect = CUT_TRANSITION
else:
# 降级:不支持的转场 → 硬切,不阻断渲染
logger.warning(
"不支持的转场效果 '%s',已降级为硬切(cut",
effect_clean,
)
final_effect = CUT_TRANSITION
# 处理 duration:边界钳制
final_duration = DEFAULT_TRANSITION_DURATION
if duration is not None:
try:
d = float(duration)
if d < MIN_TRANSITION_DURATION:
logger.warning(
"转场时长 %.3fs 小于最小值 %.1fs,已钳制到最小值",
d,
MIN_TRANSITION_DURATION,
)
final_duration = MIN_TRANSITION_DURATION
elif d > MAX_TRANSITION_DURATION:
logger.warning(
"转场时长 %.3fs 大于最大值 %.1fs,已钳制到最大值",
d,
MAX_TRANSITION_DURATION,
)
final_duration = MAX_TRANSITION_DURATION
else:
final_duration = d
except (TypeError, ValueError):
logger.warning("无效的转场时长 '%s',使用默认值 %.1fs", duration, DEFAULT_TRANSITION_DURATION)
final_duration = DEFAULT_TRANSITION_DURATION
return cls(effect=final_effect, duration=final_duration)
@property
def is_cut(self) -> bool:
"""是否为硬切(无转场效果)."""
return self.effect == CUT_TRANSITION
@property
def ffmpeg_transition(self) -> str:
"""获取对应的 FFmpeg xfade transition 名称."""
if self.is_cut:
return ""
enum_type = _resolve_transition_enum(self.effect)
return _FFMPEG_XFADE_MAP.get(enum_type, "fade")
def _resolve_transition_enum(name: str) -> TransitionType:
"""将名称解析为 TransitionType 枚举,必须先通过 is_supported 校验."""
normalized = _normalize_transition_name(name)
return _NAME_TO_ENUM_MAP.get(normalized, TransitionType.FADE)
# ── 转场引擎 ──────────────────────────────────────────────────────────────────
class TransitionEngine:
"""转场特效引擎.
封装转场配置验证、降级策略和 xfade 滤镜链构建,
供 UnifiedRenderService 等上层调用。
用法::
engine = TransitionEngine(default_duration=0.5)
config = engine.resolve_config("fade", 0.8)
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0, 5.0],
clip_video_labels=["v0", "v1", "v2"],
transitions=["cut", "fade", "dissolve"],
)
"""
def __init__(self, default_duration: float = DEFAULT_TRANSITION_DURATION) -> None:
"""初始化转场引擎.
Args:
default_duration: 默认转场时长(秒),用于未指定时长的 clip
"""
self._default_duration = default_duration
def resolve_config(
self,
effect: str | None = None,
duration: float | None = None,
) -> TransitionConfig:
"""解析单个转场配置,应用验证和降级.
Args:
effect: 转场效果名称
duration: 转场时长
Returns:
验证后的 TransitionConfig
"""
# 若未指定 duration,使用引擎默认值
dur = duration if duration is not None else self._default_duration
return TransitionConfig.parse(effect=effect, duration=dur)
def resolve_clip_transitions(
self,
clip_transitions: list[str],
clip_durations: list[float] | None = None,
) -> list[TransitionConfig]:
"""批量解析 clip 级别的转场配置.
Args:
clip_transitions: 每个 clip 的转场效果名称列表
clip_durations: 每个 clip 的时长列表(用于验证转场时长不超过片段时长)
Returns:
TransitionConfig 列表
"""
configs: list[TransitionConfig] = []
for i, effect in enumerate(clip_transitions):
cfg = self.resolve_config(effect=effect)
# 额外校验:转场时长不能超过对应 clip 时长的一半(保守限制)
if clip_durations and i < len(clip_durations) and not cfg.is_cut:
max_safe_duration = max(MIN_TRANSITION_DURATION, clip_durations[i] * 0.5)
if cfg.duration > max_safe_duration:
cfg = TransitionConfig(effect=cfg.effect, duration=max_safe_duration)
configs.append(cfg)
return configs
def build_xfade_chain(
self,
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float | None = None,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链.
对每步转场应用验证和降级,然后调用底层 ffmpeg_utils 构建。
Args:
clip_durations: 每个片段的时长
clip_video_labels: 每个片段的视频流标签
transitions: 每个片段对应的转场效果
transition_duration: 统一转场时长,None 则使用引擎默认值
output_label: 最终输出标签
Returns:
(filter_string, estimated_total_duration)
"""
if len(clip_durations) <= 1:
return build_xfade_filter_chain(
clip_durations=clip_durations,
clip_video_labels=clip_video_labels,
transitions=transitions,
transition_duration=transition_duration or self._default_duration,
output_label=output_label,
)
# 解析所有转场配置
resolved = self.resolve_clip_transitions(transitions, clip_durations)
resolved_effects = [c.effect for c in resolved]
# 使用统一的时长(取各转场中最大的时长作为基准,底层会做每步钳制)
dur = transition_duration or self._default_duration
if not dur:
dur = max(c.duration for c in resolved) if resolved else DEFAULT_TRANSITION_DURATION
# 调用底层构建
return build_xfade_filter_chain(
clip_durations=clip_durations,
clip_video_labels=clip_video_labels,
transitions=resolved_effects,
transition_duration=dur,
output_label=output_label,
)
@staticmethod
def supported_transitions() -> list[dict[str, str]]:
"""获取所有支持的转场效果列表(用于 API 返回给前端).
Returns:
[{name, display_name, category}, ...]
"""
return [
{"name": "cut", "display_name": "硬切", "category": "basic"},
{"name": "fade", "display_name": "淡入淡出", "category": "basic"},
{"name": "dissolve", "display_name": "溶解", "category": "basic"},
{"name": "slideleft", "display_name": "左滑入", "category": "slide"},
{"name": "slideright", "display_name": "右滑入", "category": "slide"},
{"name": "slideup", "display_name": "上滑入", "category": "slide"},
{"name": "slidedown", "display_name": "下滑入", "category": "slide"},
{"name": "zoom", "display_name": "缩放", "category": "zoom"},
{"name": "wipeleft", "display_name": "左擦除", "category": "wipe"},
{"name": "wiperight", "display_name": "右擦除", "category": "wipe"},
{"name": "wipeup", "display_name": "上擦除", "category": "wipe"},
{"name": "wipedown", "display_name": "下擦除", "category": "wipe"},
{"name": "circlecrop", "display_name": "圆形扩散", "category": "special"},
{"name": "rectcrop", "display_name": "矩形扩散", "category": "special"},
]
@@ -36,7 +36,6 @@ from video_processing.ffmpeg_utils import (
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
FFMPEG_BIN,
build_xfade_filter_chain,
probe_duration,
probe_video_info,
run_ffmpeg,
@@ -46,6 +45,7 @@ from video_processing.pip_engine import PiPConfig, PiPEngine, PiPLayerConfig
from video_processing.render_audio import RenderContext, merge_audio_video, mix_audio
from video_processing.render_subtitles import generate_ass_subtitles
from video_processing.subtitle_generator import generate_ass_from_timeline
from video_processing.transition_engine import TransitionEngine
from video_processing.trim_engine import TrimConfig, TrimEngine, extract_trim_from_clip_config
from video_processing.tts_engine import TtsEngine
from video_processing.watermark_engine import WatermarkConfig, WatermarkEngine
@@ -70,6 +70,7 @@ class ResolvedClip:
start_time: float = 0.0
duration: float = 0.0 # 0 表示使用素材完整时长
transition_effect: str = "cut"
transition_duration: float = 0.0 # 0 表示使用全局默认值
config: dict[str, Any] = field(default_factory=dict)
# 运行时填充
@@ -182,6 +183,7 @@ class UnifiedRenderService:
self.transition_duration = transition_duration
self.asr_service = asr_service
self.bgm_path = bgm_path
self._transition_engine = TransitionEngine(default_duration=transition_duration)
def render(self) -> RenderResult:
"""执行渲染,返回 RenderResult.
@@ -1173,6 +1175,7 @@ class UnifiedRenderService:
start_time=final_start,
duration=final_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0,
config=clip_config,
actual_duration=actual_duration,
trim_config=effective_trim,
@@ -1323,18 +1326,25 @@ class UnifiedRenderService:
# 使用 trim 后的有效时长,与 Step 1 的 trim=duration 保持一致
layer_durations = [UnifiedRenderService._clip_effective_duration(all_clips[i]) for i in layer_clip_indices]
layer_transitions = [all_clips[i].transition_effect for i in layer_clip_indices]
layer_transition_durations = [all_clips[i].transition_duration for i in layer_clip_indices]
if len(layer_labels) == 1:
# 单 clip 层,直接使用预处理标签
layer_output_labels[layer.role] = layer_labels[0]
else:
# 多 clip 层,用 xfade 串联
# 多 clip 层,用 TransitionEngine 构建转场链
out_label = f"{layer.role}_merged"
xfade_filter, _ = build_xfade_filter_chain(
# 计算该层使用的转场时长(取首个非零值,否则用默认)
layer_dur = 0.0
for d in layer_transition_durations:
if d > 0:
layer_dur = d
break
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=self.transition_duration,
transition_duration=layer_dur if layer_dur > 0 else None,
output_label=out_label,
)
if xfade_filter:
+8
View File
@@ -858,6 +858,14 @@
"type": "VARCHAR(20)",
"unique": false
},
{
"index": false,
"name": "transition_duration",
"nullable": false,
"primary_key": false,
"type": "FLOAT",
"unique": false
},
{
"index": true,
"name": "status",
+3
View File
@@ -54,6 +54,7 @@ class SQLAlchemyEditPlanClipRepository:
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
status=clip.status,
config=clip.config,
)
@@ -76,6 +77,7 @@ class SQLAlchemyEditPlanClipRepository:
model.start_time = clip.start_time
model.duration = clip.duration
model.transition_effect = clip.transition_effect
model.transition_duration = clip.transition_duration
model.status = clip.status
model.config = clip.config
model.updated_at = clip.updated_at
@@ -120,6 +122,7 @@ class SQLAlchemyEditPlanClipRepository:
start_time=model.start_time or 0.0,
duration=model.duration or 0.0,
transition_effect=model.transition_effect or "cut",
transition_duration=getattr(model, "transition_duration", 0.0) or 0.0,
status=EditPlanClipStatus(model.status) if model.status else EditPlanClipStatus.PENDING,
config=model.config or {},
created_at=model.created_at,
@@ -196,6 +196,7 @@ class EditPlanClipModel(Base):
start_time = Column(Float, nullable=False, default=0.0)
duration = Column(Float, nullable=False, default=0.0)
transition_effect = Column(String(20), nullable=False, default="cut")
transition_duration = Column(Float, nullable=False, default=0.0)
status = Column(String(20), nullable=False, default="pending", index=True)
config = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
+3
View File
@@ -50,6 +50,7 @@ class EditPlanClip:
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0 # 0 表示使用全局默认值
status: EditPlanClipStatus = EditPlanClipStatus.PENDING
config: dict[str, Any] = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -68,6 +69,7 @@ class EditPlanClip:
start_time: float = 0.0,
duration: float = 0.0,
transition_effect: str = "cut",
transition_duration: float = 0.0,
config: dict[str, Any] | None = None,
) -> EditPlanClip:
"""创建剪辑计划片段"""
@@ -91,6 +93,7 @@ class EditPlanClip:
start_time=start_time,
duration=duration,
transition_effect=transition_effect.strip() or "cut",
transition_duration=max(0.0, transition_duration),
status=EditPlanClipStatus.PENDING,
config=config or {},
)
+484
View File
@@ -0,0 +1,484 @@
"""转场特效引擎单测 — Phase 8 智能增强."""
from __future__ import annotations
import pytest
from video_processing.transition_engine import (
CUT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
MAX_TRANSITION_DURATION,
MIN_TRANSITION_DURATION,
TransitionConfig,
TransitionEngine,
TransitionType,
_normalize_transition_name,
)
# ── TransitionType 枚举测试 ──────────────────────────────────────────────────
class TestTransitionType:
"""TransitionType 枚举测试."""
def test_all_supported_count(self):
"""支持的转场类型数量(不含cut."""
supported = TransitionType.all_supported()
# 至少 8 种:fade, dissolve, slide*4, zoom, wipe*4, circlecrop, rectcrop
assert len(supported) >= 8
assert "fade" in supported
assert "dissolve" in supported
assert "zoom" in supported
assert "circlecrop" in supported
assert "rectcrop" in supported
def test_slide_directions(self):
"""四个方向的滑入转场都支持."""
assert TransitionType.is_supported("slideleft")
assert TransitionType.is_supported("slideright")
assert TransitionType.is_supported("slideup")
assert TransitionType.is_supported("slidedown")
def test_wipe_directions(self):
"""四个方向的擦除转场都支持."""
assert TransitionType.is_supported("wipeleft")
assert TransitionType.is_supported("wiperight")
assert TransitionType.is_supported("wipeup")
assert TransitionType.is_supported("wipedown")
def test_is_supported_case_insensitive(self):
"""大小写不敏感."""
assert TransitionType.is_supported("FADE")
assert TransitionType.is_supported("Fade")
assert TransitionType.is_supported("fade")
def test_is_supported_with_underscores(self):
"""下划线不影响判断."""
assert TransitionType.is_supported("slide_left")
assert TransitionType.is_supported("slide-left")
def test_is_supported_aliases(self):
"""别名支持."""
assert TransitionType.is_supported("crossfade")
assert TransitionType.is_supported("dissolve")
assert TransitionType.is_supported("zoomin")
assert TransitionType.is_supported("wipe")
def test_unsupported_transition(self):
"""不支持的转场返回 False."""
assert not TransitionType.is_supported("nonexistent_effect")
assert not TransitionType.is_supported("random_stuff")
assert not TransitionType.is_supported("")
def test_cut_not_in_supported(self):
"""硬切不在"支持的转场效果"列表中(它不是特效)."""
supported = TransitionType.all_supported()
assert "cut" not in supported
# ── 名称标准化测试 ────────────────────────────────────────────────────────────
class TestNormalizeTransitionName:
"""名称标准化函数测试."""
def test_lowercase(self):
"""大写转小写."""
assert _normalize_transition_name("FADE") == "fade"
assert _normalize_transition_name("Fade") == "fade"
def test_remove_underscores(self):
"""移除下划线."""
assert _normalize_transition_name("slide_left") == "slideleft"
assert _normalize_transition_name("slide_up") == "slideup"
def test_remove_hyphens(self):
"""移除连字符."""
assert _normalize_transition_name("slide-left") == "slideleft"
def test_mixed(self):
"""混合情况."""
assert _normalize_transition_name("Slide_Left") == "slideleft"
assert _normalize_transition_name("FADE-IN") == "fadein"
# ── TransitionConfig 测试 ────────────────────────────────────────────────────
class TestTransitionConfig:
"""TransitionConfig 配置解析测试."""
# ── 默认值 ──
def test_default_config(self):
"""默认配置是硬切."""
cfg = TransitionConfig.parse()
assert cfg.effect == CUT_TRANSITION
assert cfg.duration == DEFAULT_TRANSITION_DURATION
assert cfg.is_cut is True
def test_none_effect(self):
"""None effect 降级为 cut."""
cfg = TransitionConfig.parse(effect=None)
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_empty_effect(self):
"""空字符串 effect 降级为 cut."""
cfg = TransitionConfig.parse(effect="")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
# ── 有效转场类型 ──
def test_fade_effect(self):
"""fade 转场."""
cfg = TransitionConfig.parse(effect="fade")
assert cfg.effect == "fade"
assert cfg.is_cut is False
assert cfg.ffmpeg_transition == "fade"
def test_dissolve_effect(self):
"""dissolve 转场."""
cfg = TransitionConfig.parse(effect="dissolve")
assert cfg.effect == "dissolve"
assert cfg.ffmpeg_transition == "dissolve"
def test_zoom_effect(self):
"""zoom 转场 → FFmpeg zoomin."""
cfg = TransitionConfig.parse(effect="zoom")
assert cfg.effect == "zoom"
assert cfg.ffmpeg_transition == "zoomin"
def test_slide_left_alias(self):
"""slide_left 别名."""
cfg = TransitionConfig.parse(effect="slide_left")
assert cfg.effect == "slideleft"
assert cfg.ffmpeg_transition == "slideleft"
def test_wipe_alias(self):
"""wipe 别名 → 默认向左擦."""
cfg = TransitionConfig.parse(effect="wipe")
assert cfg.effect == "wipeleft"
assert cfg.ffmpeg_transition == "wipeleft"
def test_circlecrop_effect(self):
"""圆形扩散转场."""
cfg = TransitionConfig.parse(effect="circlecrop")
assert cfg.effect == "circlecrop"
assert cfg.ffmpeg_transition == "circlecrop"
def test_rectcrop_effect(self):
"""矩形扩散转场."""
cfg = TransitionConfig.parse(effect="rectcrop")
assert cfg.effect == "rectcrop"
assert cfg.ffmpeg_transition == "rectcrop"
# ── 降级策略 ──
def test_unsupported_fallback_to_cut(self):
"""不支持的转场自动降级为硬切,不阻断渲染."""
cfg = TransitionConfig.parse(effect="nonexistent_effect")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_unsupported_whitespace_fallback(self):
"""带空格的不支持转场也降级."""
cfg = TransitionConfig.parse(effect=" bad effect ")
assert cfg.effect == CUT_TRANSITION
# ── 时长边界校验 ──
def test_default_duration(self):
"""默认时长 0.5s."""
cfg = TransitionConfig.parse(effect="fade")
assert cfg.duration == 0.5
def test_duration_within_range(self):
"""正常范围内的时长."""
cfg = TransitionConfig.parse(effect="fade", duration=1.0)
assert cfg.duration == 1.0
def test_duration_min_boundary(self):
"""最小值边界."""
cfg = TransitionConfig.parse(effect="fade", duration=MIN_TRANSITION_DURATION)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_max_boundary(self):
"""最大值边界."""
cfg = TransitionConfig.parse(effect="fade", duration=MAX_TRANSITION_DURATION)
assert cfg.duration == MAX_TRANSITION_DURATION
def test_duration_below_min_clamped(self):
"""低于最小值的时长被钳制."""
cfg = TransitionConfig.parse(effect="fade", duration=0.1)
assert cfg.duration == MIN_TRANSITION_DURATION
assert cfg.duration >= MIN_TRANSITION_DURATION
def test_duration_above_max_clamped(self):
"""高于最大值的时长被钳制."""
cfg = TransitionConfig.parse(effect="fade", duration=5.0)
assert cfg.duration == MAX_TRANSITION_DURATION
assert cfg.duration <= MAX_TRANSITION_DURATION
def test_duration_zero_default_for_effect(self):
"""有转场效果但 duration=0 时使用默认值."""
# 0.0 会被当作小于最小值钳制到 0.3
cfg = TransitionConfig.parse(effect="fade", duration=0.0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_negative_clamped(self):
"""负时长被钳制到最小值."""
cfg = TransitionConfig.parse(effect="fade", duration=-1.0)
assert cfg.duration == MIN_TRANSITION_DURATION
def test_duration_none_uses_default(self):
"""None duration 使用默认值."""
cfg = TransitionConfig.parse(effect="fade", duration=None)
assert cfg.duration == DEFAULT_TRANSITION_DURATION
def test_duration_invalid_type(self):
"""无效类型的时长使用默认值."""
cfg = TransitionConfig.parse(effect="fade", duration="abc") # type: ignore
assert cfg.duration == DEFAULT_TRANSITION_DURATION
# ── cut 的 ffmpeg_transition ──
def test_cut_ffmpeg_transition_empty(self):
"""硬切没有对应的 FFmpeg xfade transition."""
cfg = TransitionConfig.parse(effect="cut")
assert cfg.ffmpeg_transition == ""
# ── TransitionEngine 测试 ────────────────────────────────────────────────────
class TestTransitionEngine:
"""TransitionEngine 转场引擎测试."""
def test_default_engine(self):
"""默认引擎初始化."""
engine = TransitionEngine()
assert engine is not None
def test_custom_default_duration(self):
"""自定义默认时长."""
engine = TransitionEngine(default_duration=1.0)
cfg = engine.resolve_config(effect="fade")
assert cfg.duration == 1.0
def test_resolve_config_fade(self):
"""解析 fade 配置."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="fade", duration=0.8)
assert cfg.effect == "fade"
assert cfg.duration == 0.8
def test_resolve_config_fallback(self):
"""不支持的转场降级."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="unknown_effect")
assert cfg.effect == CUT_TRANSITION
assert cfg.is_cut is True
def test_resolve_config_duration_clamp(self):
"""时长边界钳制."""
engine = TransitionEngine()
cfg = engine.resolve_config(effect="fade", duration=3.0)
assert cfg.duration == MAX_TRANSITION_DURATION
# ── 批量解析 ──
def test_resolve_clip_transitions_all_valid(self):
"""批量解析全部有效转场."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(["cut", "fade", "dissolve", "slideleft"])
assert len(configs) == 4
assert configs[0].effect == "cut"
assert configs[0].is_cut is True
assert configs[1].effect == "fade"
assert configs[2].effect == "dissolve"
assert configs[3].effect == "slideleft"
def test_resolve_clip_transitions_with_fallback(self):
"""批量解析包含不支持的转场,自动降级."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(["fade", "bad_effect", "dissolve", "worse_effect"])
assert len(configs) == 4
assert configs[0].effect == "fade"
assert configs[1].effect == "cut" # 降级
assert configs[2].effect == "dissolve"
assert configs[3].effect == "cut" # 降级
def test_resolve_clip_transitions_with_durations(self):
"""带时长校验的批量解析(转场时长不超过片段时长的一半)."""
engine = TransitionEngine(default_duration=1.0)
# 片段只有 1.0s,转场时长被限制在 0.5s
configs = engine.resolve_clip_transitions(
["fade", "dissolve"],
clip_durations=[1.0, 1.0],
)
assert len(configs) == 2
# 1.0s 默认值超过了片段时长的一半 (0.5s),所以被钳制
assert configs[0].duration <= 0.5
assert configs[1].duration <= 0.5
def test_resolve_clip_transitions_short_clip_min_bound(self):
"""超短片段的转场时长至少为最小值."""
engine = TransitionEngine()
configs = engine.resolve_clip_transitions(
["fade"],
clip_durations=[0.1], # 极短片段
)
assert len(configs) == 1
# 0.1 * 0.5 = 0.05 < MIN_TRANSITION_DURATION,所以用最小值
assert configs[0].duration == MIN_TRANSITION_DURATION
# ── xfade 滤镜链构建 ──
def test_build_xfade_single_clip(self):
"""单 clip 直接 copy."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[5.0],
clip_video_labels=["v0"],
transitions=["cut"],
output_label="outv",
)
assert "copy" in filter_str
assert "[outv]" in filter_str
assert total_dur == pytest.approx(5.0, abs=0.01)
def test_build_xfade_two_clips_fade(self):
"""两个 clip 之间 fade 转场."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
output_label="outv",
)
assert "xfade" in filter_str
assert "transition=fade" in filter_str
# 总时长 = 3 + 4 - transition_duration (0.5) = 6.5
assert total_dur == pytest.approx(6.5, abs=0.1)
def test_build_xfade_three_clips_mixed(self):
"""三个 clip 混合转场."""
engine = TransitionEngine()
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0, 5.0],
clip_video_labels=["v0", "v1", "v2"],
transitions=["cut", "fade", "dissolve"],
output_label="outv",
)
assert "xfade" in filter_str
assert "transition=fade" in filter_str
assert "transition=dissolve" in filter_str
# 总时长 ≈ 3 + 4 + 5 - 2 * 0.5 = 11.0
assert total_dur == pytest.approx(11.0, abs=0.2)
def test_build_xfade_with_custom_duration(self):
"""自定义转场时长."""
engine = TransitionEngine(default_duration=0.5)
filter_str, total_dur = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "fade"],
transition_duration=1.0,
output_label="outv",
)
assert "xfade" in filter_str
# 总时长 = 3 + 4 - 1.0 = 6.0
assert total_dur == pytest.approx(6.0, abs=0.1)
def test_build_xfade_zoom_transition(self):
"""zoom 转场滤镜构建."""
engine = TransitionEngine()
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "zoom"],
)
assert "xfade" in filter_str
assert "transition=zoomin" in filter_str # zoom → zoomin
def test_build_xfade_slide_directions(self):
"""四个方向的滑入转场."""
engine = TransitionEngine()
for direction in ["slideleft", "slideright", "slideup", "slidedown"]:
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", direction],
)
assert f"transition={direction}" in filter_str
def test_build_xfade_fallback_transition(self):
"""不支持的转场降级后构建(降级为cut,等效于极短fade)."""
engine = TransitionEngine()
# bad_effect 降级为 cutcut 使用极短转场
filter_str, _ = engine.build_xfade_chain(
clip_durations=[3.0, 4.0],
clip_video_labels=["v0", "v1"],
transitions=["cut", "bad_effect"],
)
# 降级后是 cutcut 会被 xfade 层映射为 fade(因为 cut 不在 map 里)
# 但时长会很短,所以仍然有 xfade
assert "xfade" in filter_str
# ── 支持的转场列表 ──
def test_supported_transitions_list(self):
"""获取支持的转场列表(给 API 用)."""
transitions = TransitionEngine.supported_transitions()
assert len(transitions) >= 10 # cut + 至少 9 种特效
# 检查结构
for t in transitions:
assert "name" in t
assert "display_name" in t
assert "category" in t
# 检查分类
names = [t["name"] for t in transitions]
assert "cut" in names
assert "fade" in names
assert "zoom" in names
assert "circlecrop" in names
# ── 集成测试:与 UnifiedRenderService 协作 ────────────────────────────────────
class TestTransitionIntegration:
"""转场引擎与统一渲染服务的集成测试."""
def test_unified_render_service_has_transition_engine(self):
"""UnifiedRenderService 内部有 TransitionEngine 实例."""
from pathlib import Path
from video_processing.unified_render_service import UnifiedRenderService
# 构造最小化的服务实例
service = UnifiedRenderService(
plan=None,
clips=[],
asset_path_map={},
work_dir=Path("/tmp"),
)
assert hasattr(service, "_transition_engine")
assert isinstance(service._transition_engine, TransitionEngine)
def test_resolved_clip_has_transition_duration(self):
"""ResolvedClip 有 transition_duration 字段."""
from video_processing.unified_render_service import ResolvedClip
rc = ResolvedClip(
clip_id="test",
asset_id="asset1",
local_path=__file__, # 随便一个存在的路径
clip_type="main",
order=0,
transition_effect="fade",
transition_duration=0.8,
)
assert rc.transition_duration == 0.8
assert rc.transition_effect == "fade"