- Starter/toolbox presets no longer seed fake generated results; demo fallback media URLs in tool actions now fail with honest guidance - Delete dead demo scene components, fake director console, demo history constants and unreachable submit toast fallbacks - Smart edit dialog accepts desktop drag-and-drop video files - Raise media upload client timeout to 10 minutes and surface friendly timeout errors instead of raw 'signal timed out'
486 lines
16 KiB
TypeScript
486 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 };
|
|
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);
|
|
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>
|
|
</>
|
|
)}
|
|
<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>
|
|
);
|
|
}
|