fix(api): 一键生成自动选素材全链路修复,4个核心根因一次性解决 #491

Merged
auto-approve-bot merged 1 commits from fix/auto-material-all-root-causes into develop 2026-07-18 09:21:11 +08:00
4 changed files with 148 additions and 5 deletions
@@ -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),
)
+13 -1
View File
@@ -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),
+40
View File
@@ -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_typeoverlay/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,
+87
View File
@@ -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 模式