diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 392450cf2..58c622b42 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -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))