"use client"; import { useEffect, useRef, useState } from "react"; import { ArrowUp, Check, ChevronDown, Maximize2, Play, Plus, Scissors, Upload, X, } from "lucide-react"; import { editRatios, editResolutions, planTimeline, editableCaptions, SECOND, type EditPlan, } from "@/lib/video-canvas/smart-edit/contract"; import { resolveSmartEditIntent, assertBeatMusic } from "@/lib/video-canvas/smart-edit/intent"; import styles from "./SmartEditNode.module.css"; export type SmartEditSource = { id: string; name: string; url: string; duration?: number }; export type SmartEditMode = "explain" | "ads" | "talk" | "montage"; const modes = [ { id: "auto", name: "默认模式" }, { id: "explain", name: "讲解视频" }, { id: "ads", name: "批量广告" }, { id: "talk", name: "口播视频" }, { id: "montage", name: "素材混剪" }, ]; export function SmartEditNodeCard({ title, previewUrl, plan, onOpen, onMode, selected = false, }: { title: string; selected?: boolean; previewUrl?: string; plan?: EditPlan; onOpen: () => void; onMode?: (mode: SmartEditMode) => void; }) { let timeline: ReturnType | undefined; try { if (plan) timeline = planTimeline(plan); } catch { /* Invalid saved drafts open in the editor for recovery. */ } const length = timeline?.duration || 0, tick = Math.max(1, Math.ceil(length / SECOND / 5)); const empty = !timeline && !previewUrl; return (
{title}
{previewUrl &&
{empty ? (
尝试: {([ ["explain", "讲解视频"], ["ads", "批量广告"], ["talk", "口播视频"], ["montage", "素材混剪"], ] as const).map(([mode, label]) => ( ))}
) : null} {!empty ? <>
{timeline ? ( Array.from({ length: Math.floor(length / SECOND / tick) + 1 }, (_, i) => { const seconds = i * tick; return ( {Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, "0")} ); }) ) : ( 尚未生成时间轴 )}
{timeline && ( <>
{timeline.clips.map((clip) => ( ))}
{!!timeline.audioClips.length && (
{timeline.audioClips.map((clip) => ( ))}
)} {plan?.subtitles && (
{editableCaptions(plan).map((caption) => ( ))}
)} )}
: null}
); } export function SmartEditNodeComposer({ anchor, draft, sources, availableSources, availableAudios = [], onDraft, onAddSource, onRemoveSource, onUpload, onReference, onSubmit, busy, }: { anchor: { centerX: number; bottomY: number; zoom: number }; draft: Record; sources: SmartEditSource[]; availableSources: SmartEditSource[]; availableAudios?: { name: string; url: string }[]; onDraft: (patch: Record) => void; onAddSource: (id: string) => void; onRemoveSource: (id: string) => void; onUpload: (files: File[]) => void; onReference?: () => void; onSubmit: () => Promise; busy: boolean; }) { const [menu, setMenu] = useState<"mode" | "settings" | "sources" | null>(null), [expanded, setExpanded] = useState(false), [error, setError] = useState(""), [submitting, setSubmitting] = useState(false); useEffect(() => setError(""), [draft.music?.url, draft.beatSync]); const root = useRef(null), file = useRef(null), musicFile = useRef(null); useEffect(() => { const close = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setMenu(null); }; document.addEventListener("pointerdown", close); return () => document.removeEventListener("pointerdown", close); }, []); const ratio = draft.ratio || "16:9", resolution = draft.resolution || "720p", duration = draft.targetDuration ?? 30, mode = modes.find((m) => m.id === draft.mode) || modes[0]; const toggle = (value: typeof menu) => setMenu(menu === value ? null : value); return (
e.stopPropagation()} onWheel={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} >
{!!sources.length && (
{sources.map((s, i) => (
))}
)} { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; if (file.size > 125 * 1024 * 1024) { setError("单个素材不得超过 125 MiB"); return; } setSubmitting(true); setError(""); try { const body = new FormData(); body.append("file", file); let response: Response; try { response = await fetch("/api/canvas/media/upload", { method: "POST", body, signal: AbortSignal.timeout(600000), }); } catch (e) { throw new Error( e instanceof DOMException && (e.name === "TimeoutError" || e.name === "AbortError") ? "上传超时,请检查网络后重试,或改用更小的素材" : "上传失败,请检查网络后重试", ); } const data = await response.json(), url = data.url || data.data?.url; if (!response.ok || !url) throw new Error(data.error || "音乐上传失败"); onDraft({ music: { url, gain: 0.2, start: 0 } }); } catch (e) { setError(e instanceof Error ? e.message : "音乐上传失败"); } finally { setSubmitting(false); } }} />