diff --git a/apps/web/src/api/template-editor/clips.ts b/apps/web/src/api/template-editor/clips.ts index cbee754e0..6f74a3fce 100644 --- a/apps/web/src/api/template-editor/clips.ts +++ b/apps/web/src/api/template-editor/clips.ts @@ -90,10 +90,18 @@ export async function createClipsFromAssets( templateId: string, assetIds: string[], clipType = "main", + requiredClipsCount?: number, ): Promise { + const body: Record = { + asset_ids: assetIds, + clip_type: clipType, + } + if (requiredClipsCount !== undefined) { + body.required_clips_count = requiredClipsCount + } const response = await apiClient.post( `/templates/${templateId}/editor/clips/from-assets`, - { asset_ids: assetIds, clip_type: clipType }, + body, ) return response.data } diff --git a/apps/web/src/pages/generate/hooks/useStep2Materials.ts b/apps/web/src/pages/generate/hooks/useStep2Materials.ts index 9ee6865b7..21c7c9f38 100755 --- a/apps/web/src/pages/generate/hooks/useStep2Materials.ts +++ b/apps/web/src/pages/generate/hooks/useStep2Materials.ts @@ -3,11 +3,9 @@ * 组合素材库加载 + 智能匹配两个子 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 { updateEditPlanClips, createClipsFromAssets } 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" @@ -72,17 +70,19 @@ export function useStep2Materials({ scheduleSave({ asset_ids: ids }, 500) }, [selectedTemplate, materialMode, selectedMaterials, smartSelectedIds, scheduleSave]) - /* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── */ + /* ── Step2 选择素材后同步写入 edit_plan_clips(防抖 800ms,失败静默) ── + * 调用后端 POST /clips/from-assets,由后端处理: + * - 素材不够时同一素材切多个片段 + * - 随机 start_time,不重复 + * - required_clips_count 保证片段数与模板 segments 一致 + * 先 PUT /clips(空数组)清空旧片段,再调用 from-assets 创建新片段 + */ 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 @@ -97,15 +97,14 @@ export function useStep2Materials({ const controller = new AbortController() clipsAbortRef.current = controller - const clips = buildClipsFromAssets({ - selectedIds: ids, - materials: materialsRef.current.items, - smartMatchedAssets: smartMatchedRef.current, - templateSegments: templateSegmentsRef.current || [], - }) + const segs = templateSegmentsRef.current || [] + const requiredClipsCount = segs.length > 0 ? segs.length : undefined try { - await updateEditPlanClips(tid, clips, controller.signal) + // 1. 清空旧片段 + await updateEditPlanClips(tid, [], controller.signal) + // 2. 调用后端 from-assets 接口创建片段 + await createClipsFromAssets(tid, ids, "main", requiredClipsCount) } catch (err) { const name = (err as { name?: string })?.name if (name !== "CanceledError" && name !== "AbortError") { diff --git a/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts b/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts deleted file mode 100644 index 320ec829f..000000000 --- a/apps/web/src/pages/generate/utils/buildClipsFromAssets.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * 将选中素材 + 模板 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 - * // 关键:预览播放器中 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" -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) - - // 与 FrontendPreviewPlayer.buildPlaybackSegments 中 - // endTime = Math.min(startTime + segDuration, assetDuration) - // 保持一致:duration 不能超过素材实际时长 - const duration = Math.min(segDuration, assetDuration) - - return { - asset_id: assetId, - start_time: 0, - duration, - order: i, - } - }) -}