From 9e7f04e312a456de8f2108cae02147f6ca2f6b61 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 23 Aug 2026 16:16:38 +0800 Subject: [PATCH] feat: sync selected materials to edit_plan_clips on Step2 - New API updateEditPlanClips (PUT /templates/{id}/editor/clips) - useStep2Materials: debounced 800ms, builds clips with same logic as FrontendPreviewPlayer.buildPlaybackSegments (clamp asset duration to template segment duration_min/max) - buildClipsFromAssets shared util ensures duration consistency between preview and clips written to DB - Supports both manual and smart-match modes - Failures are silent (console.warn), never block the user - Step2MaterialSelect/GenerateStepContent pass templateSegments --- apps/web/src/api/template-editor/editPlans.ts | 26 ++++++++ apps/web/src/api/template-editor/index.ts | 2 + .../components/GenerateStepContent.tsx | 5 ++ .../components/Step2MaterialSelect.tsx | 3 + .../pages/generate/hooks/useStep2Materials.ts | 64 ++++++++++++++++++- .../generate/utils/buildClipsFromAssets.ts | 57 +++++++++++++++++ 6 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/pages/generate/utils/buildClipsFromAssets.ts diff --git a/apps/web/src/api/template-editor/editPlans.ts b/apps/web/src/api/template-editor/editPlans.ts index 54a6d576f..19b7c9fab 100644 --- a/apps/web/src/api/template-editor/editPlans.ts +++ b/apps/web/src/api/template-editor/editPlans.ts @@ -50,3 +50,29 @@ export async function getGenerationTaskResults(taskId: string): Promise { + const response = await apiClient.put( + `/templates/${templateId}/editor/clips`, + { clips }, + { signal }, + ) + return response.data +} diff --git a/apps/web/src/api/template-editor/index.ts b/apps/web/src/api/template-editor/index.ts index 41a521c7f..094b41107 100644 --- a/apps/web/src/api/template-editor/index.ts +++ b/apps/web/src/api/template-editor/index.ts @@ -51,11 +51,13 @@ export { export { getEditPlan, updateEditPlan, + updateEditPlanClips, generateEditPlan, getGenerationStatus, getEditPlanGenerations, getGenerationTaskResults, } from "./editPlans" +export type { EditPlanClipInput } from "./editPlans" // 片段 CRUD + 批量操作 export { diff --git a/apps/web/src/pages/generate/components/GenerateStepContent.tsx b/apps/web/src/pages/generate/components/GenerateStepContent.tsx index e7cbca646..7f7cf93f8 100644 --- a/apps/web/src/pages/generate/components/GenerateStepContent.tsx +++ b/apps/web/src/pages/generate/components/GenerateStepContent.tsx @@ -123,6 +123,10 @@ export const GenerateStepContent: React.FC = (props) = presetVoices, } = props + /* 当前模板的 segments,传给 Step2 构建 clips */ + const currentTemplate = userTemplates.find((t) => t.id === selectedTemplate) + const templateSegments = currentTemplate?.segments + switch (currentStep) { case 1: return ( @@ -142,6 +146,7 @@ export const GenerateStepContent: React.FC = (props) = smartSelectedIds={smartSelectedIds} onSmartSelectedIdsChange={onSmartSelectedIdsChange} selectedTemplate={selectedTemplate} + templateSegments={templateSegments} /> ) case 3: diff --git a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx index 19ff574e6..b074dc1af 100644 --- a/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx +++ b/apps/web/src/pages/generate/components/Step2MaterialSelect.tsx @@ -2,6 +2,7 @@ * Step 2 素材选择组件 */ import React from "react" +import type { TemplateSegment } from "@/api/templates/types" import { useStep2Materials } from "../hooks/useStep2Materials" import MaterialModeTabs from "./material/MaterialModeTabs" import ManualMaterialList from "./material/ManualMaterialList" @@ -17,6 +18,8 @@ interface Step2MaterialSelectProps { onSmartSelectedIdsChange: (ids: string[]) => void /** 当前选中的模板/草稿 ID,用于自动保存 */ selectedTemplate?: string + /** 当前模板的 segments(用于构建 clips duration) */ + templateSegments?: TemplateSegment[] } const Step2MaterialSelect: React.FC = (props) => { diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 58d8fedc9..9ee6865b7 100755 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -3,7 +3,11 @@ * 组合素材库加载 + 智能匹配两个子 Hook */ import { useCallback, useEffect, useRef } from "react" +import type { AssetItem } from "@/api/assets" +import type { TemplateSegment } from "@/api/templates/types" +import { updateEditPlanClips } from "@/api/template-editor" import { formatDuration } from "../utils/formatDuration" +import { buildClipsFromAssets } from "../utils/buildClipsFromAssets" import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary" import { useSmartMatch } from "./step2-materials/useSmartMatch" import { useDraftAutoSave } from "./useDraftAutoSave" @@ -17,6 +21,8 @@ interface UseStep2MaterialsProps { onSmartSelectedIdsChange: (ids: string[]) => void /** 当前选中的模板/草稿 ID,用于自动保存 */ selectedTemplate?: string + /** 当前模板的 segments(用于构建 clips duration) */ + templateSegments?: TemplateSegment[] } export function useStep2Materials({ @@ -27,6 +33,7 @@ export function useStep2Materials({ smartSelectedIds, onSmartSelectedIdsChange, selectedTemplate, + templateSegments, }: UseStep2MaterialsProps) { const { libraries, selectedLibraryId, setSelectedLibraryId, materials, materialsLoading } = useMaterialLibrary() @@ -57,7 +64,7 @@ export function useStep2Materials({ handleSmartMatch() }, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch]) - /* ── Step2 选择素材后自动保存草稿(防抖 500ms,失败静默) ── */ + /* ── Step2 选择素材后自动保存草稿 asset_ids(防抖 500ms,失败静默) ── */ const { scheduleSave } = useDraftAutoSave(selectedTemplate) useEffect(() => { if (!selectedTemplate) return @@ -65,6 +72,61 @@ export function useStep2Materials({ scheduleSave({ asset_ids: ids }, 500) }, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave]) + /* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── */ + const clipsTimerRef = useRef>() + const clipsAbortRef = useRef(null) + const templateSegmentsRef = useRef(templateSegments) + templateSegmentsRef.current = templateSegments + const selectedTemplateRef = useRef(selectedTemplate) + selectedTemplateRef.current = selectedTemplate + const materialsRef = useRef(materials) + materialsRef.current = materials + const smartMatchedRef = useRef(smartMatch.smartMatchedResults) + smartMatchedRef.current = smartMatch.smartMatchedResults + + useEffect(() => { + const tid = selectedTemplateRef.current + if (!tid) return + const ids = materialMode === "auto" ? smartSelectedIds : selectedMaterials + if (!ids.length) return + + if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current) + clipsTimerRef.current = setTimeout(async () => { + // 取消上一次未完成的请求 + if (clipsAbortRef.current) clipsAbortRef.current.abort() + const controller = new AbortController() + clipsAbortRef.current = controller + + const clips = buildClipsFromAssets({ + selectedIds: ids, + materials: materialsRef.current.items, + smartMatchedAssets: smartMatchedRef.current, + templateSegments: templateSegmentsRef.current || [], + }) + + try { + await updateEditPlanClips(tid, clips, controller.signal) + } catch (err) { + const name = (err as { name?: string })?.name + if (name !== "CanceledError" && name !== "AbortError") { + console.warn("[useStep2Materials] 写入 clips 失败:", err) + } + } + }, 800) + + return () => { + if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current) + } + }, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, templateSegments]) + + // 组件卸载时取消未完成请求 + useEffect(() => { + return () => { + if (clipsTimerRef.current) clearTimeout(clipsTimerRef.current) + if (clipsAbortRef.current) clipsAbortRef.current.abort() + } + }, []) + /* ── 手动选择素材 ── */ const handleToggleMaterial = useCallback( (materialId: string) => { diff --git a/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts b/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts new file mode 100644 index 000000000..af3f2d718 --- /dev/null +++ b/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts @@ -0,0 +1,57 @@ +/** + * 将选中素材 + 模板 segments 构建为 edit_plan_clips 写入数据。 + * + * 逻辑必须与 FrontendPreviewPlayer.tsx 中 buildPlaybackSegments 完全一致: + * assetDuration = asset.duration || asset.metadata?.duration || 30 + * tplSeg = templateSegments[i] || lastSegment + * segDuration = clamp(assetDuration, tplSeg.duration_min, tplSeg.duration_max) + * start_time = 0 + * duration = segDuration + */ +import type { AssetItem } from "@/api/assets" +import type { TemplateSegment } from "@/api/templates/types" +import type { EditPlanClipInput } from "@/api/template-editor" + +interface BuildClipsOptions { + /** 选中的素材 ID 列表(按选择顺序) */ + selectedIds: string[] + /** 已加载的素材列表(用于查 duration) */ + materials: AssetItem[] + /** 智能匹配返回的素材(auto 模式下可能不在 materials 列表中) */ + smartMatchedAssets?: AssetItem[] + /** 模板 segments */ + templateSegments?: TemplateSegment[] +} + +export function buildClipsFromAssets({ + selectedIds, + materials, + smartMatchedAssets = [], + templateSegments = [], +}: BuildClipsOptions): EditPlanClipInput[] { + if (!selectedIds.length) return [] + + // 合并两个素材来源,建立 id → asset 索引 + const assetMap = new Map() + for (const a of materials) assetMap.set(a.id, a) + for (const a of smartMatchedAssets) assetMap.set(a.id, a) + + const lastSeg = templateSegments[templateSegments.length - 1] + + return selectedIds.map((assetId, i) => { + const asset = assetMap.get(assetId) + const assetDuration = asset?.duration || asset?.metadata?.duration || 30 + + const tplSeg = templateSegments[i] || lastSeg + const segDuration = tplSeg + ? Math.min(tplSeg.duration_max, Math.max(tplSeg.duration_min, assetDuration)) + : Math.min(assetDuration, 10) + + return { + asset_id: assetId, + start_time: 0, + duration: segDuration, + order: i, + } + }) +} -- 2.54.0