style: black格式化
CI/CD Pipeline / Check if frontend-only change (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Successful in 44s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 44s

This commit is contained in:
2026-07-27 08:44:48 +08:00
parent db7f3617c3
commit 8f61950eae
@@ -225,9 +225,7 @@ class UnifiedRenderService:
# 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 []
)
pip_sources = self._resolve_pip_sources(pip_config) if pip_config.enabled else []
has_pip = len(pip_sources) > 0
# 灰度埋点:开始渲染
@@ -273,15 +271,11 @@ class UnifiedRenderService:
video_duration=video_duration,
)
else:
filter_complex, input_args = self._build_filter_complex(
layers, ass_path=ass_path
)
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
)
filter_complex, input_args = self._append_pip_filters(filter_complex, input_args, pip_sources)
self._execute_ffmpeg(filter_complex, input_args, video_only_path)
@@ -313,9 +307,7 @@ class UnifiedRenderService:
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"
)
main_audio_path = self.work_dir / f"pass_through_audio_{self.plan.id}.aac"
extract_cmd = [
FFMPEG_BIN,
"-y",
@@ -332,9 +324,7 @@ class UnifiedRenderService:
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
)
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"
@@ -388,9 +378,7 @@ class UnifiedRenderService:
duration, file_size, width, height = self._probe_output(output_path)
# 9. 片头片尾拼接(后处理)
intro_outro_config = IntroOutroConfig.from_dict(
(self.plan.config or {}).get("intro_outro")
)
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:
@@ -461,9 +449,7 @@ class UnifiedRenderService:
if concat_ok and final_with_io.exists():
output_path = final_with_io
# 重新探测
duration, file_size, width, height = self._probe_output(
output_path
)
duration, file_size, width, height = self._probe_output(output_path)
logger.info(
"[unified-render] 片头片尾拼接完成: plan_id=%s",
self.plan.id,
@@ -528,9 +514,7 @@ class UnifiedRenderService:
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
)
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
@@ -558,9 +542,7 @@ class UnifiedRenderService:
return ass_path
else:
# ASR 无结果,不生成字幕
logger.info(
"ASR自动字幕无识别结果,跳过字幕: plan_id=%s", self.plan.id
)
logger.info("ASR自动字幕无识别结果,跳过字幕: plan_id=%s", self.plan.id)
return None
except Exception:
# ASR 失败降级:不生成字幕,不阻断主流程
@@ -588,9 +570,7 @@ class UnifiedRenderService:
)
return ass_path
def _generate_asr_subtitles(
self, video_duration: float, subtitle_cfg: dict
) -> Any: # SubtitleTimeline
def _generate_asr_subtitles(self, video_duration: float, subtitle_cfg: dict) -> Any: # SubtitleTimeline
"""从视频素材音频中自动识别生成字幕时间轴。
MVP 版本:使用第一个有音频的素材做ASR,然后按比例映射到整个视频时长。
@@ -721,11 +701,7 @@ class UnifiedRenderService:
len(top_text),
)
# 方式B:voice_id + 自动字幕 → 字幕对齐配音(预设配音模式)
elif (
top_voice_id
and subtitle_cfg.get("auto_generated", False)
and self.asr_service is not None
):
elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None:
tts_cfg = {
"enabled": True,
"voice_id": top_voice_id,
@@ -798,9 +774,7 @@ class UnifiedRenderService:
result = tts_engine.generate_subtitle_voiceover(tts_config, subtitles)
else:
# 整段配音模式
result = tts_engine.generate_full_voiceover(
tts_config, total_duration=video_duration
)
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)
@@ -872,9 +846,7 @@ class UnifiedRenderService:
audio_path = Path(self.voiceover_audio_path)
if not audio_path.exists() or audio_path.stat().st_size == 0:
logger.warning(
"配音素材库音频文件不存在或为空,跳过: %s", self.voiceover_audio_path
)
logger.warning("配音素材库音频文件不存在或为空,跳过: %s", self.voiceover_audio_path)
return False
try:
@@ -1023,9 +995,7 @@ class UnifiedRenderService:
# 有倒放 → 需要重编码 → 不能 copy
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
if reverse_config.enabled and (
reverse_config.reverse_video or reverse_config.reverse_audio
):
if reverse_config.enabled and (reverse_config.reverse_video or reverse_config.reverse_audio):
return False, "有倒放效果"
# 探测输入视频参数
@@ -1040,10 +1010,7 @@ class UnifiedRenderService:
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
):
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)} "
@@ -1093,9 +1060,7 @@ class UnifiedRenderService:
role = layers[0].role
# 判断是否满足 copy 条件
can_copy, reason = self._can_use_stream_copy(
clip, ass_path=ass_path, video_duration=video_duration
)
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",
@@ -1123,9 +1088,7 @@ class UnifiedRenderService:
# 计算最终时长
final_duration = effective_duration
if video_duration > 0 and (
final_duration <= 0 or final_duration > video_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}"])
@@ -1162,9 +1125,7 @@ class UnifiedRenderService:
)
return True
else:
logger.warning(
"[unified-render] stream_copy 输出为空: plan_id=%s", self.plan.id
)
logger.warning("[unified-render] stream_copy 输出为空: plan_id=%s", self.plan.id)
return False
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(
@@ -1228,9 +1189,7 @@ class UnifiedRenderService:
# 倒放滤镜
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
)
reverse_filter = ReverseEngine.build_video_filter(reverse_config, duration=effective_duration)
if reverse_filter:
filters.append(reverse_filter)
@@ -1241,18 +1200,12 @@ class UnifiedRenderService:
filters.append(f"scale={pip_w}:{pip_h}")
elif role == "background":
# background: 铺满裁剪(作为底图,覆盖全屏)
filters.append(
f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=increase"
)
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: 等比缩放 + 居中留黑边(保持原始比例,不裁剪内容)
filters.append(
f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease"
)
filters.append(
f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black"
)
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 调色滤镜
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
@@ -1291,9 +1244,7 @@ class UnifiedRenderService:
# 注意:必须用调速后的时长,否则减速场景(speed<1)会被 -t 截断
adjusted_duration = UnifiedRenderService._clip_adjusted_duration(clip)
final_duration = adjusted_duration
if video_duration > 0 and (
final_duration <= 0 or final_duration > video_duration
):
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
command = [
@@ -1329,9 +1280,7 @@ class UnifiedRenderService:
)
plan_config = getattr(self.plan, "config", {}) or {}
nr_config = NoiseReductionConfig.from_dict(
plan_config.get("audio_noise_reduction")
)
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]")
@@ -1350,16 +1299,12 @@ class UnifiedRenderService:
speed_engine = SpeedEngine()
af_parts.append(speed_engine.build_audio_filter(speed_cfg))
except Exception as e:
logger.warning(
"[unified-render] 直通模式音频调速应用失败,跳过: %s", e
)
logger.warning("[unified-render] 直通模式音频调速应用失败,跳过: %s", e)
# 音频倒放
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
)
af_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
if af_filter:
af_parts.append(af_filter)
@@ -1386,9 +1331,7 @@ class UnifiedRenderService:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
stderr_text = (e.stderr or "").strip()
stderr_tail = (
stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
)
stderr_tail = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
logger.error(
"直通渲染失败: plan_id=%s clip=%s exit_code=%d\nvf=%s\nstderr(last 1500):\n%s",
self.plan.id,
@@ -1433,9 +1376,7 @@ class UnifiedRenderService:
if trim_segments and len(trim_segments) > 1:
# 多段裁剪:展开为多个 clip
resolved_segments = TrimEngine.resolve_segments(
trim_segments, actual_duration
)
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}"
@@ -1452,8 +1393,7 @@ class UnifiedRenderService:
start_time=seg_start,
duration=seg_duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=getattr(clip, "transition_duration", 0.0)
or 0.0,
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, "_segment_id": seg.segment_id},
actual_duration=actual_duration,
@@ -1510,9 +1450,7 @@ class UnifiedRenderService:
resolved.sort(key=lambda c: c.order)
return resolved
def _group_clips_into_layers(
self, resolved_clips: list[ResolvedClip]
) -> list[RenderLayer]:
def _group_clips_into_layers(self, resolved_clips: list[ResolvedClip]) -> list[RenderLayer]:
"""将 ResolvedClips 分组为 RenderLayers。
分组规则见 _resolve_layer_role 函数文档。
@@ -1594,9 +1532,7 @@ class UnifiedRenderService:
if effective_duration > 0:
if trim_start > 0:
filters.append(
f"trim=start={trim_start:.3f}:duration={effective_duration:.3f}"
)
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")
@@ -1609,9 +1545,7 @@ class UnifiedRenderService:
# 倒放滤镜(在 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
)
reverse_filter = ReverseEngine.build_video_filter(reverse_config, duration=effective_duration)
if reverse_filter:
filters.append(reverse_filter)
@@ -1621,19 +1555,13 @@ class UnifiedRenderService:
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"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: 等比缩放 + 居中留黑边(保持原始比例,不裁剪内容)
# concat 要求所有输入分辨率完全一致,pad 模式确保不同宽高比的素材都能正常拼接
filters.append(
f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease"
)
filters.append(
f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black"
)
filters.append(f"scale={self.output_width}:{self.output_height}:force_original_aspect_ratio=decrease")
filters.append(f"pad={self.output_width}:{self.output_height}:trunc((ow-iw)/2):trunc((oh-ih)/2):black")
# 调色滤镜(每个 clip 独立的 color grade 配置)
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
@@ -1675,16 +1603,9 @@ class UnifiedRenderService:
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
]
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 层,直接使用预处理标签
@@ -1699,9 +1620,7 @@ class UnifiedRenderService:
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}]"
)
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,
@@ -1736,9 +1655,7 @@ class UnifiedRenderService:
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}]overlay=(W-w)/2:(H-h)/2[{combined_label}]"
)
filter_parts.append(f"[{final_video_label}][{base_label}]overlay=(W-w)/2:(H-h)/2[{combined_label}]")
final_video_label = combined_label
else:
# 无 background 时,取 broll 或 main 作为基础
@@ -1762,15 +1679,11 @@ class UnifiedRenderService:
20,
)
combined_label = f"combined_{layer.role}"
filter_parts.append(
f"[{final_video_label}][{overlay_label}]overlay={x}:{y}[{combined_label}]"
)
filter_parts.append(f"[{final_video_label}][{overlay_label}]overlay={x}:{y}[{combined_label}]")
final_video_label = combined_label
# 叠加水印(在字幕之前)
watermark_config = UnifiedRenderService._resolve_watermark_config(
self.plan.config
)
watermark_config = UnifiedRenderService._resolve_watermark_config(self.plan.config)
if watermark_config is not None:
wm_valid, wm_err = watermark_config.validate()
if wm_valid:
@@ -1829,9 +1742,7 @@ class UnifiedRenderService:
except Exception as e:
logger.warning("文字水印构建失败,跳过: %s", e)
# 贴纸叠加(图片贴纸 + 文字贴纸)
sticker_filter, sticker_extra_inputs = self._build_sticker_filters(
final_video_label, "after_stickers"
)
sticker_filter, sticker_extra_inputs = self._build_sticker_filters(final_video_label, "after_stickers")
if sticker_filter:
filter_parts.append(sticker_filter)
# 图片贴纸需要额外输入
@@ -1842,9 +1753,7 @@ class UnifiedRenderService:
# 叠加字幕(如有)+ 最终像素格式
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]"
)
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]")
@@ -1893,9 +1802,7 @@ class UnifiedRenderService:
except subprocess.CalledProcessError as e:
# 额外记录 filter_complex + stderr,方便排查滤镜链构建问题
stderr_text = (e.stderr or "").strip()
stderr_tail = (
stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
)
stderr_tail = stderr_text[-1500:] if len(stderr_text) > 1500 else stderr_text
logger.error(
"渲染失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s\nstderr(last 1500):\n%s",
self.plan.id,
@@ -1905,9 +1812,7 @@ class UnifiedRenderService:
)
raise
def _build_sticker_filters(
self, input_label: str, output_label: str
) -> tuple[str, list[str]]:
def _build_sticker_filters(self, input_label: str, output_label: str) -> tuple[str, list[str]]:
"""构建贴纸叠加滤镜链.
Args:
@@ -1966,9 +1871,7 @@ class UnifiedRenderService:
# ── 画中画(PiP)相关方法 ──────────────────────────────────────────────────
def _resolve_pip_sources(
self, pip_config: PiPConfig
) -> list[tuple[str, PiPLayerConfig, Path]]:
def _resolve_pip_sources(self, pip_config: PiPConfig) -> list[tuple[str, PiPLayerConfig, Path]]:
"""解析画中画图层的素材源,返回可用的图层列表.
降级策略:素材不存在或无效的图层自动跳过,不阻断渲染。
@@ -1990,9 +1893,7 @@ class UnifiedRenderService:
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
)
logger.warning("PiP图层素材不可用,跳过: layer_index=%d source=%s", i, layer.source)
continue
# 标签占位,实际输入索引由 build_pip_filters 内部管理
result.append((f"pip_src_{i}", layer, path))