diff --git a/apps/api/app/api/routes/edit_plans_generation.py b/apps/api/app/api/routes/edit_plans_generation.py index fbd21f518..56b1ab7d6 100755 --- a/apps/api/app/api/routes/edit_plans_generation.py +++ b/apps/api/app/api/routes/edit_plans_generation.py @@ -132,17 +132,21 @@ def _auto_fallback_auto_material_mode( asset_library_repo: Any, asset_repo: Any, ) -> None: - """自动兜底 4: 自动素材模式 → 从项目默认视频素材库选取""" + """自动兜底 4: 项目有视频素材库时,自动选取 ready 视频素材分配给无素材片段 + + 注:原先需要 material_mode=="auto" 才触发,但全代码库没有任何地方设置为 auto, + 导致这道兜底防线永远不生效。现改为:只要有 project_id 且存在无素材片段, + 就自动从项目视频素材库选取素材兜底,确保一键生成等场景能正常出片。 + """ if not clips_without_asset: return - material_mode = (plan_check.config or {}).get("material_mode", "manual") - if material_mode != "auto" or not plan_check.project_id: + if not plan_check.project_id: return import random logger.info( - "自动兜底4: plan=%s 自动素材模式,从项目素材库选取素材 (%d 个片段需要)", + "自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段", plan_id, len(clips_without_asset), ) diff --git a/apps/api/app/api/routes/edit_plans_timeline.py b/apps/api/app/api/routes/edit_plans_timeline.py old mode 100755 new mode 100644 index 4b99c00b6..61a6d983d --- a/apps/api/app/api/routes/edit_plans_timeline.py +++ b/apps/api/app/api/routes/edit_plans_timeline.py @@ -214,8 +214,20 @@ def generate_from_template( plan = result["plan"] clips = result["clips"] + # 把 asset_ids 写入 plan.config,供生成时兜底分配使用 + if resolved_asset_ids: + from app.services import EditPlanService + + from packages.domain.config_schemas import normalize_plan_config + + svc = EditPlanService(db) + current_config = plan.config or {} + if current_config.get("asset_ids") != resolved_asset_ids: + current_config["asset_ids"] = resolved_asset_ids + plan = svc.update_plan(plan.id, config=normalize_plan_config(current_config)) + logger.info( - "基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%s", + "基于模板生成剪辑计划: plan_id=%s template_id=%s clips=%d by user=%d", plan.id, body.template_id, len(clips), diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py old mode 100644 new mode 100755 index db68aa8f8..2edf565d6 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -99,6 +99,11 @@ class PlanGeneratorService: # 3. 生成片段列表 if clip_configs: clips = self._create_clips_from_configs(plan.id, clip_configs) + # 模板 clip_config 的 clip_type 是 ClipType 枚举(main/intro/outro 等), + # 但 PIP / VOICE_PIP 模式需要特定的 clip_type(overlay/background/corner_voice/b_roll) + # 才能让素材分配和渲染分层正确工作。 + # 这里将 MAIN 类型的片段按顺序映射为对应模式的角色类型。 + self._map_clip_types_for_mode(clips, editing_mode) else: clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids)) @@ -195,6 +200,41 @@ class PlanGeneratorService: return clips + def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None: + """将模板 clip_config 生成的 MAIN 类型片段,按 editing_mode 映射为对应角色类型。 + + 模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等), + 但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名 + (overlay / background / corner_voice / b_roll)。 + + 映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型): + - PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画) + - VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll + - ONE_TAKE / VOICE_OVER: 保持 main 不变 + """ + from packages.domain.template_clip_config import ClipType + + main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] + if not main_clips: + return + + if editing_mode == EditingMode.PIP.value: + # 第1个 main 保持(背景层),其余改为 overlay(画中画层) + for i, clip in enumerate(main_clips): + if i > 0: + clip.clip_type = "overlay" + + elif editing_mode == EditingMode.VOICE_PIP.value: + for i, clip in enumerate(main_clips): + if i == 0: + clip.clip_type = "background" + elif i == 1: + clip.clip_type = "corner_voice" + else: + clip.clip_type = "b_roll" + + # ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理 + def _generate_default_clips( self, plan_id: str, diff --git a/tests/unit/test_plan_generator.py b/tests/unit/test_plan_generator.py old mode 100644 new mode 100755 index 6599df932..263dd1aaa --- a/tests/unit/test_plan_generator.py +++ b/tests/unit/test_plan_generator.py @@ -334,6 +334,93 @@ class TestGeneratePipPlan: assert overlay_clips[1].asset_id == "overlay-2" assert overlay_clips[2].asset_id == "overlay-3" + def test_pip_with_clip_configs(self): + """PIP + 有 clip_configs(都是 MAIN 类型)→ 自动映射为 main + overlay,素材正确分配""" + svc, _, _ = _make_generator() + template = _make_template(editing_mode=EditingMode.PIP.value) + + # 4 个 MAIN 类型的 clip_config(模拟 PIP 模板配置) + clip_configs = [ + TemplateClipConfig.create( + template_id=template.id, + clip_type=ClipType.MAIN, + order=i, + min_duration=3.0, + max_duration=5.0, + ) + for i in range(4) + ] + asset_ids = ["bg-asset", "overlay-1", "overlay-2", "overlay-3"] + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=asset_ids, + ) + + plan = result["plan"] + clips = result["clips"] + + assert plan.config["editing_mode"] == "pip" + assert len(clips) == 4 + + # 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画) + main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] + overlay_clips = [c for c in clips if c.clip_type == "overlay"] + + assert len(main_clips) == 1 + assert len(overlay_clips) == 3 + + # 素材分配正确 + assert main_clips[0].asset_id == "bg-asset" + assert overlay_clips[0].asset_id == "overlay-1" + assert overlay_clips[1].asset_id == "overlay-2" + assert overlay_clips[2].asset_id == "overlay-3" + + def test_voice_pip_with_clip_configs(self): + """VOICE_PIP + 有 clip_configs(都是 MAIN 类型)→ 自动映射为 background + corner_voice + b_roll""" + svc, _, _ = _make_generator() + template = _make_template(editing_mode=EditingMode.VOICE_PIP.value) + + # 4 个 MAIN 类型的 clip_config + clip_configs = [ + TemplateClipConfig.create( + template_id=template.id, + clip_type=ClipType.MAIN, + order=i, + min_duration=3.0, + max_duration=5.0, + ) + for i in range(4) + ] + asset_ids = ["bg-asset", "voice-asset", "broll-1", "broll-2"] + + result = svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=asset_ids, + ) + + plan = result["plan"] + clips = result["clips"] + + assert plan.config["editing_mode"] == "voice_pip" + assert len(clips) == 4 + + bg_clips = [c for c in clips if c.clip_type == "background"] + corner_clips = [c for c in clips if c.clip_type == "corner_voice"] + broll_clips = [c for c in clips if c.clip_type == "b_roll"] + + assert len(bg_clips) == 1 + assert len(corner_clips) == 1 + assert len(broll_clips) == 2 + + # 素材分配正确 + assert bg_clips[0].asset_id == "bg-asset" + assert corner_clips[0].asset_id == "voice-asset" + assert broll_clips[0].asset_id == "broll-1" + assert broll_clips[1].asset_id == "broll-2" + # --------------------------------------------------------------------------- # 测试:VOICE_OVER 模式