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, } })