512 lines
16 KiB
TypeScript
512 lines
16 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 };
|
||
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<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));
|
||
const empty = !timeline && !previewUrl;
|
||
return (
|
||
<div className={`${styles.card} ${empty ? styles.emptyCard : ""}`} 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} />
|
||
{empty ? (
|
||
<span>空空如也,请连接视频节点后操作</span>
|
||
) : (
|
||
<button onPointerDown={(e) => e.stopPropagation()} onClick={onOpen}>
|
||
打开智能剪辑
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{empty ? (
|
||
<div className={styles.emptyActions}>
|
||
<span>尝试:</span>
|
||
{([
|
||
["explain", "讲解视频"],
|
||
["ads", "批量广告"],
|
||
["talk", "口播视频"],
|
||
["montage", "素材混剪"],
|
||
] as const).map(([mode, label]) => (
|
||
<button key={mode} onPointerDown={(e) => e.stopPropagation()} onClick={() => onMode?.(mode)} type="button">{label}</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{!empty ? <>
|
||
<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>
|
||
</> : null}
|
||
</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 || "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 (
|
||
<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);
|
||
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);
|
||
}
|
||
}}
|
||
/>
|
||
<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>
|
||
</>
|
||
)}
|
||
<details className={styles.musicDetails}>
|
||
<summary>背景音乐与卡点</summary>
|
||
<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>
|
||
)}
|
||
</details>
|
||
<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>
|
||
);
|
||
}
|