diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 3026c58cf..195d6321b 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -585,10 +585,18 @@ class EditPlanService: if not clips: return False, "请先添加片段后再生成视频" + # 检查是否至少有一个片段分配了素材 + if not any(c.asset_id for c in clips): + return False, "没有可渲染的就绪片段,请确保已选择素材" + return True, "" def mark_clips_ready(self, plan_id: str) -> int: - """将所有 pending 状态的片段标记为 ready + """将已分配素材的 pending 片段标记为 ready + + 只标记同时满足以下条件的片段: + - status == PENDING + - asset_id 非空(已分配素材) Returns: int: 标记的片段数量 @@ -599,10 +607,16 @@ class EditPlanService: ) count = 0 for clip in clips: - clip.mark_ready() - self._clip_repo.update(clip) - count += 1 - logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count) + if clip.asset_id: + clip.mark_ready() + self._clip_repo.update(clip) + count += 1 + logger.info( + "标记片段就绪: plan_id=%s marked=%d total_pending=%d", + plan_id, + count, + len(clips), + ) return count def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan: diff --git a/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts b/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts index ced8ec345..e7f39ece8 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts @@ -61,14 +61,23 @@ export const buildEditPlanPayload = (props: UseGenerateVideoProps) => { * 返回错误信息,通过则返回 null */ export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => { - const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props + const { + titleSettings, + materialMode, + selectedMaterials, + smartSelectedIds, + voiceMode, + selectedClonedVoice, + } = props // AI 自动选择模式下,标题可以为空(后端会自行生成) if (!titleSettings.aiAutoSelect && !titleSettings.title?.trim()) { return "请先选择或输入标题" } - if (materialMode === "manual" && selectedMaterials.length === 0) { - return "请至少选择一个素材" + // 无论手动还是自动模式,都必须有素材 + const materialIds = materialMode === "auto" ? smartSelectedIds || [] : selectedMaterials || [] + if (materialIds.length === 0) { + return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材" } if (voiceMode === "clone" && !selectedClonedVoice) { return "请先选择一个克隆音色" diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py index 806f3de61..b79bf5d18 100755 --- a/tests/unit/test_edit_plan_service.py +++ b/tests/unit/test_edit_plan_service.py @@ -501,11 +501,22 @@ class TestGenerationWorkflow: svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.transition_status(p.id, EditPlanStatus.EDITING) - svc.create_clip(p.id, "intro", 0) + clip = svc.create_clip(p.id, "intro", 0) + svc.assign_asset(clip.id, "asset-001") can, reason = svc.can_generate(p.id) assert can is True assert reason == "" + def test_can_generate_no_assets_fails(self): + """片段存在但没有分配素材时,can_generate 应返回 False""" + svc = _make_service() + p = svc.create_plan("tpl-001", "测试") + svc.transition_status(p.id, EditPlanStatus.EDITING) + svc.create_clip(p.id, "intro", 0) + can, reason = svc.can_generate(p.id) + assert can is False + assert "没有可渲染" in reason or "素材" in reason + def test_can_generate_draft_fails(self): svc = _make_service() p = svc.create_plan("tpl-001", "测试") @@ -523,23 +534,20 @@ class TestGenerationWorkflow: assert "请先添加片段后再生成视频" in reason def test_mark_clips_ready(self): + """只有分配了素材的 pending 片段才会被标记为 ready""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") - svc.create_clip(p.id, "intro", 0) - svc.create_clip(p.id, "main", 1) + clip1 = svc.create_clip(p.id, "intro", 0) + clip2 = svc.create_clip(p.id, "main", 1) + # 只给 clip1 分配素材 + svc.assign_asset(clip1.id, "asset-001") count = svc.mark_clips_ready(p.id) - assert count == 2 - # 验证所有片段都是 ready 状态 + assert count == 1 # 只有 clip1 被标记 + # 验证 clip1 是 ready,clip2 仍是 pending clips = svc.list_clips(p.id) - for c in clips: - assert c.status == EditPlanClipStatus.READY - svc = _make_service() - p = svc.create_plan("tpl-001", "测试") - svc.create_clip(p.id, "intro", 0) - result = svc.get_generation_status(p.id) - assert result["plan"].id == p.id - assert len(result["clips"]) == 1 - assert result["generation_task_id"] is None + clips_by_order = {c.order: c for c in clips} + assert clips_by_order[0].status == EditPlanClipStatus.READY + assert clips_by_order[1].status == EditPlanClipStatus.PENDING def test_update_plan_config(self): svc = _make_service() @@ -613,7 +621,8 @@ class TestResumeEditingAndRegenerate: """完成后编辑 → can_generate 返回 True,可再生成""" svc = _make_service() p = svc.create_plan("tpl-001", "测试") - svc.create_clip(p.id, "main", 0) + clip = svc.create_clip(p.id, "main", 0) + svc.assign_asset(clip.id, "asset-001") svc.transition_status(p.id, EditPlanStatus.EDITING) svc.transition_status(p.id, EditPlanStatus.RENDERING) svc.transition_status(p.id, EditPlanStatus.COMPLETED)