512 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}