"use client"; import { useEffect, useRef, useState } from "react"; import { X, Upload } from "lucide-react"; import { hasRecoverableCanvasTask } from "@/lib/video-canvas/task-recovery"; import { SmartEditEditor, SmartEditEmptyEditor } from "./SmartEditEditor"; import { assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent"; import { EditPreview } from "./EditPreview"; import { smartEditRequestSchema, planTimeline, parseSrt, subtitlesToSrt, compiledSubtitles, SECOND, type EditPlan, type SmartEditRequest, } from "@/lib/video-canvas/smart-edit/contract"; import type { CanvasGenerationTask } from "@/lib/video-canvas/task-contract"; type Asset = { id: string; url: string; name: string; subtitles: ReturnType }; export function SmartEditDialog({ initial, plans: serverPlans, planTaskId, sources, connectedSourceIds = [], audios, preferredModelRef, tasks, onClose, onDraft, onPlan, onRender, onCancel, }: { initial?: Record; plans?: EditPlan[]; planTaskId?: string; sources: Asset[]; connectedSourceIds?: string[]; audios: { url: string; name: string }[]; preferredModelRef?: string; tasks: CanvasGenerationTask[]; onClose: () => void; onDraft: (draft: Record) => void; onPlan: (params: SmartEditRequest) => Promise; onRender: (plan: EditPlan) => Promise; onCancel?: (taskId: string) => Promise; }) { const [draft, setDraft] = useState>(() => ({ assets: [], mode: "auto", prompt: "", targetDuration: 30, ratio: "16:9", resolution: "720p", semantic: true, removeSilence: false, transcribe: false, beatSync: false, beatsPerCut: "4", variants: 3, preferredModelRef, ...initial, plans: initial?.planTaskId === planTaskId ? initial?.plans || serverPlans || [] : serverPlans || [], planTaskId, })); const latest = useRef(draft); latest.current = draft; const patch = (value: Record) => { const next = { ...latest.current, ...value }; latest.current = next; setDraft(next); onDraft(next); }; const [selected, setSelected] = useState(() => Math.max( 0, Math.min((draft.plans?.length || 1) - 1, Number(initial?.selectedPlanIndex) || 0), ), ), [clipIndex, setClipIndex] = useState(0), [time, setTime] = useState(0), [playing, setPlaying] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(""), [cap, setCap] = useState(), [zoom, setZoom] = useState(1), [snap, setSnap] = useState(true); const [configure, setConfigure] = useState(false); const [videoDragActive, setVideoDragActive] = useState(false); const videoDragDepthRef = useRef(0); const uploadVideoFiles = async (files: File[]) => { if (!files.length) return; if (files.length + draft.assets.length > 12) { setError("最多 12 个素材"); return; } setBusy(true); try { for (const file of files) await upload(file, "video"); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }; const applied = useRef(planTaskId); useEffect(() => { if (planTaskId && planTaskId !== applied.current) { applied.current = planTaskId; const processing = tasks.some( (t) => t.id === planTaskId && ["separate", "transcribe"].includes(String(t.request.params.operation)), ); const index = processing ? Math.min(selected, (serverPlans?.length || 1) - 1) : 0; patch({ plans: serverPlans || [], planTaskId, selectedPlanIndex: index }); setSelected(index); setTime(0); setPlaying(false); setConfigure(false); } }, [planTaskId, serverPlans]); useEffect(() => { const controller = new AbortController(); fetch("/api/canvas/smart-edit/capabilities", { signal: controller.signal }) .then(async (r) => { const data = await r.json(); if (!r.ok) throw new Error(data.error); setCap(data); }) .catch((e) => { if (!controller.signal.aborted) setError(e.message); }); return () => controller.abort(); }, []); const waiting = tasks.some((t) => t.status === "pending" || t.status === "running"), last = tasks.slice(-1)[0]; const plan = draft.plans?.[selected] as EditPlan | undefined; let timeline: ReturnType | undefined, validation = ""; try { if (plan) timeline = planTimeline(plan); } catch (e) { validation = (e as Error).message; } const autoPrepared = useRef(false); const startSequence = async () => { const assets = [...new Set(connectedSourceIds)] .map((id) => sources.find((a) => a.id === id)) .filter((a): a is Asset => Boolean(a)); if (!assets.length) return; autoPrepared.current = true; setBusy(true); setError(""); try { const params = smartEditRequestSchema.parse({ operation: "plan", assets, mode: "sequence", targetDuration: 300, semantic: false, ratio: draft.ratio, resolution: draft.resolution, }); patch({ assets }); const preparationRequestId = await onPlan(params); patch({ preparationRequestId }); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } }; useEffect(() => { if ( !plan && !configure && !autoPrepared.current && !draft.preparationRequestId && !waiting && connectedSourceIds.length ) void startSequence(); }, [Boolean(plan), configure, connectedSourceIds.join("|"), waiting]); const changePlan = (next: EditPlan) => { setPlaying(false); patch({ selectedPlanIndex: selected, plans: draft.plans.map((p: EditPlan, i: number) => (i === selected ? next : p)), }); }; const saveFile = (name: string, text: string) => { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); const a = document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }; const upload = async (file: File, kind: "video" | "audio") => { if (file.size > 125 * 1024 * 1024) throw new Error("单个素材不得超过 125 MiB"); const form = new FormData(); form.append("file", file); let response: Response; try { response = await fetch("/api/canvas/media/upload", { method: "POST", body: form, signal: AbortSignal.timeout(600000), }); } catch (e) { throw new Error( e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError") ? "上传超时,请检查网络后重试,或改用更小的素材" : "上传失败,请检查网络后重试", ); } const result = await response.json(); const url = result.url || result.data?.url; if (!response.ok || !url) throw new Error(result.error || "上传失败"); if (kind === "audio") patch({ music: { url, gain: 0.2, start: 0 } }); else patch({ assets: [ ...latest.current.assets, { id: crypto.randomUUID(), name: file.name, url, subtitles: [] }, ], }); }; const clipEdit = (index: number, value: Record) => { if (plan) changePlan({ ...plan, clips: plan.clips.map((c, i) => (i === index ? { ...c, ...value } : c)), }); }; const cut = (action: "split" | "left" | "right") => { if (!plan || !timeline) return; const clip = timeline.clips[clipIndex]; if (!clip) return; const point = Math.round(clip.sourceIn + (time * SECOND - clip.start) * clip.speed); if (point - clip.sourceIn < 200000 || clip.sourceOut - point < 200000) { setError("请把播放头移到片段内部,并在两边至少保留 0.2 秒"); return; } if (action === "split") { if (plan.clips.length >= 12) { setError("最多 12 个片段"); return; } const left = { ...plan.clips[clipIndex], sourceOut: point }, right = { ...plan.clips[clipIndex], id: crypto.randomUUID(), sourceIn: point }; changePlan({ ...plan, clips: [ ...plan.clips.slice(0, clipIndex), left, right, ...plan.clips.slice(clipIndex + 1), ], }); } else clipEdit(clipIndex, action === "left" ? { sourceIn: point } : { sourceOut: point }); }; const button = "rounded border border-white/20 px-3 py-2 text-xs hover:bg-white/10 disabled:opacity-40", input = "rounded border border-white/15 bg-[#303030] px-2 py-1 text-sm"; if (!plan && !configure) return ( setConfigure(true)} onRetry={() => void startSequence()} loading={busy || waiting || Boolean(draft.preparationRequestId && !last)} message={ error || last?.error || last?.result?.smartEdit?.stage || (connectedSourceIds.length ? "读取已连接的视频素材" : "连接视频素材后开始剪辑") } /> ); const audioTask = [...tasks] .reverse() .find((t) => ["separate", "transcribe"].includes(String(t.request.params.operation))); const audioRecovering = audioTask?.status === "interrupted" && hasRecoverableCanvasTask(audioTask); if (audioTask && (["pending", "running"].includes(audioTask.status) || audioRecovering)) return ( {}} onRetry={() => {}} loading message={ audioTask.result?.smartEdit?.stage || "等待音频处理,可退出剪辑,稍后重新打开查看结果" } /> ); if (plan && timeline && !configure) return ( setConfigure(true)} onRender={onRender} onCancel={onCancel} canSeparate={Boolean(cap?.capabilities?.separation)} canTranscribe={Boolean(cap?.capabilities?.asr)} processError={ audioTask && (["failed", "cancelled"].includes(audioTask.status) || (audioTask.status === "interrupted" && !audioRecovering)) ? audioTask.error || "音频处理失败,原内容已保留" : undefined } onProcess={(operation, plan, clipId, track, keep) => onPlan(smartEditRequestSchema.parse({ operation, plan, clipId, track, keep })) } tasks={tasks} canRender={Boolean(cap?.capabilities?.render)} exportState={draft.exportState} onExportState={(exportState) => patch({ exportState })} /> ); return (
{ if (!Array.from(e.dataTransfer.types).includes("Files")) return; e.preventDefault(); videoDragDepthRef.current += 1; setVideoDragActive(true); }} onDragOver={(e) => { if (!Array.from(e.dataTransfer.types).includes("Files")) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }} onDragLeave={(e) => { videoDragDepthRef.current = Math.max(0, videoDragDepthRef.current - 1); if (!videoDragDepthRef.current) setVideoDragActive(false); }} onDrop={(e) => { if (!Array.from(e.dataTransfer.types).includes("Files")) return; e.preventDefault(); videoDragDepthRef.current = 0; setVideoDragActive(false); const files = Array.from(e.dataTransfer.files).filter((file) => file.type.startsWith("video/"), ); if (!files.length) { setError("请拖入 MP4、MOV 或 WebM 视频素材"); return; } void uploadVideoFiles(files); }} > {videoDragActive ? (
松开以上传视频素材
) : null}

智能剪辑 · {plan?.title || "从素材到成片"}

{plan && ( )}