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
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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user