09d2b12ea8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 18m38s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m7s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Successful in 2m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
1749 lines
69 KiB
Python
Executable File
1749 lines
69 KiB
Python
Executable File
"""统一渲染引擎 — 输入 EditPlan + EditPlanClips,按时间线+图层渲染视频.
|
||
|
||
核心原则(灵应):渲染引擎是统一的,不判断模式,只按 clip_type/config.role
|
||
分组为图层再合成。
|
||
|
||
图层分组:
|
||
main (无 config.role) → main (z=0)
|
||
main + config.role=b_roll → broll (z=0,与 main 同层替换)
|
||
overlay → overlay (z=1,画中画叠加)
|
||
background → background (z=0,全屏底图)
|
||
corner_voice → corner_voice (z=1,右上角小窗)
|
||
b_roll → broll (z=0)
|
||
intro / outro → main (z=0,按 order 排在首/尾)
|
||
|
||
合成流程:
|
||
1. 每个 clip 先 trim + scale + setpts 预处理
|
||
2. 同层 clips 按 order 用 xfade 串联
|
||
3. overlay/corner_voice 层 overlay 到主层
|
||
4. 如有独立音频轨,amix 混入
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import subprocess
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from video_processing.color_grade_engine import ColorGradeConfig, ColorGradeEngine
|
||
from video_processing.ffmpeg_utils import (
|
||
DEFAULT_FPS,
|
||
DEFAULT_OUTPUT_HEIGHT,
|
||
DEFAULT_OUTPUT_WIDTH,
|
||
DEFAULT_TRANSITION_DURATION,
|
||
FFMPEG_BIN,
|
||
probe_duration,
|
||
probe_video_info,
|
||
run_ffmpeg,
|
||
)
|
||
from video_processing.intro_outro_engine import IntroOutroConfig, IntroOutroEngine
|
||
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.reverse_engine import ReverseConfig, ReverseEngine
|
||
from video_processing.speed_engine import SpeedEngine
|
||
from video_processing.sticker_engine import StickerEngine
|
||
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
|
||
|
||
from packages.domain.tts_config import TtsConfig
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass
|
||
class ResolvedClip:
|
||
"""已解析到本地路径的片段。"""
|
||
|
||
clip_id: str
|
||
asset_id: str
|
||
local_path: Path
|
||
clip_type: str
|
||
order: int
|
||
start_time: float = 0.0
|
||
duration: float = 0.0 # 0 表示使用素材完整时长
|
||
transition_effect: str = "cut"
|
||
transition_duration: float = 0.0 # 0 表示使用全局默认值
|
||
playback_speed: float = 1.0 # 0 或 1.0 表示原速
|
||
config: dict[str, Any] = field(default_factory=dict)
|
||
|
||
# 运行时填充
|
||
actual_duration: float = 0.0 # 素材实际时长(probe 后填充)
|
||
trim_config: TrimConfig | None = None # 解析后的裁剪配置(运行时填充)
|
||
|
||
|
||
@dataclass
|
||
class RenderLayer:
|
||
"""渲染图层。"""
|
||
|
||
role: str # "main" | "overlay" | "pip" | "background" | "corner_voice" | "broll" | "audio"
|
||
clips: list[ResolvedClip] = field(default_factory=list)
|
||
z_index: int = 0
|
||
opacity: float = 1.0
|
||
position: tuple[int, int] | None = None # (x, y) 偏移,None 表示全屏
|
||
|
||
|
||
@dataclass
|
||
class RenderResult:
|
||
"""渲染结果。"""
|
||
|
||
output_path: Path
|
||
duration: float
|
||
file_size: int
|
||
width: int
|
||
height: int
|
||
|
||
|
||
# ── clip_type → layer role 映射 ──────────────────────────────────────────────
|
||
|
||
|
||
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
|
||
"""根据 clip_type 和 config.role 确定图层角色。
|
||
|
||
映射规则:
|
||
intro / outro → "main"(按 order 排在首/尾)
|
||
overlay → "overlay"(画中画叠加,z=1)
|
||
corner_voice → "corner_voice"(右上角小窗,z=1)
|
||
background → "background"(全屏底图,z=0)
|
||
b_roll → "broll"(z=0)
|
||
main + config.role=b_roll → "broll"
|
||
main (default) → "main"
|
||
"""
|
||
role = config.get("role", "")
|
||
|
||
if clip_type in ("intro", "outro"):
|
||
return "main"
|
||
if clip_type == "overlay":
|
||
return "overlay"
|
||
if clip_type == "corner_voice":
|
||
return "corner_voice"
|
||
if clip_type == "background":
|
||
return "background"
|
||
if clip_type == "b_roll":
|
||
return "broll"
|
||
# main type
|
||
if role == "b_roll":
|
||
return "broll"
|
||
if role == "audio":
|
||
return "audio"
|
||
return "main"
|
||
|
||
|
||
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
|
||
|
||
_LAYER_Z_INDEX: dict[str, int] = {
|
||
"background": -1,
|
||
"broll": 0,
|
||
"main": 0,
|
||
"overlay": 1,
|
||
"corner_voice": 1,
|
||
"audio": 2,
|
||
}
|
||
|
||
# 图层默认 PiP 位置(相对输出画布的偏移)
|
||
_PIP_SCALE = 0.25 # PiP 占主画面的比例
|
||
|
||
|
||
# ── 统一渲染引擎 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
class UnifiedRenderService:
|
||
"""统一渲染引擎。
|
||
|
||
输入 EditPlan + EditPlanClips + 素材路径映射,按时间线+图层执行渲染。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
plan: Any, # EditPlan
|
||
clips: list[Any], # list[EditPlanClip]
|
||
asset_path_map: dict[str, Path], # asset_id → local_path
|
||
work_dir: Path,
|
||
*,
|
||
output_width: int = DEFAULT_OUTPUT_WIDTH,
|
||
output_height: int = DEFAULT_OUTPUT_HEIGHT,
|
||
output_fps: int = DEFAULT_FPS,
|
||
transition_duration: float = DEFAULT_TRANSITION_DURATION,
|
||
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
|
||
bgm_path: str | None = None, # BGM 本地文件路径
|
||
):
|
||
self.plan = plan
|
||
self.clips = clips
|
||
self.asset_path_map = asset_path_map
|
||
self.work_dir = work_dir
|
||
self.output_width = output_width
|
||
self.output_height = output_height
|
||
self.output_fps = output_fps
|
||
self.transition_duration = transition_duration
|
||
self.asr_service = asr_service
|
||
self.bgm_path = bgm_path
|
||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||
self._speed_engine = SpeedEngine()
|
||
|
||
def render(self) -> RenderResult:
|
||
"""执行渲染,返回 RenderResult.
|
||
|
||
优化路径:
|
||
- 单图层单 clip → 直通模式(-vf),性能最优
|
||
- 其他情况 → 完整 filter_complex 渲染
|
||
|
||
字幕渲染流程:
|
||
1. 视频主渲染(直通或完整链路)
|
||
2. 如有 title/subtitle,叠加 ASS 字幕
|
||
|
||
音频后处理:
|
||
1. 主图层音频 concat 拼接
|
||
2. 独立音频轨 amix 混入
|
||
3. 合并到输出视频
|
||
|
||
Raises:
|
||
ValueError: 没有可渲染的片段时抛出
|
||
"""
|
||
t_start = time.time()
|
||
|
||
# 1. 解析 clips → ResolvedClips(跳过无素材的 clip)
|
||
resolved = self._resolve_clips()
|
||
if not resolved:
|
||
raise ValueError("没有可渲染的片段(所有片段素材缺失或下载失败)")
|
||
|
||
# 2. 分组为 RenderLayers
|
||
layers = self._group_clips_into_layers(resolved)
|
||
|
||
# 3. 计算视频总时长(用于字幕显示时长)
|
||
video_duration = self._estimate_total_duration(layers)
|
||
|
||
# 3.5 TTS 配音生成(如果配置了)
|
||
self._maybe_add_voiceover_layer(layers, video_duration=video_duration)
|
||
|
||
# 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置)
|
||
ass_path = self._maybe_generate_ass(video_duration)
|
||
|
||
# 4.5 解析画中画配置
|
||
pip_config = PiPConfig.from_dict((self.plan.config or {}).get("pip_config"))
|
||
pip_sources = self._resolve_pip_sources(pip_config) if pip_config.enabled else []
|
||
has_pip = len(pip_sources) > 0
|
||
|
||
# 灰度埋点:开始渲染
|
||
layer_roles = [layer.role for layer in layers]
|
||
clip_counts = {layer.role: len(layer.clips) for layer in layers}
|
||
logger.info(
|
||
"[unified-render] start render: plan_id=%s clip_count=%d layers=%s clip_counts=%s pip_layers=%d",
|
||
self.plan.id,
|
||
len(resolved),
|
||
layer_roles,
|
||
clip_counts,
|
||
len(pip_sources),
|
||
)
|
||
|
||
# 5. 视频主渲染
|
||
t_video_start = time.time()
|
||
video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4"
|
||
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
|
||
|
||
# 有画中画时不走直通(需要额外图层叠加)
|
||
is_pass_through = self._can_use_pass_through(layers) and not has_pip
|
||
pass_through_has_audio = False
|
||
used_stream_copy = False
|
||
|
||
if is_pass_through:
|
||
# 先尝试 stream copy 优化(无重编码,性能提升 10 倍+)
|
||
# 条件不满足或失败时回退到带滤镜的直通渲染
|
||
stream_copy_ok = self._try_render_stream_copy(
|
||
layers, output_path, ass_path=ass_path, video_duration=video_duration
|
||
)
|
||
if stream_copy_ok:
|
||
used_stream_copy = True
|
||
# stream copy 模式下,直接探测输出是否有音频
|
||
clip = layers[0].clips[0]
|
||
info = probe_video_info(str(clip.local_path))
|
||
pass_through_has_audio = info.get("has_audio", True)
|
||
else:
|
||
# 回退到带滤镜的直通渲染
|
||
pass_through_has_audio = self._render_pass_through(
|
||
layers,
|
||
output_path,
|
||
ass_path=ass_path,
|
||
video_duration=video_duration,
|
||
)
|
||
else:
|
||
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
|
||
|
||
# 追加画中画滤镜
|
||
if has_pip:
|
||
filter_complex, input_args = self._append_pip_filters(filter_complex, input_args, pip_sources)
|
||
|
||
self._execute_ffmpeg(filter_complex, input_args, video_only_path)
|
||
|
||
t_video_end = time.time()
|
||
video_render_ms = int((t_video_end - t_video_start) * 1000)
|
||
logger.info(
|
||
"[unified-render] video render done: plan_id=%s duration_ms=%d pass_through=%s stream_copy=%s",
|
||
self.plan.id,
|
||
video_render_ms,
|
||
is_pass_through,
|
||
used_stream_copy,
|
||
)
|
||
|
||
# 6. 音频后处理混音(直通场景已合并处理,跳过)
|
||
t_audio_start = time.time()
|
||
audio_mix_ms = 0
|
||
has_audio = False
|
||
|
||
if is_pass_through:
|
||
# 直通场景已在一次调用中完成视频+音频
|
||
has_audio = pass_through_has_audio
|
||
# 直通模式下也支持 BGM 混音:提取音频 → 混 BGM → 合并回视频
|
||
if self.bgm_path and pass_through_has_audio:
|
||
config = self.plan.config or {}
|
||
bgm_config = config.get("bgm", {}) or {}
|
||
if bgm_config.get("enabled", False):
|
||
ctx = RenderContext(work_dir=self.work_dir, plan_id=self.plan.id)
|
||
from video_processing.bgm_mixer import BGMConfig, mix_bgm_with_main
|
||
|
||
bgm_cfg = BGMConfig.from_config_dict(self.bgm_path, bgm_config)
|
||
# 从直通输出中提取音频
|
||
main_audio_path = self.work_dir / f"pass_through_audio_{self.plan.id}.aac"
|
||
extract_cmd = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
str(output_path),
|
||
"-vn",
|
||
"-acodec",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
str(main_audio_path),
|
||
]
|
||
try:
|
||
from video_processing.ffmpeg_utils import run_ffmpeg
|
||
|
||
run_ffmpeg(extract_cmd)
|
||
final_audio = mix_bgm_with_main(ctx, main_audio_path, bgm_cfg, video_duration)
|
||
# 合并回视频
|
||
|
||
bgm_output = self.work_dir / f"rendered_{self.plan.id}_bgm.mp4"
|
||
merge_audio_video(ctx, output_path, final_audio, bgm_output)
|
||
output_path = bgm_output
|
||
logger.info("[unified-render] pass-through BGM mix done: plan_id=%s", self.plan.id)
|
||
except Exception:
|
||
logger.exception(
|
||
"[unified-render] pass-through BGM mix failed, skipping: plan_id=%s", self.plan.id
|
||
)
|
||
else:
|
||
config = self.plan.config or {}
|
||
bgm_config = config.get("bgm", {}) or {}
|
||
audio_tracks_config = config.get("audio_tracks") or {}
|
||
noise_reduction_config = config.get("audio_noise_reduction")
|
||
ctx = RenderContext(
|
||
work_dir=self.work_dir,
|
||
plan_id=self.plan.id,
|
||
noise_reduction_config=noise_reduction_config,
|
||
)
|
||
audio_path = mix_audio(
|
||
ctx,
|
||
layers,
|
||
video_duration,
|
||
bgm_path=self.bgm_path,
|
||
bgm_config=bgm_config,
|
||
audio_tracks_config=audio_tracks_config,
|
||
)
|
||
t_audio_end = time.time()
|
||
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
|
||
has_audio = audio_path is not None
|
||
if has_audio:
|
||
logger.info(
|
||
"[unified-render] audio mix done: plan_id=%s duration_ms=%d",
|
||
self.plan.id,
|
||
audio_mix_ms,
|
||
)
|
||
# 7. 合并音视频
|
||
merge_audio_video(ctx, video_only_path, audio_path, output_path)
|
||
else:
|
||
# 无音频,直接用无声视频
|
||
import shutil
|
||
|
||
shutil.copy2(video_only_path, output_path)
|
||
|
||
# 8. 探测输出
|
||
duration, file_size, width, height = self._probe_output(output_path)
|
||
|
||
# 9. 片头片尾拼接(后处理)
|
||
intro_outro_config = IntroOutroConfig.from_dict((self.plan.config or {}).get("intro_outro"))
|
||
if intro_outro_config.has_intro or intro_outro_config.has_outro:
|
||
io_valid, io_err = intro_outro_config.validate()
|
||
if io_valid:
|
||
final_with_io = self.work_dir / f"rendered_{self.plan.id}_with_io.mp4"
|
||
intro_path = None
|
||
outro_path = None
|
||
|
||
# 生成片头
|
||
if intro_outro_config.has_intro:
|
||
intro_path = self.work_dir / f"intro_{self.plan.id}.mp4"
|
||
intro_ok = False
|
||
if intro_outro_config.intro_type == "video":
|
||
import shutil
|
||
|
||
src = Path(intro_outro_config.intro_video_path)
|
||
if src.exists():
|
||
shutil.copy2(src, intro_path)
|
||
intro_ok = True
|
||
else:
|
||
logger.warning("片头视频不存在,跳过片头: %s", src)
|
||
elif intro_outro_config.intro_type == "text":
|
||
intro_ok = IntroOutroEngine.generate_text_intro(
|
||
intro_path,
|
||
intro_outro_config,
|
||
self.output_width,
|
||
self.output_height,
|
||
self.output_fps,
|
||
)
|
||
|
||
if not intro_ok:
|
||
intro_path = None
|
||
|
||
# 生成片尾
|
||
if intro_outro_config.has_outro:
|
||
outro_path = self.work_dir / f"outro_{self.plan.id}.mp4"
|
||
outro_ok = False
|
||
if intro_outro_config.outro_type == "video":
|
||
import shutil
|
||
|
||
src = Path(intro_outro_config.outro_video_path)
|
||
if src.exists():
|
||
shutil.copy2(src, outro_path)
|
||
outro_ok = True
|
||
else:
|
||
logger.warning("片尾视频不存在,跳过片尾: %s", src)
|
||
elif intro_outro_config.outro_type in ("text", "follow"):
|
||
outro_ok = IntroOutroEngine.generate_text_outro(
|
||
outro_path,
|
||
intro_outro_config,
|
||
self.output_width,
|
||
self.output_height,
|
||
self.output_fps,
|
||
)
|
||
|
||
if not outro_ok:
|
||
outro_path = None
|
||
|
||
# 拼接
|
||
if intro_path or outro_path:
|
||
concat_ok = IntroOutroEngine.concat_with_intro_outro(
|
||
output_path,
|
||
intro_path,
|
||
outro_path,
|
||
final_with_io,
|
||
transition_duration=intro_outro_config.transition_duration,
|
||
transition_effect=intro_outro_config.transition_effect,
|
||
)
|
||
if concat_ok and final_with_io.exists():
|
||
output_path = final_with_io
|
||
# 重新探测
|
||
duration, file_size, width, height = self._probe_output(output_path)
|
||
logger.info("[unified-render] 片头片尾拼接完成: plan_id=%s", self.plan.id)
|
||
else:
|
||
logger.warning("[unified-render] 片头片尾拼接失败,使用原视频: plan_id=%s", self.plan.id)
|
||
else:
|
||
logger.warning("[unified-render] 片头片尾配置无效,跳过: %s", io_err)
|
||
|
||
t_total = int((time.time() - t_start) * 1000)
|
||
logger.info(
|
||
"[unified-render] render done: plan_id=%s total_ms=%d video_ms=%d audio_ms=%d "
|
||
"output_duration=%.2fs output_size=%d resolution=%dx%d has_audio=%s",
|
||
self.plan.id,
|
||
t_total,
|
||
video_render_ms,
|
||
audio_mix_ms if has_audio else 0,
|
||
duration,
|
||
file_size,
|
||
width,
|
||
height,
|
||
has_audio,
|
||
)
|
||
|
||
return RenderResult(
|
||
output_path=output_path,
|
||
duration=duration,
|
||
file_size=file_size,
|
||
width=width,
|
||
height=height,
|
||
)
|
||
|
||
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
|
||
"""估算视频总时长(用于字幕等需要)。
|
||
|
||
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算。
|
||
"""
|
||
# 找主图层(第一个有视频内容的图层)
|
||
main_layer = None
|
||
for role in ("main", "broll", "background"):
|
||
for layer in layers:
|
||
if layer.role == role:
|
||
main_layer = layer
|
||
break
|
||
if main_layer:
|
||
break
|
||
|
||
if not main_layer or not main_layer.clips:
|
||
return 0.0
|
||
|
||
total = sum(UnifiedRenderService._clip_adjusted_duration(c) for c in main_layer.clips)
|
||
|
||
# 减去转场重叠时间(粗略估算)
|
||
n_clips = len(main_layer.clips)
|
||
if n_clips > 1:
|
||
total -= (n_clips - 1) * self.transition_duration
|
||
|
||
return max(0.1, total)
|
||
|
||
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
|
||
"""根据 plan.config 生成 ASS 字幕文件。
|
||
|
||
支持两种字幕模式:
|
||
1. 静态字幕 — title/subtitle 配置了 text 时,生成整段静态字幕
|
||
2. ASR 自动字幕 — subtitle.auto_generated=true 时,从音频自动识别生成时间轴字幕
|
||
|
||
Returns:
|
||
ASS 文件路径,没有字幕时返回 None
|
||
"""
|
||
config = self.plan.config or {}
|
||
title_cfg = config.get("title", {}) or {}
|
||
subtitle_cfg = config.get("subtitle", {}) or {}
|
||
|
||
title_enabled = title_cfg.get("enabled", True)
|
||
subtitle_enabled = subtitle_cfg.get("enabled", True)
|
||
title_text = title_cfg.get("text", "") or ""
|
||
subtitle_text = subtitle_cfg.get("text", "") or ""
|
||
auto_generated = subtitle_cfg.get("auto_generated", False)
|
||
|
||
has_title = title_enabled and bool(title_text.strip())
|
||
has_static_subtitle = subtitle_enabled and bool(subtitle_text.strip())
|
||
has_auto_subtitle = subtitle_enabled and auto_generated and self.asr_service is not None
|
||
|
||
if not has_title and not has_static_subtitle and not has_auto_subtitle:
|
||
return None
|
||
|
||
ass_path = self.work_dir / f"subtitles_{self.plan.id}.ass"
|
||
|
||
# ASR 自动字幕模式
|
||
if has_auto_subtitle:
|
||
try:
|
||
timeline = self._generate_asr_subtitles(video_duration, subtitle_cfg)
|
||
if timeline and timeline.segments:
|
||
generate_ass_from_timeline(
|
||
ass_path,
|
||
timeline,
|
||
video_width=self.output_width,
|
||
video_height=self.output_height,
|
||
subtitle_config=subtitle_cfg,
|
||
)
|
||
logger.info(
|
||
"ASR自动字幕生成完成: plan_id=%s segments=%d duration=%.1fs",
|
||
self.plan.id,
|
||
timeline.segment_count,
|
||
video_duration,
|
||
)
|
||
return ass_path
|
||
else:
|
||
# ASR 无结果,不生成字幕
|
||
logger.info("ASR自动字幕无识别结果,跳过字幕: plan_id=%s", self.plan.id)
|
||
return None
|
||
except Exception:
|
||
# ASR 失败降级:不生成字幕,不阻断主流程
|
||
logger.warning("ASR自动字幕生成失败,跳过字幕", exc_info=True)
|
||
return None
|
||
|
||
# 静态字幕模式(原有逻辑)
|
||
generate_ass_subtitles(
|
||
ass_path,
|
||
video_width=self.output_width,
|
||
video_height=self.output_height,
|
||
video_duration=video_duration,
|
||
title_text=title_text,
|
||
title_config=title_cfg,
|
||
subtitle_text=subtitle_text,
|
||
subtitle_config=subtitle_cfg,
|
||
)
|
||
|
||
logger.info(
|
||
"生成字幕: plan_id=%s title=%s subtitle=%s ass=%s",
|
||
self.plan.id,
|
||
has_title,
|
||
has_static_subtitle,
|
||
ass_path,
|
||
)
|
||
return ass_path
|
||
|
||
def _generate_asr_subtitles(self, video_duration: float, subtitle_cfg: dict) -> Any: # SubtitleTimeline
|
||
"""从视频素材音频中自动识别生成字幕时间轴。
|
||
|
||
MVP 版本:使用第一个有音频的素材做ASR,然后按比例映射到整个视频时长。
|
||
后续优化:支持多片段拼接后的完整音频ASR。
|
||
"""
|
||
from packages.domain.subtitle import SubtitleTimeline
|
||
|
||
# 找第一个有本地路径的素材
|
||
first_asset_path = None
|
||
for clip in self.clips:
|
||
asset_id = getattr(clip, "asset_id", None)
|
||
if asset_id and asset_id in self.asset_path_map:
|
||
first_asset_path = self.asset_path_map[asset_id]
|
||
break
|
||
|
||
if first_asset_path is None:
|
||
logger.warning("ASR字幕生成失败:找不到可用素材音频")
|
||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||
|
||
# 提取素材音频为 wav(16kHz单声道,ASR友好格式)
|
||
audio_path = self.work_dir / f"asr_audio_{self.plan.id}.wav"
|
||
try:
|
||
self._extract_audio(first_asset_path, audio_path)
|
||
except Exception:
|
||
logger.warning("ASR音频提取失败", exc_info=True)
|
||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||
|
||
if not audio_path.exists():
|
||
return SubtitleTimeline(segments=[], total_duration=video_duration)
|
||
|
||
# 调用 ASR 服务
|
||
language = subtitle_cfg.get("language", "") or None
|
||
timeline = self.asr_service.transcribe(
|
||
audio_path,
|
||
language=language,
|
||
with_word_timestamps=True,
|
||
)
|
||
|
||
# 字幕后处理:合并短片段 + 拆分长片段
|
||
min_chars = int(subtitle_cfg.get("min_chars_per_segment", 8))
|
||
max_chars = int(subtitle_cfg.get("max_chars_per_line", 20))
|
||
|
||
if timeline.segments:
|
||
timeline = timeline.merge_short_segments(min_chars=min_chars)
|
||
timeline = timeline.split_long_segments(max_chars=max_chars)
|
||
|
||
# 清理临时音频文件
|
||
try:
|
||
audio_path.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
|
||
return timeline
|
||
|
||
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
|
||
"""从视频中提取音频为16kHz单声道wav(ASR友好格式)。"""
|
||
cmd = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
str(video_path),
|
||
"-vn",
|
||
"-acodec",
|
||
"pcm_s16le",
|
||
"-ar",
|
||
"16000",
|
||
"-ac",
|
||
"1",
|
||
str(output_path),
|
||
]
|
||
|
||
try:
|
||
run_ffmpeg(cmd, timeout=120)
|
||
except Exception as e:
|
||
raise RuntimeError(f"音频提取失败: {str(e)[:200]}") from e
|
||
|
||
def _maybe_add_voiceover_layer(
|
||
self,
|
||
layers: list[RenderLayer],
|
||
*,
|
||
video_duration: float,
|
||
) -> bool:
|
||
"""根据 plan.config 生成 TTS 配音,加到 audio 图层.
|
||
|
||
Returns:
|
||
是否成功添加了配音音轨
|
||
"""
|
||
config = self.plan.config or {}
|
||
tts_cfg = config.get("tts", {}) or {}
|
||
|
||
tts_config = TtsConfig.parse(tts_cfg)
|
||
if not tts_config.enabled:
|
||
return False
|
||
|
||
try:
|
||
from apps.worker.services.tts_service_factory import get_tts_service
|
||
|
||
tts_service = get_tts_service()
|
||
tts_engine = TtsEngine(tts_service, self.work_dir / "tts")
|
||
|
||
# 整段配音模式
|
||
result = tts_engine.generate_full_voiceover(tts_config, total_duration=video_duration)
|
||
|
||
if not result.success or not result.segments:
|
||
logger.warning("TTS 配音生成失败,跳过: %s", result.error_message)
|
||
return False
|
||
|
||
# 获取主音轨图层(用于判断 replace 模式下是否静音原音)
|
||
# 这里只处理混音添加,replace 模式在外部处理
|
||
|
||
# 找到或创建 audio 图层
|
||
audio_layer = None
|
||
for layer in layers:
|
||
if layer.role == "audio":
|
||
audio_layer = layer
|
||
break
|
||
|
||
if audio_layer is None:
|
||
from video_processing.unified_render_service import _LAYER_Z_INDEX # type: ignore
|
||
|
||
z_index = _LAYER_Z_INDEX.get("audio", 2)
|
||
audio_layer = RenderLayer(role="audio", z_index=z_index)
|
||
layers.append(audio_layer)
|
||
|
||
# 把配音片段作为 audio clip 加入
|
||
for seg in result.segments:
|
||
if seg.audio_path is None:
|
||
continue
|
||
vo_clip = ResolvedClip(
|
||
clip_id=f"tts_{seg.start_time:.3f}",
|
||
asset_id="tts_voiceover",
|
||
local_path=seg.audio_path,
|
||
clip_type="audio",
|
||
order=len(audio_layer.clips),
|
||
start_time=seg.start_time,
|
||
duration=seg.duration,
|
||
config={"volume": tts_config.volume, "tts": True},
|
||
actual_duration=seg.duration,
|
||
)
|
||
audio_layer.clips.append(vo_clip)
|
||
|
||
logger.info(
|
||
"TTS 配音已添加: plan_id=%s voice_id=%s segments=%d total_%.2fs",
|
||
self.plan.id,
|
||
tts_config.voice_id,
|
||
len(result.segments),
|
||
result.total_duration,
|
||
)
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.warning("TTS 配音异常,跳过: %s", e)
|
||
return False
|
||
|
||
def _can_use_pass_through(self, layers: list[RenderLayer]) -> bool:
|
||
"""判断是否可以走直通优化路径。
|
||
|
||
条件:
|
||
1. 只有 1 个图层
|
||
2. 该图层是视频图层(main/broll/background),不是 overlay/corner_voice/audio
|
||
3. 该图层只有 1 个 clip(无转场需求)
|
||
4. 没有贴纸(贴纸需要 filter_complex 或额外输入)
|
||
"""
|
||
if len(layers) != 1:
|
||
return False
|
||
layer = layers[0]
|
||
if layer.role not in ("main", "broll", "background"):
|
||
return False
|
||
if len(layer.clips) != 1:
|
||
return False
|
||
# 有贴纸时禁用直通(图片贴纸需要额外输入,统一走 filter_complex)
|
||
plan_config = getattr(self.plan, "config", None) or {}
|
||
if isinstance(plan_config, dict) and plan_config.get("stickers"):
|
||
return False
|
||
|
||
# 有水印时禁用直通(图片水印需要额外输入,统一走 filter_complex)
|
||
try:
|
||
from video_processing.watermark_engine import WatermarkConfig
|
||
|
||
wm_config = WatermarkConfig.from_dict(plan_config.get("watermark"))
|
||
if wm_config is not None and wm_config.validate()[0]:
|
||
return False
|
||
except Exception:
|
||
pass
|
||
|
||
# 有调速时仍然可以走直通(视频调速通过 setpts 实现,单输入即可)
|
||
|
||
return True
|
||
|
||
def _can_use_stream_copy(
|
||
self,
|
||
clip: ResolvedClip,
|
||
*,
|
||
ass_path: Path | None = None,
|
||
video_duration: float = 0.0,
|
||
) -> tuple[bool, str]:
|
||
"""判断是否可以走 stream copy(流拷贝,不重编码)。
|
||
|
||
性能提升:10 倍以上(典型场景从 20s → 1-2s)。
|
||
|
||
条件:
|
||
1. 视频编码为 h264(输出目标也是 h264)
|
||
2. 像素格式为 yuv420p
|
||
3. 分辨率与输出一致(不需要 scale/crop)
|
||
4. 帧率与输出一致(误差 < 0.1fps)
|
||
5. 无字幕叠加(字幕需要滤镜)
|
||
6. 无 trim 需求(或 trim 后恰好等于原时长)
|
||
7. 无转场、无特效(单 clip 直通已保证)
|
||
|
||
Returns:
|
||
(是否可以 copy, 原因说明)
|
||
"""
|
||
# 有字幕 → 需要滤镜 → 不能 copy
|
||
if ass_path is not None:
|
||
return False, "有字幕叠加"
|
||
|
||
# 探测输入视频参数
|
||
info = probe_video_info(str(clip.local_path))
|
||
|
||
# 编码必须是 h264
|
||
if info.get("video_codec", "") != "h264":
|
||
return False, f"视频编码不是h264: {info.get('video_codec', 'unknown')}"
|
||
|
||
# 像素格式必须是 yuv420p
|
||
if info.get("pix_fmt", "") != "yuv420p":
|
||
return False, f"像素格式不是yuv420p: {info.get('pix_fmt', 'unknown')}"
|
||
|
||
# 分辨率必须一致
|
||
if info.get("width", 0) != self.output_width or info.get("height", 0) != self.output_height:
|
||
return False, (
|
||
f"分辨率不匹配: "
|
||
f"{info.get('width', 0)}x{info.get('height', 0)} "
|
||
f"vs {self.output_width}x{self.output_height}"
|
||
)
|
||
|
||
# 帧率必须一致(误差 < 0.1fps)
|
||
fps_diff = abs(info.get("fps", 0) - self.output_fps)
|
||
if fps_diff > 0.1:
|
||
return False, f"帧率不匹配: {info.get('fps', 0)} vs {self.output_fps}"
|
||
|
||
# 检查是否需要 trim
|
||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||
if effective_duration > 0:
|
||
# 有 trim 需求但视频时长足够,可用 -ss/-t 实现 copy trim
|
||
input_duration = info.get("duration", 0)
|
||
if input_duration <= 0:
|
||
return False, "无法探测输入时长"
|
||
# trim 起始点 + 目标时长 <= 输入时长
|
||
start_time = getattr(clip, "start_time", 0) or 0
|
||
if start_time + effective_duration > input_duration + 0.1:
|
||
return False, "trim 超出输入时长"
|
||
|
||
# video_duration 截断
|
||
if video_duration > 0 and effective_duration > 0:
|
||
final_duration = min(effective_duration, video_duration)
|
||
if final_duration != effective_duration:
|
||
# 也需要截断,但 -t 可以 copy 模式下用
|
||
pass
|
||
|
||
return True, "所有条件满足"
|
||
|
||
def _try_render_stream_copy(
|
||
self,
|
||
layers: list[RenderLayer],
|
||
output_path: Path,
|
||
*,
|
||
ass_path: Path | None = None,
|
||
video_duration: float = 0.0,
|
||
) -> bool:
|
||
"""尝试 stream copy 渲染,成功返回 True,失败返回 False(调用方回退到重编码)。
|
||
|
||
stream copy 模式:不重编码,直接拷贝视频/音频流,性能提升 10 倍+。
|
||
仅用于单 clip 直通场景且满足 copy 条件。
|
||
"""
|
||
clip = layers[0].clips[0]
|
||
role = layers[0].role
|
||
|
||
# 判断是否满足 copy 条件
|
||
can_copy, reason = self._can_use_stream_copy(clip, ass_path=ass_path, video_duration=video_duration)
|
||
if not can_copy:
|
||
logger.info(
|
||
"[unified-render] stream_copy 跳过: plan_id=%s reason=%s",
|
||
self.plan.id,
|
||
reason,
|
||
)
|
||
return False
|
||
|
||
# 构建 copy 命令
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
]
|
||
|
||
# trim 支持(-ss 放在 -i 前 = input seeking,速度更快但精度稍差;
|
||
# 放在 -i 后 = output seeking,精度高但慢)
|
||
# 这里用 output seeking 保证精度,反正 copy 模式已经很快了
|
||
start_time = getattr(clip, "start_time", 0) or 0
|
||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||
|
||
command.extend(["-i", str(clip.local_path)])
|
||
|
||
if start_time > 0:
|
||
command.extend(["-ss", f"{start_time:.3f}"])
|
||
|
||
# 计算最终时长
|
||
final_duration = effective_duration
|
||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||
final_duration = video_duration
|
||
if final_duration > 0:
|
||
command.extend(["-t", f"{final_duration:.3f}"])
|
||
|
||
# 流拷贝
|
||
command.extend(
|
||
[
|
||
"-c:v",
|
||
"copy",
|
||
"-c:a",
|
||
"copy",
|
||
"-movflags",
|
||
"+faststart",
|
||
str(output_path),
|
||
]
|
||
)
|
||
|
||
logger.info(
|
||
"[unified-render] stream_copy 渲染: plan_id=%s clip=%s role=%s duration=%.2fs",
|
||
self.plan.id,
|
||
clip.clip_id,
|
||
role,
|
||
final_duration,
|
||
)
|
||
|
||
try:
|
||
run_ffmpeg(command)
|
||
# 验证输出文件存在且有大小
|
||
if output_path.exists() and output_path.stat().st_size > 0:
|
||
logger.info(
|
||
"[unified-render] stream_copy 成功: plan_id=%s size=%d",
|
||
self.plan.id,
|
||
output_path.stat().st_size,
|
||
)
|
||
return True
|
||
else:
|
||
logger.warning("[unified-render] stream_copy 输出为空: plan_id=%s", self.plan.id)
|
||
return False
|
||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||
logger.warning(
|
||
"[unified-render] stream_copy 失败,回退到重编码: plan_id=%s error=%s",
|
||
self.plan.id,
|
||
str(e)[:200],
|
||
)
|
||
# 清理可能的损坏输出文件
|
||
if output_path.exists():
|
||
try:
|
||
output_path.unlink()
|
||
except OSError as unlink_err:
|
||
logger.warning(
|
||
"[unified-render] 损坏输出文件清理失败: path=%s error=%s",
|
||
output_path,
|
||
unlink_err,
|
||
)
|
||
return False
|
||
|
||
def _render_pass_through(
|
||
self,
|
||
layers: list[RenderLayer],
|
||
output_path: Path,
|
||
*,
|
||
ass_path: Path | None = None,
|
||
video_duration: float = 0.0,
|
||
) -> bool:
|
||
"""单图层单 clip 直通渲染(使用 -vf 而非 -filter_complex),一次性输出带音频的最终视频。
|
||
|
||
性能优化:
|
||
- 避免 filter_complex 的解析和调度开销,单clip场景性能提升 ~30%
|
||
- 视频+音频一次FFmpeg调用完成,省去后续音频提取+音视频合并两次调用
|
||
|
||
Args:
|
||
layers: 图层列表(只有1个图层1个clip)
|
||
output_path: 输出文件路径
|
||
ass_path: ASS 字幕文件路径,有则叠加字幕
|
||
video_duration: 视频总时长(用于截断音频,0表示不额外截断)
|
||
|
||
Returns:
|
||
True 表示输出包含音频(近似判断,实际以输出文件为准)
|
||
"""
|
||
clip = layers[0].clips[0]
|
||
role = layers[0].role
|
||
|
||
# 构建视频滤镜链(与 _build_filter_complex 中预处理逻辑一致)
|
||
filters: list[str] = []
|
||
|
||
# trim
|
||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||
|
||
if effective_duration > 0:
|
||
filters.append(f"trim=duration={effective_duration}")
|
||
filters.append("setpts=PTS-STARTPTS")
|
||
|
||
# 调速 — 与 filter_complex 路径一致
|
||
speed = UnifiedRenderService._clip_speed(clip)
|
||
if abs(speed - 1.0) >= 1e-6:
|
||
filters.append(f"setpts=PTS/{speed:.4f}")
|
||
|
||
# 倒放滤镜
|
||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||
if reverse_config.enabled and reverse_config.reverse_video:
|
||
reverse_filter = ReverseEngine.build_video_filter(reverse_config, duration=effective_duration)
|
||
if reverse_filter:
|
||
filters.append(reverse_filter)
|
||
|
||
# scale + crop(铺满裁剪)
|
||
if role in ("overlay", "corner_voice"):
|
||
pip_w = int(self.output_width * _PIP_SCALE)
|
||
pip_h = int(self.output_height * _PIP_SCALE)
|
||
filters.append(f"scale={pip_w}:{pip_h}")
|
||
else:
|
||
# main / broll / background: 铺满裁剪
|
||
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase")
|
||
filters.append(f"crop={self.output_width}:{self.output_height}")
|
||
|
||
# 调色滤镜
|
||
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
|
||
if color_grade.enabled and color_grade.has_effect():
|
||
grade_filter = ColorGradeEngine.build_filter(color_grade)
|
||
if grade_filter:
|
||
filters.append(grade_filter)
|
||
# chroma key 绿幕抠像
|
||
try:
|
||
from video_processing.chroma_key_engine import ChromaKeyConfig, ChromaKeyEngine
|
||
|
||
ck_config = ChromaKeyConfig.from_dict(clip.config.get("chroma_key"))
|
||
if ck_config.has_effect():
|
||
ck_engine = ChromaKeyEngine(ck_config)
|
||
ck_full = ck_engine.build_filter("[in]", "[out]")
|
||
ck_filter_part = ck_full[len("[in]") : -len("[out]")]
|
||
filters.append(ck_filter_part)
|
||
except Exception as e:
|
||
logger.warning("[unified-render] chroma key 直通模式应用失败,跳过: %s", e)
|
||
|
||
filters.append("setpts=PTS-STARTPTS")
|
||
filters.append(f"fps={self.output_fps}")
|
||
filters.append("format=yuv420p")
|
||
|
||
# 字幕叠加
|
||
if ass_path is not None:
|
||
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
|
||
filters.append(f"subtitles='{ass_filter_path}'")
|
||
|
||
vf_str = ",".join(filters)
|
||
|
||
# 最终输出时长:取 clip 有效时长和 video_duration 的较小值
|
||
final_duration = effective_duration
|
||
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
|
||
final_duration = video_duration
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-i",
|
||
str(clip.local_path),
|
||
"-vf",
|
||
vf_str,
|
||
"-c:v",
|
||
"libx264",
|
||
"-crf",
|
||
"23",
|
||
"-preset",
|
||
"medium",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-movflags",
|
||
"+faststart",
|
||
]
|
||
|
||
# 音频处理:background 通常是图片无音频,跳过;其他编码为 aac
|
||
# background 以外的视频素材,默认带音频
|
||
has_audio = role != "background"
|
||
if has_audio:
|
||
# 检查是否需要音频降噪
|
||
af_parts: list[str] = []
|
||
try:
|
||
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
|
||
|
||
plan_config = getattr(self.plan, "config", {}) or {}
|
||
nr_config = NoiseReductionConfig.from_dict(plan_config.get("audio_noise_reduction"))
|
||
if nr_config.has_effect():
|
||
nr_engine = NoiseReductionEngine(nr_config)
|
||
nr_full = nr_engine.build_filter("[in]", "[out]")
|
||
nr_filter_part = nr_full[len("[in]") : -len("[out]")]
|
||
af_parts.append(nr_filter_part)
|
||
except Exception as e:
|
||
logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e)
|
||
|
||
if af_parts:
|
||
command.extend(["-af", ",".join(af_parts)])
|
||
|
||
command.extend(["-c:a", "aac", "-b:a", "128k"])
|
||
|
||
# 音频倒放
|
||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||
if reverse_config.enabled and reverse_config.reverse_audio:
|
||
af_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
|
||
if af_filter:
|
||
command.extend(["-af", af_filter])
|
||
|
||
# 统一截断时长(同时作用于视频和音频)
|
||
if final_duration > 0:
|
||
command.extend(["-t", f"{final_duration:.3f}"])
|
||
|
||
command.append(str(output_path))
|
||
|
||
logger.info(
|
||
"直通渲染: plan_id=%s clip=%s role=%s duration=%.2fs has_audio=%s",
|
||
self.plan.id,
|
||
clip.clip_id,
|
||
role,
|
||
effective_duration,
|
||
has_audio,
|
||
)
|
||
try:
|
||
run_ffmpeg(command)
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error(
|
||
"直通渲染失败: plan_id=%s clip=%s exit_code=%d\nvf=%s",
|
||
self.plan.id,
|
||
clip.clip_id,
|
||
e.returncode,
|
||
vf_str[:2000],
|
||
)
|
||
raise
|
||
|
||
return has_audio
|
||
|
||
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
||
|
||
def _resolve_clips(self) -> list[ResolvedClip]:
|
||
"""将 EditPlanClip 列表解析为 ResolvedClip 列表。
|
||
|
||
跳过 asset_id 为空或在 asset_path_map 中找不到的片段。
|
||
支持多段裁剪:一个 clip 配置了 trim_segments 时会展开为多个 ResolvedClip。
|
||
"""
|
||
resolved: list[ResolvedClip] = []
|
||
for clip in self.clips:
|
||
asset_id = clip.asset_id
|
||
if not asset_id:
|
||
logger.warning("片段无素材: clip_id=%s", clip.id)
|
||
continue
|
||
|
||
local_path = self.asset_path_map.get(asset_id)
|
||
if local_path is None or not local_path.exists():
|
||
logger.warning("素材不存在: clip_id=%s asset_id=%s", clip.id, asset_id)
|
||
continue
|
||
|
||
# 探测实际时长
|
||
try:
|
||
actual_duration = probe_duration(local_path)
|
||
except Exception:
|
||
actual_duration = clip.duration or 5.0
|
||
|
||
# 检查是否有多段裁剪配置
|
||
clip_config = clip.config or {}
|
||
trim_segments = TrimEngine.parse_segments_from_config(clip_config)
|
||
|
||
if trim_segments and len(trim_segments) > 1:
|
||
# 多段裁剪:展开为多个 clip
|
||
resolved_segments = TrimEngine.resolve_segments(trim_segments, actual_duration)
|
||
for i, seg in enumerate(resolved_segments):
|
||
# 每个段生成一个独立的 ResolvedClip
|
||
seg_clip_id = f"{clip.id}_seg_{seg.segment_id}"
|
||
seg_order = clip.order + seg.order * 0.001 + i * 0.0001 # 保持排序
|
||
seg_start = seg.trim.start_time
|
||
seg_duration = seg.trim.duration
|
||
|
||
rc = ResolvedClip(
|
||
clip_id=seg_clip_id,
|
||
asset_id=asset_id,
|
||
local_path=local_path,
|
||
clip_type=clip.clip_type,
|
||
order=seg_order,
|
||
start_time=seg_start,
|
||
duration=seg_duration,
|
||
transition_effect=clip.transition_effect or "cut",
|
||
config={**clip_config, "_segment_id": seg.segment_id},
|
||
actual_duration=actual_duration,
|
||
trim_config=seg.trim,
|
||
)
|
||
resolved.append(rc)
|
||
continue
|
||
|
||
# 单段裁剪(或无裁剪)
|
||
# 解析裁剪配置:config 优先,否则用 clip.start_time + clip.duration
|
||
trim_config = extract_trim_from_clip_config(clip_config)
|
||
if trim_config is None and (clip.start_time > 0 or clip.duration > 0):
|
||
# 用旧字段构造
|
||
trim_config = TrimConfig(
|
||
start_time=clip.start_time,
|
||
duration=clip.duration,
|
||
)
|
||
|
||
# 钳制到实际素材时长
|
||
effective_trim: TrimConfig | None = None
|
||
final_start = clip.start_time
|
||
final_duration = clip.duration
|
||
|
||
if trim_config is not None and actual_duration > 0:
|
||
effective_trim = trim_config.validate_and_resolve(actual_duration)
|
||
if effective_trim.is_valid:
|
||
final_start = effective_trim.start_time
|
||
final_duration = effective_trim.duration
|
||
else:
|
||
# 裁剪无效 → 使用完整素材
|
||
logger.warning("裁剪配置无效,使用完整素材: clip_id=%s", clip.id)
|
||
effective_trim = None
|
||
final_start = 0.0
|
||
final_duration = actual_duration
|
||
|
||
rc = ResolvedClip(
|
||
clip_id=clip.id,
|
||
asset_id=asset_id,
|
||
local_path=local_path,
|
||
clip_type=clip.clip_type,
|
||
order=clip.order,
|
||
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,
|
||
playback_speed=getattr(clip, "playback_speed", 1.0) or 1.0,
|
||
config=clip_config,
|
||
actual_duration=actual_duration,
|
||
trim_config=effective_trim,
|
||
)
|
||
resolved.append(rc)
|
||
|
||
# 按 order 排序
|
||
resolved.sort(key=lambda c: c.order)
|
||
return resolved
|
||
|
||
def _group_clips_into_layers(self, resolved_clips: list[ResolvedClip]) -> list[RenderLayer]:
|
||
"""将 ResolvedClips 分组为 RenderLayers。
|
||
|
||
分组规则见 _resolve_layer_role 函数文档。
|
||
"""
|
||
layer_map: dict[str, RenderLayer] = {}
|
||
|
||
for clip in resolved_clips:
|
||
role = _resolve_layer_role(clip.clip_type, clip.config)
|
||
if role not in layer_map:
|
||
z = _LAYER_Z_INDEX.get(role, 0)
|
||
layer_map[role] = RenderLayer(role=role, z_index=z)
|
||
layer_map[role].clips.append(clip)
|
||
|
||
# 每个 layer 内的 clips 按 order 排序
|
||
for layer in layer_map.values():
|
||
layer.clips.sort(key=lambda c: c.order)
|
||
|
||
# 计算 PiP 位置
|
||
pip_width = int(self.output_width * _PIP_SCALE)
|
||
margin = 20 # 边距
|
||
|
||
if "overlay" in layer_map:
|
||
layer_map["overlay"].position = (
|
||
self.output_width - pip_width - margin,
|
||
margin,
|
||
)
|
||
if "corner_voice" in layer_map:
|
||
layer_map["corner_voice"].position = (
|
||
self.output_width - pip_width - margin,
|
||
margin,
|
||
)
|
||
|
||
# 按 z_index 排序返回
|
||
layers = sorted(layer_map.values(), key=lambda lyr: lyr.z_index)
|
||
return layers
|
||
|
||
def _build_filter_complex(
|
||
self, layers: list[RenderLayer], *, ass_path: Path | None = None
|
||
) -> tuple[str, list[str]]:
|
||
"""构建 FFmpeg filter_complex 字符串和输入参数列表。
|
||
|
||
Args:
|
||
layers: 图层列表
|
||
ass_path: ASS 字幕文件路径,有则在最后叠加字幕
|
||
|
||
Returns:
|
||
(filter_complex_str, input_args_list)
|
||
input_args_list 是 ["-i", path1, "-i", path2, ...] 格式
|
||
"""
|
||
if not layers:
|
||
raise ValueError("没有可渲染的图层")
|
||
|
||
# 收集所有 clips(按图层顺序,同层按 order)
|
||
all_clips: list[ResolvedClip] = []
|
||
for layer in layers:
|
||
all_clips.extend(layer.clips)
|
||
|
||
# 构建输入参数
|
||
input_args: list[str] = []
|
||
clip_to_input_idx: dict[str, int] = {}
|
||
for i, clip in enumerate(all_clips):
|
||
input_args.extend(["-i", str(clip.local_path)])
|
||
clip_to_input_idx[clip.clip_id] = i
|
||
|
||
filter_parts: list[str] = []
|
||
|
||
# Step 1: 预处理每个 clip — trim + scale + setpts
|
||
# 为每个 clip 生成预处理后的标签 [v0], [v1], ...
|
||
preprocessed_labels: list[str] = []
|
||
for i, clip in enumerate(all_clips):
|
||
label = f"v{i}"
|
||
role = _resolve_layer_role(clip.clip_type, clip.config)
|
||
|
||
filters: list[str] = []
|
||
|
||
# trim — 裁剪到指定区间,精确到帧
|
||
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
|
||
trim_start = getattr(clip, "start_time", 0) or 0
|
||
|
||
if effective_duration > 0:
|
||
if trim_start > 0:
|
||
filters.append(f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}")
|
||
else:
|
||
filters.append(f"trim=duration={effective_duration:.3f}")
|
||
filters.append("setpts=PTS-STARTPTS")
|
||
|
||
# 调速 — 基于 setpts 改变播放速度
|
||
speed = UnifiedRenderService._clip_speed(clip)
|
||
if abs(speed - 1.0) >= 1e-6:
|
||
filters.append(f"setpts=PTS/{speed:.4f}")
|
||
|
||
# 倒放滤镜(在 trim 之后、scale 之前应用)
|
||
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
|
||
if reverse_config.enabled and reverse_config.reverse_video:
|
||
reverse_filter = ReverseEngine.build_video_filter(reverse_config, duration=effective_duration)
|
||
if reverse_filter:
|
||
filters.append(reverse_filter)
|
||
|
||
# scale
|
||
if role in ("overlay", "corner_voice"):
|
||
pip_w = int(self.output_width * _PIP_SCALE)
|
||
pip_h = int(self.output_height * _PIP_SCALE)
|
||
filters.append(f"scale={pip_w}:{pip_h}")
|
||
elif role == "background":
|
||
filters.append(
|
||
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
|
||
)
|
||
filters.append(f"crop={self.output_width}:{self.output_height}")
|
||
else:
|
||
# main / broll: 铺满裁剪(scale to cover + center crop)
|
||
# 对齐链路A编辑器合成行为,与主流短视频平台一致
|
||
filters.append(
|
||
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
|
||
)
|
||
filters.append(f"crop={self.output_width}:{self.output_height}")
|
||
|
||
# 调色滤镜(每个 clip 独立的 color grade 配置)
|
||
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
|
||
if color_grade.enabled and color_grade.has_effect():
|
||
grade_filter = ColorGradeEngine.build_filter(color_grade)
|
||
if grade_filter:
|
||
filters.append(grade_filter)
|
||
# chroma key 绿幕抠像(在 scale 之后,fps 之前)
|
||
try:
|
||
from video_processing.chroma_key_engine import ChromaKeyConfig, ChromaKeyEngine
|
||
|
||
ck_config = ChromaKeyConfig.from_dict(clip.config.get("chroma_key"))
|
||
if ck_config.has_effect():
|
||
ck_engine = ChromaKeyEngine(ck_config)
|
||
# 提取滤镜部分(不带输入输出标签)
|
||
ck_full = ck_engine.build_filter("[in]", "[out]")
|
||
ck_filter_part = ck_full[len("[in]") : -len("[out]")]
|
||
filters.append(ck_filter_part)
|
||
except Exception as e:
|
||
logger.warning("[unified-render] chroma key 应用失败,跳过 clip=%s: %s", clip.clip_id, e)
|
||
|
||
filters.append("setpts=PTS-STARTPTS")
|
||
filters.append(f"fps={self.output_fps}")
|
||
|
||
filter_str = f"[{i}:v]{','.join(filters)}[{label}]"
|
||
filter_parts.append(filter_str)
|
||
preprocessed_labels.append(label)
|
||
|
||
# Step 2: 同层 clips 用 xfade 串联
|
||
layer_output_labels: dict[str, str] = {}
|
||
for layer in layers:
|
||
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
|
||
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
|
||
# 使用调速后的实际时长,与 Step 1 的调速处理保持一致
|
||
layer_durations = [UnifiedRenderService._clip_adjusted_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:
|
||
out_label = f"{layer.role}_merged"
|
||
# 判断是否全部为硬切:是则用 concat filter,否则用 xfade 转场链
|
||
all_cut = all(
|
||
t is None or t == "" or str(t).lower() == "cut"
|
||
for t in layer_transitions[1:] # 第一个 clip 的转场忽略
|
||
)
|
||
if all_cut:
|
||
# 全硬切:用 concat filter,性能远优于 xfade
|
||
concat_inputs = "".join(f"[{label}]" for label in layer_labels)
|
||
filter_parts.append(f"{concat_inputs}concat=n={len(layer_labels)}:v=1:a=0[{out_label}]")
|
||
logger.info(
|
||
"[unified-render] layer=%s clips=%d using concat (all hard-cut)",
|
||
layer.role,
|
||
len(layer_labels),
|
||
)
|
||
else:
|
||
# 有转场效果:用 TransitionEngine 构建 xfade 链
|
||
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=layer_dur if layer_dur > 0 else None,
|
||
output_label=out_label,
|
||
)
|
||
if xfade_filter:
|
||
filter_parts.append(xfade_filter)
|
||
layer_output_labels[layer.role] = out_label
|
||
|
||
# Step 3: 合成各层
|
||
# 找到主层 — background 优先作为底图,其次 broll / main
|
||
final_video_label = None
|
||
|
||
if "background" in layer_output_labels:
|
||
final_video_label = layer_output_labels["background"]
|
||
# b_roll / main 叠加到 background 上
|
||
for role in ("broll", "main"):
|
||
if role in layer_output_labels:
|
||
base_label = layer_output_labels[role]
|
||
combined_label = f"combined_{role}"
|
||
filter_parts.append(
|
||
f"[{final_video_label}][{base_label}]" f"overlay=(W-w)/2:(H-h)/2[{combined_label}]"
|
||
)
|
||
final_video_label = combined_label
|
||
else:
|
||
# 无 background 时,取 broll 或 main 作为基础
|
||
for role in ("broll", "main"):
|
||
if role in layer_output_labels:
|
||
final_video_label = layer_output_labels[role]
|
||
break
|
||
|
||
if final_video_label is None:
|
||
# 没有任何主层,使用第一个层
|
||
final_video_label = layer_output_labels[layers[0].role]
|
||
|
||
# 叠加 overlay 层
|
||
for layer in layers:
|
||
if layer.role in ("overlay", "corner_voice"):
|
||
if layer.role not in layer_output_labels:
|
||
continue
|
||
overlay_label = layer_output_labels[layer.role]
|
||
x, y = layer.position or (
|
||
self.output_width - int(self.output_width * _PIP_SCALE) - 20,
|
||
20,
|
||
)
|
||
combined_label = f"combined_{layer.role}"
|
||
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
|
||
final_video_label = combined_label
|
||
|
||
# 叠加水印(在字幕之前)
|
||
watermark_config = WatermarkConfig.from_dict((self.plan.config or {}).get("watermark"))
|
||
if watermark_config is not None:
|
||
wm_valid, wm_err = watermark_config.validate()
|
||
if wm_valid:
|
||
wm_label = "watermarked"
|
||
if watermark_config.mode == "image":
|
||
# 图片水印:检查图片是否存在
|
||
wm_path = Path(watermark_config.image_path)
|
||
if wm_path.exists():
|
||
# 图片水印需要额外输入,放在 filter 开头
|
||
wm_idx = len(all_clips) # 水印图是最后一个输入
|
||
wm_scale = int(self.output_width * watermark_config.scale)
|
||
|
||
# 透明度
|
||
wm_filters = f"scale={wm_scale}:-1"
|
||
if watermark_config.opacity < 1.0:
|
||
wm_filters += f",format=rgba,colorchannelmixer=aa={watermark_config.opacity}"
|
||
|
||
filter_parts.insert(0, f"[{wm_idx}:v]{wm_filters}[wm_scaled]")
|
||
input_args.extend(["-i", str(wm_path)])
|
||
|
||
# 位置计算(水印高度用 scale 后的宽度近似)
|
||
wm_h = wm_scale # 近似(正方形假设)
|
||
x, y = WatermarkEngine.calc_position(
|
||
watermark_config.position,
|
||
self.output_width,
|
||
self.output_height,
|
||
wm_scale,
|
||
wm_h,
|
||
watermark_config.margin_x,
|
||
watermark_config.margin_y,
|
||
)
|
||
|
||
# 滚动水印
|
||
if watermark_config.scroll:
|
||
x_expr = f"W-mod({watermark_config.scroll_speed}*t\\,W+w)"
|
||
overlay = f"[{final_video_label}][wm_scaled]overlay=x={x_expr}:y={y}[{wm_label}]"
|
||
else:
|
||
overlay = f"[{final_video_label}][wm_scaled]overlay=x={x}:y={y}[{wm_label}]"
|
||
|
||
filter_parts.append(overlay)
|
||
final_video_label = wm_label
|
||
else:
|
||
logger.warning("水印图片不存在,跳过水印: %s", wm_path)
|
||
elif watermark_config.mode == "text":
|
||
# 文字水印
|
||
try:
|
||
text_wm = WatermarkEngine.build_text_watermark_filter(
|
||
f"[{final_video_label}]",
|
||
f"[{wm_label}]",
|
||
watermark_config,
|
||
self.output_width,
|
||
self.output_height,
|
||
)
|
||
filter_parts.append(text_wm)
|
||
final_video_label = wm_label
|
||
except Exception as e:
|
||
logger.warning("文字水印构建失败,跳过: %s", e)
|
||
# 贴纸叠加(图片贴纸 + 文字贴纸)
|
||
sticker_filter, sticker_extra_inputs = self._build_sticker_filters(final_video_label, "after_stickers")
|
||
if sticker_filter:
|
||
filter_parts.append(sticker_filter)
|
||
# 图片贴纸需要额外输入
|
||
for img_path in sticker_extra_inputs:
|
||
input_args.extend(["-i", img_path])
|
||
final_video_label = "after_stickers"
|
||
|
||
# 叠加字幕(如有)+ 最终像素格式
|
||
if ass_path is not None:
|
||
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
|
||
filter_parts.append(f"[{final_video_label}]subtitles='{ass_filter_path}',format=yuv420p[final_video]")
|
||
else:
|
||
filter_parts.append(f"[{final_video_label}]format=yuv420p[final_video]")
|
||
|
||
filter_complex = ";".join(filter_parts)
|
||
return filter_complex, input_args
|
||
|
||
def _execute_ffmpeg(
|
||
self,
|
||
filter_complex: str,
|
||
input_args: list[str],
|
||
output_path: Path,
|
||
) -> None:
|
||
"""执行 FFmpeg 渲染命令。
|
||
|
||
失败时记录完整 filter_complex 以便排查(如 exit code 183)。
|
||
"""
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
*input_args,
|
||
"-filter_complex",
|
||
filter_complex,
|
||
"-map",
|
||
"[final_video]",
|
||
"-c:v",
|
||
"libx264",
|
||
"-crf",
|
||
"23",
|
||
"-preset",
|
||
"medium",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-movflags",
|
||
"+faststart",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info(
|
||
"执行渲染: plan_id=%s inputs=%d output=%s",
|
||
self.plan.id,
|
||
input_args.count("-i"),
|
||
output_path,
|
||
)
|
||
try:
|
||
run_ffmpeg(command)
|
||
except subprocess.CalledProcessError as e:
|
||
# 额外记录 filter_complex,方便排查滤镜链构建问题
|
||
logger.error(
|
||
"渲染失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s",
|
||
self.plan.id,
|
||
e.returncode,
|
||
filter_complex[:5000],
|
||
)
|
||
raise
|
||
|
||
def _build_sticker_filters(self, input_label: str, output_label: str) -> tuple[str, list[str]]:
|
||
"""构建贴纸叠加滤镜链.
|
||
|
||
Args:
|
||
input_label: 输入视频标签
|
||
output_label: 输出视频标签
|
||
|
||
Returns:
|
||
(filter_str, extra_input_paths)
|
||
filter_str: 贴纸滤镜字符串(空表示无贴纸)
|
||
extra_input_paths: 额外需要的输入文件路径(图片贴纸)
|
||
"""
|
||
plan_config = getattr(self.plan, "config", None) or {}
|
||
if isinstance(plan_config, dict):
|
||
stickers_data = plan_config.get("stickers", [])
|
||
else:
|
||
stickers_data = []
|
||
|
||
if not stickers_data:
|
||
return "", []
|
||
|
||
try:
|
||
result = StickerEngine.build_sticker_chain(
|
||
stickers=stickers_data,
|
||
input_label=f"[{input_label}]",
|
||
output_label=f"[{output_label}]",
|
||
canvas_w=self.output_width,
|
||
canvas_h=self.output_height,
|
||
)
|
||
return result.filter_str, result.extra_inputs
|
||
except Exception as e:
|
||
logger.warning("贴纸滤镜构建失败,跳过贴纸: %s", e)
|
||
return "", []
|
||
|
||
def _probe_output(self, output_path: Path) -> tuple[float, int, int, int]:
|
||
"""探测输出文件的时长、大小、宽高.
|
||
|
||
Returns:
|
||
(duration, file_size, width, height)
|
||
"""
|
||
info = probe_video_info(str(output_path))
|
||
file_size = output_path.stat().st_size if output_path.exists() else 0
|
||
return (
|
||
info["duration"],
|
||
file_size,
|
||
info["width"],
|
||
info["height"],
|
||
)
|
||
|
||
@staticmethod
|
||
def _clip_effective_duration(clip: ResolvedClip) -> float:
|
||
"""计算 clip 的有效时长(原速 trim 后时长)."""
|
||
if clip.duration > 0:
|
||
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
|
||
return clip.actual_duration if clip.actual_duration > 0 else 0.0
|
||
|
||
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
|
||
|
||
def _resolve_pip_sources(self, pip_config: PiPConfig) -> list[tuple[str, PiPLayerConfig, Path]]:
|
||
"""解析画中画图层的素材源,返回可用的图层列表.
|
||
|
||
降级策略:素材不存在或无效的图层自动跳过,不阻断渲染。
|
||
|
||
Returns:
|
||
[(input_label_placeholder, layer_config, local_path), ...]
|
||
input_label 在 build_pip_filters 中会用实际的输入索引替换
|
||
"""
|
||
if not pip_config.enabled:
|
||
return []
|
||
|
||
engine = PiPEngine(
|
||
output_width=self.output_width,
|
||
output_height=self.output_height,
|
||
output_fps=self.output_fps,
|
||
)
|
||
|
||
result = []
|
||
for i, layer in enumerate(pip_config.layers):
|
||
path = engine.validate_layer_source(layer, self.asset_path_map)
|
||
if path is None:
|
||
logger.warning("PiP图层素材不可用,跳过: layer_index=%d source=%s", i, layer.source)
|
||
continue
|
||
# 标签占位,实际输入索引由 build_pip_filters 内部管理
|
||
result.append((f"pip_src_{i}", layer, path))
|
||
|
||
return result
|
||
|
||
def _append_pip_filters(
|
||
self,
|
||
filter_complex: str,
|
||
input_args: list[str],
|
||
pip_sources: list[tuple[str, Any, Path]],
|
||
) -> tuple[str, list[str]]:
|
||
"""将画中画滤镜追加到 filter_complex 末尾.
|
||
|
||
处理逻辑:
|
||
1. 将原 final_video 标签重命名为 pip_base(作为PiP的底层视频)
|
||
2. 追加 PiP 预处理和 overlay 滤镜
|
||
3. PiP 最终输出命名为 final_video
|
||
|
||
Args:
|
||
filter_complex: 原 filter_complex 字符串
|
||
input_args: 原输入参数列表
|
||
pip_sources: PiP 素材列表 [(label, layer_config, path), ...]
|
||
|
||
Returns:
|
||
(new_filter_complex, new_input_args)
|
||
"""
|
||
if not pip_sources:
|
||
return filter_complex, input_args
|
||
|
||
pip_engine = PiPEngine(
|
||
output_width=self.output_width,
|
||
output_height=self.output_height,
|
||
output_fps=self.output_fps,
|
||
)
|
||
|
||
# 1. 将原 final_video 改为 pip_base
|
||
new_filter = filter_complex.replace("[final_video]", "[pip_base]")
|
||
|
||
# 2. 构建 PiP 滤镜链
|
||
# 主输入数量 = len(input_args) // 2(每个输入占 "-i path" 两个参数)
|
||
base_input_idx = len(input_args) // 2
|
||
pip_filter_parts, pip_input_args, final_label = pip_engine.build_pip_filters(
|
||
base_label="pip_base",
|
||
pip_sources=pip_sources,
|
||
base_input_idx=base_input_idx,
|
||
)
|
||
|
||
if not pip_filter_parts:
|
||
# 没有有效PiP滤镜,恢复原标签
|
||
return filter_complex, input_args
|
||
|
||
# 3. 追加 PiP 滤镜 + 最终格式转换(输出为 final_video)
|
||
pip_filter_str = ";".join(pip_filter_parts)
|
||
final_format = f"[{final_label}]format=yuv420p[final_video]"
|
||
new_filter = f"{new_filter};{pip_filter_str};{final_format}"
|
||
|
||
# 4. 追加输入参数
|
||
new_input_args = list(input_args) + pip_input_args
|
||
|
||
logger.info(
|
||
"[unified-render] appended PiP filters: layers=%d new_inputs=%d",
|
||
len(pip_sources),
|
||
len(pip_input_args) // 2,
|
||
)
|
||
|
||
return new_filter, new_input_args
|
||
|
||
@staticmethod
|
||
def _clip_speed(clip: ResolvedClip) -> float:
|
||
"""获取 clip 的播放速度,无效值回退到 1.0."""
|
||
speed = getattr(clip, "playback_speed", 1.0)
|
||
if not isinstance(speed, (int, float)) or speed <= 0:
|
||
return 1.0
|
||
return float(speed)
|
||
|
||
@staticmethod
|
||
def _clip_adjusted_duration(clip: ResolvedClip) -> float:
|
||
"""计算调速后的 clip 实际时长(用于拼接计算)."""
|
||
base = UnifiedRenderService._clip_effective_duration(clip)
|
||
speed = UnifiedRenderService._clip_speed(clip)
|
||
if abs(speed - 1.0) < 1e-6:
|
||
return base
|
||
return base / speed
|