477 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|