302 lines
9.4 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 } from "@/lib/video-canvas/smart-edit/contract";
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,
onOpen,
selected = false,
}: {
title: string;
selected?: boolean;
previewUrl?: string;
onOpen: () => void;
}) {
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}>
{Array.from({ length: 12 }, (_, i) => (
<span key={i}>0:{String(i * 2).padStart(2, "0")}</span>
))}
</div>
<div className={styles.tracks}>
{[0, 1].map((row) => (
<div key={row}>
{Array.from({ length: 8 }, (_, i) => (
<i key={i} />
))}
</div>
))}
</div>
</div>
);
}
export function SmartEditNodeComposer({
anchor,
draft,
sources,
availableSources,
onDraft,
onAddSource,
onRemoveSource,
onUpload,
onReference,
onSubmit,
busy,
}: {
anchor: { centerX: number; bottomY: number; zoom: number };
draft: Record<string, any>;
sources: SmartEditSource[];
availableSources: SmartEditSource[];
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);
const root = useRef<HTMLDivElement>(null),
file = 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>
)}
<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 {
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>
<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>
);
}