diff --git a/apps/worker/video_processing/render_adapter.py b/apps/worker/video_processing/render_adapter.py index d1abb4089..8d84f9735 100755 --- a/apps/worker/video_processing/render_adapter.py +++ b/apps/worker/video_processing/render_adapter.py @@ -483,6 +483,7 @@ class RenderAdapter: progress_cb: ProgressCallback | None = None, rendered_clip_ids: list[str] | None = None, failed_clip_ids: list[str] | None = None, + voiceover_audio_path: str | None = None, ) -> RenderAdapterResult: """执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。 @@ -491,6 +492,7 @@ class RenderAdapter: Args: rendered_clip_ids: 成功下载/准备的 clip id 列表(render_plan 从下载阶段传入) failed_clip_ids: 失败的 clip id 列表 + voiceover_audio_path: 配音素材库音频本地路径(一键生成场景使用) Returns: RenderAdapterResult @@ -525,6 +527,7 @@ class RenderAdapter: output_height=output_height, bgm_path=bgm_path, asr_service=asr_service, + voiceover_audio_path=voiceover_audio_path, ) result = render_svc.render() @@ -593,6 +596,7 @@ class RenderAdapter: job_id: str = "", work_dir: Path | None = None, progress_cb: ProgressCallback | None = None, + voiceover_audio_path: str | None = None, ) -> RenderAdapterResult: """使用内存中的 plan/clips/asset_path_map 直接渲染。 @@ -606,6 +610,7 @@ class RenderAdapter: job_id: 关联的 Job ID work_dir: 工作目录,不传则用临时目录 progress_cb: 进度回调 + voiceover_audio_path: 配音素材库音频本地路径 Returns: RenderAdapterResult @@ -649,6 +654,7 @@ class RenderAdapter: plan_id=actual_plan_id, job_id=job_id, progress_cb=progress_cb, + voiceover_audio_path=voiceover_audio_path, ) except subprocess.CalledProcessError as exc: diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 67401439a..45a3e0c85 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -175,6 +175,7 @@ class UnifiedRenderService: transition_duration: float = DEFAULT_TRANSITION_DURATION, asr_service: Any = None, # ASRService 实例,用于自动生成字幕 bgm_path: str | None = None, # BGM 本地文件路径 + voiceover_audio_path: str | None = None, # 配音素材库音频本地路径 ): self.plan = plan self.clips = clips @@ -186,6 +187,7 @@ class UnifiedRenderService: self.transition_duration = transition_duration self.asr_service = asr_service self.bgm_path = bgm_path + self.voiceover_audio_path = voiceover_audio_path self._transition_engine = TransitionEngine(default_duration=transition_duration) self._speed_engine = SpeedEngine() self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用 @@ -226,6 +228,9 @@ class UnifiedRenderService: # 3.5 TTS 配音生成(如果配置了) self._maybe_add_voiceover_layer(layers, video_duration=video_duration) + # 3.6 配音素材库音频(如果传入了本地路径) + self._maybe_add_voice_library_layer(layers, video_duration=video_duration) + # 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置) ass_path = self._maybe_generate_ass(video_duration) @@ -843,6 +848,69 @@ class UnifiedRenderService: logger.warning("TTS 配音异常,跳过: %s", e) return False + def _maybe_add_voice_library_layer( + self, + layers: list[RenderLayer], + *, + video_duration: float, + ) -> bool: + """将配音素材库音频作为整段配音加到 audio 图层. + + 与 TTS 配音共享同一套 audio 图层混音架构, + 支持与 BGM、TTS 的音量平衡,不再走独立的后处理 mux 链路。 + + Returns: + 是否成功添加了配音音轨 + """ + if not self.voiceover_audio_path: + return False + + 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) + return False + + try: + # 找到或创建 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) + + # 配音素材作为整段配音:从 0 开始,覆盖整个视频时长 + # 音频不足视频时长时,混音层会按实际长度处理(amix 不自动循环) + vo_clip = ResolvedClip( + clip_id="voice_library_main", + asset_id="voice_library", + local_path=audio_path, + clip_type="audio", + order=len(audio_layer.clips), + start_time=0.0, + duration=video_duration, + config={"volume": 1.0, "voice_library": True}, + actual_duration=video_duration, + ) + audio_layer.clips.append(vo_clip) + + logger.info( + "配音素材库音频已添加到 audio 图层: plan_id=%s duration=%.2fs", + self.plan.id, + video_duration, + ) + return True + + except Exception as e: + logger.warning("配音素材库音频添加失败,跳过: %s", e) + return False + @staticmethod def _resolve_watermark_config(plan_config: dict[str, Any] | None) -> WatermarkConfig | None: """从 plan config 中解析水印配置,兼容两种存储格式. diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index 6b1e1bd37..d165295ea 100755 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -1238,6 +1238,7 @@ def _render_video( plan_id=f"gen_{task_id}", job_id=task_id, work_dir=temp_path, + voiceover_audio_path=voice_path, ) finally: db.close() @@ -1256,17 +1257,8 @@ def _render_video( render_duration, ) - # 配音混音(素材库音频,后处理混音) - if voice_path: - final_path = temp_path / f"final-{task_id}.mp4" - try: - _mux_audio_track(render_output_path, voice_path, final_path) - output_path = final_path - except Exception as mux_err: - logger.warning("[task_id=%s] [混音] 音频混合失败,使用无音频版本: %s", task_id, mux_err) - output_path = render_output_path - else: - output_path = render_output_path + # 配音素材库音频已在统一渲染引擎内部通过 audio 图层混音处理 + output_path = render_output_path return output_path, render_duration