From a590c0c886a58f2335103b0e42e0c683f8ae21e2 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 20:02:56 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E7=94=9F=E6=88=90=E6=97=B6=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E3=80=8C=E6=B2=A1=E6=9C=89=E5=8F=AF=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E7=9A=84=E5=B0=B1=E7=BB=AA=E7=89=87=E6=AE=B5=E3=80=8D=E2=80=94?= =?UTF-8?q?=20mark=5Fclips=5Fready=20=E4=BB=85=E6=A0=87=E8=AE=B0=E6=9C=89?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E7=9A=84=E7=89=87=E6=AE=B5=20(#1270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: - mark_clips_ready() 标记所有 PENDING 片段为 READY,不管是否已分配素材 - can_generate() 不检查片段是否有素材 - 导致渲染时 ready_clips(需同时满足 status=READY 且 asset_id 非空)为空 修复: - mark_clips_ready(): 只标记 asset_id 非空的 pending 片段为 ready - can_generate(): 新增检查 — 至少有一个片段分配了素材 - validateGenerateInputs(): auto 模式下也校验素材非空,给出友好提示 - 更新相关单元测试 Closes #1270 --- apps/api/app/services/edit_plan_service.py | 23 ++++++++++--- .../hooks/generate-video/buildPayload.ts | 17 ++++++++-- tests/unit/test_edit_plan_service.py | 32 ++++++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 3026c58cf..7c583d391 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -585,10 +585,19 @@ class EditPlanService: if not clips: return False, "请先添加片段后再生成视频" + # 检查是否至少有一个片段分配了素材 + clips_with_asset = [c for c in clips if c.asset_id] + if not clips_with_asset: + 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 +608,14 @@ 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..d591e6b6e 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,25 @@ 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..ce4452da6 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,16 +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 + 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 svc = _make_service() p = svc.create_plan("tpl-001", "测试") svc.create_clip(p.id, "intro", 0) @@ -613,7 +628,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) -- 2.54.0 From e8cd6820324cc5781f0722362f718e1acf165161 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 20:06:22 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E5=B0=81?= =?UTF-8?q?=E9=9D=A2=E5=AE=9E=E9=99=85=E8=B0=83=E7=94=A8=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=94=9F=E6=88=90=20+=20=E5=88=A0=E9=99=A4=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=E6=96=87=E5=AD=97=20(#1271,=20#1272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1271 智能封面未实际生成: - useStep6Cover 新增 assetIds/selectedTemplate 参数和 generateAutoCover 方法 - Step6CoverSettings 进入 auto 模式时自动调用 POST /generate-cover API - 预览区优先显示 thumbnail_url(后端返回的封面图) - GenerateStepContent 传递 assetIds 和 selectedTemplate 给 Step6 #1272 删除封面说明文字: - 移除 Step6CoverSettings 中「AI 将分析视频内容...」说明文字 - 节省页面空间 Closes #1271, #1272 --- .../components/GenerateStepContent.tsx | 2 + .../components/Step6CoverSettings.tsx | 45 +++++++++++++++---- .../src/pages/generate/hooks/useStep6Cover.ts | 36 ++++++++++++++- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index eb6863906..fee188fb6 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -172,6 +172,8 @@ export const GenerateStepContent: React.FC = (props) = coverSettings={coverSettings} onCoverSettingsChange={onCoverSettingsChange} duration={duration} + assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials} + selectedTemplate={selectedTemplate} /> ) case 7: diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index f0b802739..c1e59ccb9 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useEffect, useRef } from "react" import type { CoverConfig } from "../../editing-planner/types" import { useStep6Cover } from "../hooks/useStep6Cover" import { CoverModeSelector } from "./cover-settings/CoverModeSelector" @@ -9,6 +9,10 @@ interface Step6CoverSettingsProps { coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void duration: number + /** 当前素材 ID 列表,用于智能封面生成 */ + assetIds?: string[] + /** 当前选中的模板 ID */ + selectedTemplate?: string } const Step6CoverSettings: React.FC = (props) => { @@ -19,10 +23,38 @@ const Step6CoverSettings: React.FC = (props) => { setMode, setFrameTime, handleUpload, + generateAutoCover, totalDuration, COVER_MODE_LABELS, COVER_MODE_ICONS, - } = useStep6Cover(props) + } = useStep6Cover({ + coverSettings: props.coverSettings, + onCoverSettingsChange: props.onCoverSettingsChange, + duration: props.duration, + assetIds: props.assetIds, + selectedTemplate: props.selectedTemplate, + }) + + // 进入 auto 模式时自动触发智能封面生成 + const autoTriggeredRef = useRef(false) + useEffect(() => { + if ( + coverSettings.enabled && + coverSettings.mode === "auto" && + !coverSettings.thumbnail_url && + !autoTriggeredRef.current + ) { + autoTriggeredRef.current = true + generateAutoCover() + } + // 切换模式时重置触发标记 + if (coverSettings.mode !== "auto") { + autoTriggeredRef.current = false + } + }, [coverSettings.enabled, coverSettings.mode, coverSettings.thumbnail_url, generateAutoCover]) + + // 预览图:优先 thumbnail_url,其次 upload_url + const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url return (
@@ -52,9 +84,6 @@ const Step6CoverSettings: React.FC = (props) => { {coverSettings.mode === "auto" && (
-
- AI 将分析视频内容,自动选择最具吸引力的画面作为封面。 -
🤖 AI 智能选帧 @@ -77,14 +106,14 @@ const Step6CoverSettings: React.FC = (props) => {
封面预览
- {coverSettings.upload_url ? ( - 封面预览 + {previewUrl ? ( + 封面预览 ) : (
🖼️ {coverSettings.mode === "auto" - ? "AI 智能选择" + ? "AI 正在选择..." : coverSettings.mode === "frame" ? `帧 ${formatTime(coverSettings.frame_time)}` : "未上传封面"} diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index f94085b28..4dd3781cb 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -2,21 +2,30 @@ * Step 6 封面设置 Hook * 封装封面设置的交互逻辑 */ -import { useCallback } from "react" +import { useCallback, useRef } from "react" import type { CoverConfig } from "../../editing-planner/types" import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants" +import { generateCover } from "@/api/template-editor" interface UseStep6CoverProps { coverSettings: CoverConfig onCoverSettingsChange: (settings: CoverConfig) => void duration: number + /** 当前素材 ID 列表,用于智能封面生成 */ + assetIds?: string[] + /** 当前选中的模板 ID */ + selectedTemplate?: string } export function useStep6Cover({ coverSettings, onCoverSettingsChange, duration, + assetIds = [], + selectedTemplate = "", }: UseStep6CoverProps) { + const generatingRef = useRef(false) + const formatTime = useCallback((seconds: number) => { const m = Math.floor(seconds / 60) const s = Math.floor(seconds % 60) @@ -62,6 +71,30 @@ export function useStep6Cover({ [coverSettings, onCoverSettingsChange], ) + /** 调用后端智能封面 API,生成封面并更新预览 */ + const generateAutoCover = useCallback(async () => { + if (!selectedTemplate || assetIds.length === 0 || generatingRef.current) return + generatingRef.current = true + try { + const response = await generateCover(selectedTemplate, { + asset_ids: assetIds, + cover_type: "ai_frame", + }) + const thumbnailUrl = response.cover?.thumbnail_url || "" + if (thumbnailUrl) { + onCoverSettingsChange({ + ...coverSettings, + thumbnail_url: thumbnailUrl, + ai_suggested_time: response.cover?.frame_time ?? null, + }) + } + } catch (err) { + console.warn("[Step6] 智能封面生成失败:", err) + } finally { + generatingRef.current = false + } + }, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange]) + const totalDuration = duration || 30 return { @@ -71,6 +104,7 @@ export function useStep6Cover({ setMode, setFrameTime, handleUpload, + generateAutoCover, totalDuration, COVER_MODE_LABELS, COVER_MODE_ICONS, -- 2.54.0 From 214d1f95f4ec838d558ea7219d554901fcee3d53 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Fri, 7 Aug 2026 12:09:38 +0000 Subject: [PATCH 3/6] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/api/app/services/edit_plan_service.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 7c583d391..ab4c3aec0 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -614,7 +614,9 @@ class EditPlanService: count += 1 logger.info( "标记片段就绪: plan_id=%s marked=%d total_pending=%d", - plan_id, count, len(clips), + plan_id, + count, + len(clips), ) return count -- 2.54.0 From b584e92f73db8136265573232bbecd26213833ab Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 20:30:37 +0800 Subject: [PATCH 4/6] fix: prettier formatting in buildPayload.ts --- .../src/pages/generate/hooks/generate-video/buildPayload.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 d591e6b6e..49dec5c6e 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts @@ -77,9 +77,7 @@ export const validateGenerateInputs = (props: UseGenerateVideoProps): string | n // 无论手动还是自动模式,都必须有素材 const materialIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials if (materialIds.length === 0) { - return materialMode === "auto" - ? "AI 未匹配到素材,请手动选择素材后重试" - : "请至少选择一个素材" + return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材" } if (voiceMode === "clone" && !selectedClonedVoice) { return "请先选择一个克隆音色" -- 2.54.0 From 28613fc9215a9d91312bf36a0f3737a68ee7c298 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 7 Aug 2026 20:32:58 +0800 Subject: [PATCH 5/6] fix: AI review feedback - autoTriggeredRef reset on asset change, null safety, remove dead test code --- apps/api/app/services/edit_plan_service.py | 3 +-- .../components/Step6CoverSettings.tsx | 23 +++++++++++++------ .../hooks/generate-video/buildPayload.ts | 2 +- tests/unit/test_edit_plan_service.py | 7 ------ 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index ab4c3aec0..195d6321b 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -586,8 +586,7 @@ class EditPlanService: return False, "请先添加片段后再生成视频" # 检查是否至少有一个片段分配了素材 - clips_with_asset = [c for c in clips if c.asset_id] - if not clips_with_asset: + if not any(c.asset_id for c in clips): return False, "没有可渲染的就绪片段,请确保已选择素材" return True, "" diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index c1e59ccb9..c0a4e9fef 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -38,20 +38,29 @@ const Step6CoverSettings: React.FC = (props) => { // 进入 auto 模式时自动触发智能封面生成 const autoTriggeredRef = useRef(false) useEffect(() => { + // 切换模式、禁用封面或素材变更时重置触发标记 + if (coverSettings.mode !== "auto" || !coverSettings.enabled) { + autoTriggeredRef.current = false + return + } + // 有素材且未生成过封面时自动触发 if ( - coverSettings.enabled && coverSettings.mode === "auto" && !coverSettings.thumbnail_url && - !autoTriggeredRef.current + !autoTriggeredRef.current && + props.assetIds && + props.assetIds.length > 0 ) { autoTriggeredRef.current = true generateAutoCover() } - // 切换模式时重置触发标记 - if (coverSettings.mode !== "auto") { - autoTriggeredRef.current = false - } - }, [coverSettings.enabled, coverSettings.mode, coverSettings.thumbnail_url, generateAutoCover]) + }, [ + coverSettings.enabled, + coverSettings.mode, + coverSettings.thumbnail_url, + generateAutoCover, + props.assetIds, + ]) // 预览图:优先 thumbnail_url,其次 upload_url const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url 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 49dec5c6e..e7f39ece8 100644 --- a/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts +++ b/apps/web/src/pages/generate/hooks/generate-video/buildPayload.ts @@ -75,7 +75,7 @@ export const validateGenerateInputs = (props: UseGenerateVideoProps): string | n return "请先选择或输入标题" } // 无论手动还是自动模式,都必须有素材 - const materialIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials + const materialIds = materialMode === "auto" ? smartSelectedIds || [] : selectedMaterials || [] if (materialIds.length === 0) { return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材" } diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py index ce4452da6..b79bf5d18 100755 --- a/tests/unit/test_edit_plan_service.py +++ b/tests/unit/test_edit_plan_service.py @@ -548,13 +548,6 @@ class TestGenerationWorkflow: 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 - 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 def test_update_plan_config(self): svc = _make_service() -- 2.54.0 From bb59e32a5ec6906a8b775837b14533209ba1c6f3 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 8 Aug 2026 04:37:32 +0000 Subject: [PATCH 6/6] style: auto-format with black + isort + prettier [skip ci-format-check] --- apps/api/app/services/edit_plan_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index 54a44c69c..f28a2d08a 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -644,7 +644,6 @@ class EditPlanService: ) return False, "没有可渲染的就绪片段,请确保已选择素材" - return True, "" def mark_clips_ready(self, plan_id: str) -> int: -- 2.54.0