477 lines
15 KiB
TypeScript

"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 };
const modes = [
{ id: "auto", name: "默认模式" },
{ id: "explain", name: "讲解视频" },
{ id: "ads", name: "批量广告" },
{ id: "talk", name: "口播视频" },
{ id: "montage", name: "素材混剪" },
];
export function SmartEditNodeCard({
title,
previewUrl,
plan,
onOpen,
selected = false,
}: {
title: string;
selected?: boolean;
previewUrl?: string;
plan?: EditPlan;
onOpen: () => void;
}) {
let timeline: ReturnType<typeof planTimeline> | 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));
return (
<div className={styles.card} style={{ borderColor: selected ? "#888" : "#454545" }}>
<div className={styles.title}>
<Play size={12} fill="currentColor" />
{title}
</div>
<div className={styles.preview}>
{previewUrl && <video src={`${previewUrl}#t=0.1`} muted preload="metadata" />}
<div className={styles.open}>
<Scissors size={36} />
<button onPointerDown={(e) => e.stopPropagation()} onClick={onOpen}>
打开智能剪辑
</button>
</div>
</div>
<div className={styles.ruler} aria-label="剪辑时间刻度">
{timeline ? (
Array.from({ length: Math.floor(length / SECOND / tick) + 1 }, (_, i) => {
const seconds = i * tick;
return (
<span key={i}>
{Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, "0")}
</span>
);
})
) : (
<span>尚未生成时间轴</span>
)}
</div>
<div className={styles.tracks} aria-label="剪辑时间轴缩略图">
{timeline && (
<>
<div>
{timeline.clips.map((clip) => (
<i
key={clip.id}
title={clip.label}
style={{
left: `${(clip.start / length) * 100}%`,
width: `${(clip.duration / length) * 100}%`,
}}
/>
))}
</div>
{!!timeline.audioClips.length && (
<div>
{timeline.audioClips.map((clip) => (
<i
key={clip.id}
style={{
left: `${(clip.start / length) * 100}%`,
width: `${(clip.duration / length) * 100}%`,
background: "#346752",
}}
/>
))}
</div>
)}
{plan?.subtitles && (
<div>
{editableCaptions(plan).map((caption) => (
<i
key={caption.id}
title={caption.text}
style={{
left: `${(caption.start / length) * 100}%`,
width: `${((caption.end - caption.start) / length) * 100}%`,
background: "#75602e",
}}
/>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
export function SmartEditNodeComposer({
anchor,
draft,
sources,
availableSources,
availableAudios = [],
onDraft,
onAddSource,
onRemoveSource,
onUpload,
onReference,
onSubmit,
busy,
}: {
anchor: { centerX: number; bottomY: number; zoom: number };
draft: Record<string, any>;
sources: SmartEditSource[];
availableSources: SmartEditSource[];
availableAudios?: { name: string; url: string }[];
onDraft: (patch: Record<string, unknown>) => void;
onAddSource: (id: string) => void;
onRemoveSource: (id: string) => void;
onUpload: (files: File[]) => void;
onReference?: () => void;
onSubmit: () => Promise<unknown>;
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<HTMLDivElement>(null),
file = useRef<HTMLInputElement>(null),
musicFile = useRef<HTMLInputElement>(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 || "1080p",
duration = draft.targetDuration ?? 20,
mode = modes.find((m) => m.id === draft.mode) || modes[0];
const toggle = (value: typeof menu) => setMenu(menu === value ? null : value);
return (
<div
ref={root}
className={styles.composer}
style={{ left: anchor.centerX, top: anchor.bottomY + 16 * anchor.zoom }}
onPointerDown={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className={styles.referenceRow}>
<button
onClick={() => (onReference ? onReference() : toggle("sources"))}
onContextMenu={(e) => {
e.preventDefault();
toggle("sources");
}}
>
<Plus size={14} />
参考
</button>
<button
aria-label={expanded ? "收起输入框" : "展开输入框"}
onClick={() => setExpanded(!expanded)}
>
<Maximize2 size={16} />
</button>
</div>
{!!sources.length && (
<div className={styles.sources}>
{sources.map((s, i) => (
<div className={styles.source} key={s.id} title={s.name}>
<video src={`${s.url}#t=0.1`} preload="metadata" muted />
<b>{i + 1}</b>
<Play size={12} fill="white" />
<span>{s.duration ? `${s.duration.toFixed(1)}s` : "视频"}</span>
<button aria-label={`移除 ${s.name}`} onClick={() => onRemoveSource(s.id)}>
<X size={10} />
</button>
</div>
))}
</div>
)}
<input
ref={musicFile}
hidden
type="file"
accept="audio/*"
onChange={async (e) => {
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);
const response = await fetch("/api/canvas/media/upload", {
method: "POST",
body,
signal: AbortSignal.timeout(120000),
});
const data = await response.json(),
url = data.url || data.data?.url;
if (!response.ok || !url) throw Error(data.error || "音乐上传失败");
onDraft({ music: { url, gain: 0.2, start: 0 } });
} catch (e) {
setError(e instanceof Error ? e.message : "音乐上传失败");
} finally {
setSubmitting(false);
}
}}
/>
<textarea
aria-label="智能剪辑要求"
placeholder="描述你想要的剪辑效果"
value={draft.prompt || ""}
onChange={(e) => onDraft({ prompt: e.target.value })}
style={{ height: expanded ? 192 : 96 }}
/>
<div className={styles.controls}>
<button className={styles.mode} onClick={() => toggle("mode")}>
{mode.name}
<ChevronDown size={12} />
</button>
<button className={styles.parameters} onClick={() => toggle("settings")}>
{ratio} · {String(resolution).toUpperCase()} · {duration ? `${duration}s` : "自动"}
<ChevronDown size={12} />
</button>
<button
className={styles.send}
disabled={busy || submitting || sources.length === 0}
aria-label="发送"
onClick={async () => {
setError("");
setSubmitting(true);
try {
assertBeatMusic(resolveSmartEditIntent(draft));
await onSubmit();
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败");
} finally {
setSubmitting(false);
}
}}
>
<ArrowUp size={20} />
</button>
</div>
{error && (
<div role="alert" className={styles.error}>
{error}
</div>
)}
{menu === "mode" && (
<div className={`${styles.popup} ${styles.modeMenu}`}>
{modes.map((m) => (
<button
key={m.id}
onClick={() => {
onDraft({ mode: m.id });
setMenu(null);
}}
>
{m.name}
{m.id === mode.id && <Check size={14} />}
</button>
))}
</div>
)}
{menu === "settings" && (
<div className={`${styles.popup} ${styles.settings}`}>
<label>比例</label>
<div className={styles.ratios}>
{editRatios.map((r) => {
const [w, h] = r.split(":").map(Number);
return (
<button
key={r}
aria-pressed={r === ratio}
onClick={() => onDraft({ ratio: r })}
>
<i
style={{
width: Math.min(16, (16 * w) / h),
height: Math.min(16, (16 * h) / w),
}}
/>
{r}
</button>
);
})}
</div>
<label htmlFor="smart-edit-duration">时长</label>
<div className={styles.duration}>
<input
aria-label="目标时长"
type="range"
min={0}
max={300}
step={1}
value={duration}
onChange={(e) => onDraft({ targetDuration: Number(e.target.value) })}
/>
<input
id="smart-edit-duration"
type="number"
min={0}
max={300}
value={duration}
onChange={(e) =>
onDraft({ targetDuration: Math.max(0, Math.min(300, Number(e.target.value))) })
}
/>
<span>s</span>
</div>
{mode.id === "ads" && (
<>
<label>广告版本</label>
<div className={styles.resolutions}>
{[1, 2, 3].map((count) => (
<button
key={count}
aria-pressed={(draft.variants ?? 3) === count}
onClick={() => onDraft({ variants: count })}
>
{count} 版
</button>
))}
</div>
</>
)}
<label htmlFor="smart-edit-music">背景音乐</label>
<select
id="smart-edit-music"
value={draft.music?.url || ""}
onChange={(e) =>
onDraft({
music: e.target.value
? { url: e.target.value, gain: 0.2, start: 0 }
: undefined,
})
}
>
<option value="">未选择</option>
{draft.music?.url && !availableAudios.some((a) => a.url === draft.music.url) && (
<option value={draft.music.url}>已上传音乐</option>
)}
{availableAudios.map((audio) => (
<option key={audio.url} value={audio.url}>
{audio.name}
</option>
))}
</select>
<button
className={styles.musicUpload}
disabled={submitting}
onClick={() => musicFile.current?.click()}
>
<Upload size={12} />
上传音乐
</button>
<label className={styles.beatToggle}>
<input
type="checkbox"
checked={resolveSmartEditIntent(draft).beatSync}
onChange={(e) => onDraft({ beatSync: e.target.checked })}
/>
音乐卡点
</label>
{resolveSmartEditIntent(draft).beatSync && (
<div className={styles.resolutions}>
{["2", "4"].map((beats) => (
<button
key={beats}
aria-pressed={(draft.beatsPerCut || "4") === beats}
onClick={() => onDraft({ beatsPerCut: beats })}
>
{beats} 拍切换
</button>
))}
</div>
)}
<label>清晰度</label>
<div className={styles.resolutions}>
{editResolutions.map((r) => (
<button
key={r}
aria-pressed={r === resolution}
onClick={() => onDraft({ resolution: r })}
>
{r.toUpperCase()}
</button>
))}
</div>
</div>
)}
{menu === "sources" && (
<div className={`${styles.popup} ${styles.sourceMenu}`}>
<button onClick={() => file.current?.click()} disabled={sources.length >= 12}>
<Upload size={14} />
上传视频
</button>
<div>画布素材 · {sources.length}/12</div>
{availableSources.map((s) => (
<button
key={s.id}
disabled={sources.length >= 12 && !sources.some((a) => a.id === s.id)}
onClick={() =>
sources.some((a) => a.id === s.id) ? onRemoveSource(s.id) : onAddSource(s.id)
}
>
<Play size={14} />
<span>{s.name}</span>
{sources.some((a) => a.id === s.id) && <Check size={14} />}
</button>
))}
{!availableSources.length && <p>上传视频或连接画布视频素材</p>}
</div>
)}
<input
ref={file}
type="file"
hidden
accept="video/*"
multiple
onChange={(e) => {
onUpload(Array.from(e.target.files || []).slice(0, 12 - sources.length));
e.target.value = "";
setMenu(null);
}}
/>
</div>
);
}