From 7df38e31498d45c275397d399b6f09f3473da1da Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 23 Aug 2026 23:38:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A1=AE=E4=BF=9D=20source=5Fedit=5Fpla?= =?UTF-8?q?n=5Fid=20=E6=AD=A3=E7=A1=AE=E4=BC=A0=E9=80=92=20+=20clips?= =?UTF-8?q?=E6=97=B6=E9=95=BF=E4=B8=8E=E9=A2=84=E8=A7=88=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A8=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务一:创建正式生成任务时 source_edit_plan_id 兜底 - useGenerateFormState 新增 sourceEditPlanId:URL edit_plan_id 优先,否则回退 selectedTemplate - selectedTemplate 异步加载后会触发重渲染,sourceEditPlanId 自动更新为正确值 - GeneratePage useGenerateVideo 调用改用 sourceEditPlanId - editPlanId 仍保留原始 URL 值用于 fromEditPlan 标识和 usePlanConfigLoader 任务二:预览创建任务传 source_edit_plan_id - useStep6Cover createPreview 调用新增 source_edit_plan_id: selectedTemplate - 与正式生成保持一致,不再依赖后端 template_id+user_id 兜底查找 任务三:buildClipsFromAssets 时长截断对齐预览播放器 - FrontendPreviewPlayer.buildPlaybackSegments 中 endTime = Math.min(startTime + segDuration, assetDuration) - 原 buildClipsFromAssets 缺少此截断,素材实际时长比 clamp 值短时 Worker 会读到不存在的片段 - 新增 duration = Math.min(segDuration, assetDuration),与预览完全一致 - 更新文件头注释说明数据流向(预览在内存构建segments,clips写DB给Worker) --- apps/web/src/pages/generate/GeneratePage.tsx | 3 ++- .../generate/hooks/useGenerateFormState/index.ts | 13 +++++++++++++ .../src/pages/generate/hooks/useStep6Cover.ts | 1 + .../pages/generate/utils/buildClipsFromAssets.ts | 16 ++++++++++++++-- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index 449b30174..2c822aa6d 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -72,6 +72,7 @@ const GeneratePage: React.FC = () => { autoSubtitles, bgm, editPlanId, + sourceEditPlanId, previewVideo, setPreviewVideo, previewModalOpen, @@ -171,7 +172,7 @@ const GeneratePage: React.FC = () => { autoSubtitles, bgm, generateCount, - sourceEditPlanId: editPlanId, + sourceEditPlanId, }) /* ================================================================ diff --git a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts index 2ae71dc77..e251cc25f 100755 --- a/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts +++ b/apps/web/src/pages/generate/hooks/useGenerateFormState/index.ts @@ -82,6 +82,13 @@ export interface GenerateFormState { editPlanId: string | null planConfigStr: string | null + /** + * 传给 Worker 的 source_edit_plan_id。 + * 优先使用 URL 中的 edit_plan_id;URL 没有时回退为 selectedTemplate(模板 ID), + * 因为 Step2 的 clips 就是用 selectedTemplate 作为 plan_id 写入的。 + */ + sourceEditPlanId: string | null + /* 预览弹窗 */ previewVideo: GeneratedVideo | null setPreviewVideo: (v: GeneratedVideo | null) => void @@ -100,6 +107,11 @@ export const useGenerateFormState = (): GenerateFormState => { /* ── 模板选择 ── */ const { selectedTemplate, setSelectedTemplate, userTemplates } = useTemplateSelection() + /* ── source_edit_plan_id:URL 优先,否则回退到 selectedTemplate ── */ + // selectedTemplate 是异步加载的(react-query),组件重新渲染时此值会自动更新, + // 因此最终传给 useGenerateVideo 的 sourceEditPlanId 能在模板就绪后拿到正确值。 + const sourceEditPlanId = editPlanId || selectedTemplate || null + /* ── 素材 ── */ const [selectedMaterials, setSelectedMaterials] = useState([]) const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") @@ -189,6 +201,7 @@ export const useGenerateFormState = (): GenerateFormState => { autoSubtitles, bgm, editPlanId, + sourceEditPlanId, planConfigStr, previewVideo, setPreviewVideo, diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index f86141680..3786e499c 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -154,6 +154,7 @@ export function useStep6Cover({ const previewResp = await createPreview({ template_id: selectedTemplate, asset_ids: assetIds, + source_edit_plan_id: selectedTemplate, duration: duration || 30, ...(titleSettings?.title ? { diff --git a/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts b/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts index af3f2d718..320ec829f 100644 --- a/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts +++ b/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts @@ -6,7 +6,14 @@ * tplSeg = templateSegments[i] || lastSegment * segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max) * start_time = 0 - * duration = segDuration + * // 关键:预览播放器中 endTime = min(startTime + segDuration, assetDuration) + * // 因此 clips.duration 也必须用 min(segDuration, assetDuration) 截断, + * // 避免素材实际时长比 clamp 后的 segDuration 短时,Worker 尝试读取不存在的片段 + * duration = min(segDuration, assetDuration) + * + * 预览播放器(Canvas 实时预览)直接在内存中构建 segments 播放,不读 edit_plan_clips; + * 本函数产出的 clips 写入 DB 后由 Worker 渲染。两边用完全相同的时长计算, + * 保证用户在编辑过程中看到的预览与最终生成视频一致。 */ import type { AssetItem } from "@/api/assets" import type { TemplateSegment } from "@/api/templates/types" @@ -47,10 +54,15 @@ export function buildClipsFromAssets({ ? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration)) : Math.min(assetDuration, 10) + // 与 FrontendPreviewPlayer.buildPlaybackSegments 中 + // endTime = Math.min(startTime + segDuration, assetDuration) + // 保持一致:duration 不能超过素材实际时长 + const duration = Math.min(segDuration, assetDuration) + return { asset_id: assetId, start_time: 0, - duration: segDuration, + duration, order: i, } }) -- 2.54.0