ai-chatbot/components/canvas/smart-edit/SmartEditEditor.tsx

1665 lines
57 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, type PointerEvent, type ReactNode } from "react";
import {
X,
ChevronDown,
Download,
Frame,
Play,
Pause,
Maximize,
Type,
Magnet,
Plus,
Volume2,
VolumeX,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Shrink,
Expand,
Eye,
EyeOff,
Search,
} from "lucide-react";
import { EditPreview } from "./EditPreview";
import styles from "./SmartEditEditor.module.css";
import {
planTimeline,
editableCaptions,
defaultCaptionStyle,
SECOND,
type EditPlan,
type TimelineCaption,
type CaptionStyle,
} from "@/lib/video-canvas/smart-edit/contract";
import type { CanvasGenerationTask } from "@/lib/video-canvas/task-contract";
import { downloadCanvasMedia } from "@/lib/video-canvas/download";
const transitionNames = { fade: "淡入淡出", fadeblack: "黑场过渡", fadewhite: "白场过渡" };
const stamp = (s: number) =>
`${String(Math.floor(s / 60)).padStart(2, "0")}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const frameStamp = (s: number) =>
`00:${stamp(s)}:${String(Math.floor(s * 30) % 30).padStart(2, "0")}`;
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v));
function Icon({
title,
children,
onClick,
active = false,
disabled = false,
extra = "",
}: {
title: string;
children: ReactNode;
onClick: () => void;
active?: boolean;
disabled?: boolean;
extra?: string;
}) {
return (
<button
type="button"
aria-label={title}
title={title}
aria-pressed={active || undefined}
className={`se-icon ${active ? "active" : ""} ${extra}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
function Thumb({ url, time, width }: { url: string; time: number; width?: number }) {
return (
<video
style={width ? { width } : undefined}
src={`${url}#t=${time.toFixed(3)}`}
muted
playsInline
preload="metadata"
aria-hidden="true"
onLoadedMetadata={(e) => {
e.currentTarget.currentTime = time;
}}
/>
);
}
export function SmartEditEditor({
plan,
onChange,
onClose,
onConfigure,
onRender,
tasks,
canRender,
exportState,
onExportState,
}: {
plan: EditPlan;
onChange: (p: EditPlan) => void;
onClose: () => void;
onConfigure: () => void;
onRender: (p: EditPlan) => Promise<unknown>;
tasks: CanvasGenerationTask[];
canRender: boolean;
exportState?: { requestId: string; destination: "local" | "canvas" };
onExportState: (
value: { requestId: string; destination: "local" | "canvas" } | undefined,
) => void;
}) {
const [time, setTime] = useState(0),
[playing, setPlaying] = useState(false),
[selected, setSelected] = useState<string>(),
[draggedClip, setDraggedClip] = useState<{ id: string; offset: number }>(),
[panel, setPanel] = useState<"video" | "caption" | "transition" | null>(null),
[panelWidth, setPanelWidth] = useState(300),
[libraryWidth, setLibraryWidth] = useState(372),
[captionsOpen, setCaptionsOpen] = useState(false),
[subTab, setSubTab] = useState<"list" | "text">("list"),
[captionId, setCaptionId] = useState<string>(),
[transitionsOpen, setTransitionsOpen] = useState(false),
[transitionIndex, setTransitionIndex] = useState(1),
[zoom, setZoom] = useState(20),
[viewportWidth, setViewportWidth] = useState(() =>
typeof window === "undefined" ? 1280 : window.innerWidth,
),
[snap, setSnap] = useState(false),
[collapsed, setCollapsed] = useState(false),
[timelineHeight, setTimelineHeight] = useState(() =>
typeof window === "undefined" ? 244 : Math.round(window.innerHeight * 0.35) - 8,
),
[exportOpen, setExportOpen] = useState(false),
[error, setError] = useState(() => plan.warnings.join(";")),
[busy, setBusy] = useState(false),
[search, setSearch] = useState(""),
[context, setContext] = useState<{
x: number;
y: number;
kind: "clip" | "caption" | "timeline";
}>();
const root = useRef<HTMLDivElement>(null),
preview = useRef<HTMLDivElement>(null),
scroll = useRef<HTMLDivElement>(null);
const resized = useRef(false);
useEffect(() => {
const resize = () => {
setViewportWidth(window.innerWidth);
if (!resized.current) setTimelineHeight(Math.round(window.innerHeight * 0.35) - 8);
};
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
const undo = useRef<EditPlan[]>([]),
redo = useRef<EditPlan[]>([]),
latest = useRef(plan),
downloaded = useRef<string>();
latest.current = plan;
const timeline = planTimeline(plan),
duration = timeline.duration / SECOND,
pps = 25 * 2 ** (zoom / 10),
clipIndex = plan.clips.findIndex((c) => c.id === selected),
clip = plan.clips[clipIndex],
captions = editableCaptions(plan),
caption = captions.find((s) => s.id === captionId),
captionStyle = caption?.style || defaultCaptionStyle;
const pending = exportState && tasks.find((t) => t.requestId === exportState.requestId);
const exportWaiting =
busy ||
Boolean(
exportState && (!pending || ["pending", "running", "interrupted"].includes(pending.status)),
);
const resultUrl =
pending?.status === "succeeded" ? (pending.result?.url as string | undefined) : undefined;
const apply = (next: EditPlan, history = true) => {
try {
planTimeline(next);
if (history) {
undo.current.push(latest.current);
if (undo.current.length > 50) undo.current.shift();
redo.current = [];
}
setPlaying(false);
setError("");
onChange(next);
} catch (e) {
setError((e as Error).message);
}
};
const changeClip = (index: number, patch: Partial<EditPlan["clips"][number]>) =>
apply({ ...plan, clips: plan.clips.map((c, i) => (i === index ? { ...c, ...patch } : c)) });
const changeCaption = (id: string, patch: Partial<TimelineCaption>) =>
apply({ ...plan, captions: captions.map((s) => (s.id === id ? { ...s, ...patch } : s)) });
const changeStyle = (patch: Partial<CaptionStyle>) => {
if (caption) changeCaption(caption.id, { style: { ...captionStyle, ...patch } });
};
const seek = (seconds: number) => {
setTime(clamp(seconds, 0, duration));
setPlaying(false);
};
const position = (clientX: number) => {
const el = scroll.current;
return el
? clamp((clientX - el.getBoundingClientRect().left + el.scrollLeft) / pps, 0, duration)
: 0;
};
const snapped = (seconds: number) => {
if (!snap) return seconds;
const values = [
0,
time,
duration,
...timeline.clips.flatMap((c) => [c.start / SECOND, c.end / SECOND]),
];
return values.find((v) => Math.abs(v - seconds) * pps < 8) ?? seconds;
};
const [, setHistoryRevision] = useState(0);
const drag = (
e: PointerEvent,
move: (event: globalThis.PointerEvent) => void,
finish?: () => void,
) => {
e.preventDefault();
e.stopPropagation();
const original = latest.current;
const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId);
const end = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", cancel);
finish?.();
setHistoryRevision((value) => value + 1);
};
const cancel = () => {
setDraggedClip(undefined);
if (latest.current !== original) onChange(original);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", cancel);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end);
window.addEventListener("pointercancel", cancel);
};
const columnResize = (e: PointerEvent, side: "left" | "right") => {
const x = e.clientX,
initial = side === "left" ? libraryWidth : panelWidth;
drag(e, (event) => {
const value = clamp(
initial + (event.clientX - x) * (side === "left" ? 1 : -1),
side === "left" ? 280 : 240,
Math.max(
300,
innerWidth - (side === "left" ? panelWidth : transitionsOpen ? libraryWidth : 0) - 220,
),
);
(side === "left" ? setLibraryWidth : setPanelWidth)(value);
});
};
const dragTrim = (e: PointerEvent, index: number, side: "left" | "right") => {
const original = plan,
c = plan.clips[index],
startX = e.clientX,
asset = plan.assets.find((a) => a.id === c.assetId)!;
let next = original;
drag(
e,
(event) => {
const delta = ((event.clientX - startX) / pps) * SECOND * c.speed;
const value = Math.round(
side === "left"
? clamp(c.sourceIn + delta, 0, c.sourceOut - 200000)
: clamp(c.sourceOut + delta, c.sourceIn + 200000, asset.duration),
);
next = {
...original,
clips: original.clips.map((item, i) =>
i === index ? { ...item, [side === "left" ? "sourceIn" : "sourceOut"]: value } : item,
),
};
try {
planTimeline(next);
onChange(next);
} catch {
next = latest.current;
}
},
() => {
undo.current.push(original);
redo.current = [];
setPlaying(false);
},
);
};
const dragClip = (e: PointerEvent, index: number) => {
if ((e.target as HTMLElement).closest(".se-handle")) return;
const x = e.clientX,
original = plan,
id = plan.clips[index].id;
setSelected(id);
setPanel("video");
setCaptionId(undefined);
setPlaying(false);
let moved = false,
destination = index;
drag(
e,
(event) => {
if (Math.abs(event.clientX - x) < 6 && !moved) return;
moved = true;
setDraggedClip({ id, offset: event.clientX - x });
const point = position(event.clientX) * SECOND;
destination = timeline.clips.findIndex((c) => point >= c.start && point < c.end);
if (destination < 0) destination = point >= timeline.duration ? plan.clips.length - 1 : 0;
},
() => {
setDraggedClip(undefined);
if (moved && destination !== index) {
const clips = [...original.clips];
clips.splice(destination, 0, clips.splice(index, 1)[0]);
apply({ ...original, clips });
}
},
);
};
const dragCaptionEdge = (e: PointerEvent, caption: TimelineCaption, side: "start" | "end") => {
const x = e.clientX,
original = plan;
drag(
e,
(event) => {
const point = Math.round(
snapped(caption[side] / SECOND + (event.clientX - x) / pps) * SECOND,
);
const value =
side === "start"
? clamp(point, 0, caption.end - 100000)
: clamp(point, caption.start + 100000, timeline.duration);
onChange({
...original,
captions: captions.map((s) => (s.id === caption.id ? { ...s, [side]: value } : s)),
});
},
() => {
undo.current.push(original);
redo.current = [];
setPlaying(false);
},
);
};
const cutPoint = clip
? Math.round(clip.sourceIn + (time * SECOND - timeline.clips[clipIndex].start) * clip.speed)
: 0;
const canCut = Boolean(
clip && cutPoint - clip.sourceIn >= 200000 && clip.sourceOut - cutPoint >= 200000,
);
const cut = (kind: "split" | "left" | "right") => {
if (!clip || !canCut) return;
if (kind === "split") {
if (plan.clips.length >= 12) {
setError("最多 12 个片段");
return;
}
apply({
...plan,
clips: [
...plan.clips.slice(0, clipIndex),
{ ...clip, sourceOut: cutPoint },
{
...clip,
id: crypto.randomUUID(),
sourceIn: cutPoint,
transition: { type: "fade", duration: 0 },
},
...plan.clips.slice(clipIndex + 1),
],
});
} else
changeClip(clipIndex, kind === "left" ? { sourceIn: cutPoint } : { sourceOut: cutPoint });
};
const remove = () => {
if (panel === "caption" && caption) {
apply({ ...plan, captions: captions.filter((s) => s.id !== caption.id) });
setCaptionId(undefined);
} else if (clip) {
if (plan.clips.length === 1) {
setError("请至少保留一个视频片段");
return;
}
apply({ ...plan, clips: plan.clips.filter((c) => c.id !== selected) });
setSelected(undefined);
setPanel(null);
}
setContext(undefined);
};
const togglePlay = () => {
if (time >= duration) setTime(0);
setPlaying((v) => !v);
};
const history = (forward: boolean) => {
const stack = forward ? redo : undo,
target = stack.current.pop();
if (target) {
(forward ? undo : redo).current.push(latest.current);
apply(target, false);
}
};
useEffect(() => {
if (time > duration) setTime(duration);
}, [duration, time]);
useEffect(() => {
const key = (e: KeyboardEvent) => {
if ((e.target as HTMLElement)?.closest("input,textarea,select,[contenteditable=true]"))
return;
if (
["Space", "ArrowLeft", "ArrowRight", "Backspace", "Delete", "Escape"].includes(e.code) ||
((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
)
e.stopPropagation();
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
if (!e.repeat) history(e.shiftKey);
} else if (e.code === "Space") {
e.preventDefault();
togglePlay();
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
remove();
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
seek(time + (e.key === "ArrowLeft" ? -1 : 1) / 30);
} else if (e.key === "Escape") {
setContext(undefined);
setExportOpen(false);
setPanel(null);
setSelected(undefined);
}
};
window.addEventListener("keydown", key, true);
return () => window.removeEventListener("keydown", key, true);
});
useEffect(() => {
if (
resultUrl &&
exportState?.destination === "local" &&
downloaded.current !== exportState.requestId
) {
downloaded.current = exportState.requestId;
void downloadCanvasMedia([{ url: resultUrl, name: plan.title }]).catch((e) =>
setError(`成片已生成,下载失败:${e.message}`),
);
}
}, [resultUrl, exportState, plan.title]);
useEffect(() => {
if (pending?.status === "failed") setError(pending.error || "导出失败,请重试");
}, [pending?.status, pending?.error]);
const exportPlan = async (destination: "local" | "canvas") => {
setExportOpen(false);
setBusy(true);
setError("");
setPlaying(false);
try {
const requestId = await onRender(plan);
if (typeof requestId !== "string") throw Error("未取得导出任务编号,请在画布检查任务状态");
onExportState({ requestId, destination });
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
const addCaption = () => {
if (captions.length >= 500) {
setError("字幕最多 500 条");
return;
}
const start = Math.round(Math.min(time, Math.max(0, duration - 0.2)) * SECOND),
id = crypto.randomUUID();
apply({
...plan,
subtitles: true,
captions: [
...captions,
{ id, start, end: Math.min(timeline.duration, start + 2 * SECOND), text: "默认文本" },
],
});
setCaptionId(id);
setSubTab("text");
setPanel("caption");
};
const transition = plan.clips[transitionIndex]?.transition;
const setTransition = (type: "fade" | "fadeblack" | "fadewhite", value = 0.3) => {
if (!plan.clips[transitionIndex]) {
setError("至少需要两个片段才能添加转场");
return;
}
changeClip(transitionIndex, { transition: { type, duration: value } });
};
const openContext = (e: React.MouseEvent, kind: "clip" | "caption" | "timeline") => {
e.preventDefault();
e.stopPropagation();
setContext({
x: Math.min(e.clientX, innerWidth - 165),
y: Math.min(e.clientY, innerHeight - 160),
kind,
});
};
return (
<div
ref={root}
className={styles.root}
role="dialog"
aria-modal="true"
aria-label="智能剪辑"
onPointerDown={(e) => {
e.stopPropagation();
if (!(e.target as HTMLElement).closest(".se-actions")) setExportOpen(false);
}}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setContext(undefined);
}}
>
<header className="se-header">
<span className="se-title">智能剪辑</span>
<div className="se-actions">
<button
className="se-export"
aria-label="导出"
aria-expanded={exportOpen}
disabled={!canRender || exportWaiting}
title={!canRender ? "视频合成服务不可用" : undefined}
onClick={() => setExportOpen((v) => !v)}
>
导出
<ChevronDown />
</button>
<Icon title="退出剪辑" extra="se-exit" onClick={onClose}>
<X />
</Icon>
{exportOpen && (
<div className="se-menu" role="menu">
<div className="se-menu-title">导出位置</div>
<button role="menuitem" onClick={() => void exportPlan("local")}>
<Download />
导出到本地
</button>
<button role="menuitem" onClick={() => void exportPlan("canvas")}>
<Frame />
导出到画布
</button>
</div>
)}
</div>
</header>
{error && (
<button className="se-error" role="alert" onClick={() => setError("")}>
{error}
</button>
)}
{resultUrl && (
<div className="se-export-result">
{exportState?.destination === "canvas" ? "成片已添加到画布" : "成片已生成"}
<button
onClick={() =>
void downloadCanvasMedia([{ url: resultUrl, name: plan.title }]).catch((e) =>
setError(e.message),
)
}
>
下载成片
</button>
<button aria-label="关闭导出提示" onClick={() => onExportState(undefined)}>
×
</button>
</div>
)}
{!collapsed && (
<div className="se-body">
{transitionsOpen && (
<aside className="se-panel left" aria-label="转场库" style={{ width: libraryWidth }}>
<div
className="se-column-resizer right"
role="separator"
aria-label="调整转场库宽度"
aria-orientation="vertical"
onPointerDown={(e) => columnResize(e, "left")}
/>
<div className="se-panel-head">
转场库
<Icon
title="关闭转场库"
onClick={() => {
setTransitionsOpen(false);
if (panel === "transition") setPanel(null);
}}
>
<X />
</Icon>
</div>
<div className="se-panel-scroll">
<div className="se-small-label">基础转场</div>
<div className="se-transition-list">
{Object.entries(transitionNames).map(([type, name]) => (
<button
key={type}
className={`se-transition-card ${transition?.type === type && transition.duration ? "selected" : ""}`}
onClick={() => setTransition(type as keyof typeof transitionNames)}
>
<span
className={`se-transition-demo ${type === "fadeblack" ? "black" : type === "fadewhite" ? "white" : ""}`}
>
<Thumb url={plan.assets[0].url} time={plan.clips[0].sourceIn / SECOND} />
</span>
{name}
</button>
))}
</div>
</div>
</aside>
)}
<div
className="se-preview"
ref={preview}
onClick={() => {
setSelected(undefined);
if (panel === "video") setPanel(null);
}}
>
<div className="se-preview-stage">
<EditPreview
plan={plan}
time={time}
playing={playing}
onTime={setTime}
onPlaying={setPlaying}
onError={setError}
fit
/>
</div>
{exportWaiting && (
<div className="se-progress" role="status">
{pending?.result?.smartEdit?.stage || "正在导出,任务已保存,可返回画布继续工作"}
</div>
)}
</div>
{panel === "video" && clip && (
<aside className="se-panel" aria-label="视频参数" style={{ width: panelWidth }}>
<div
className="se-column-resizer"
role="separator"
aria-label="调整参数栏宽度"
aria-orientation="vertical"
onPointerDown={(e) => columnResize(e, "right")}
/>
<div className="se-panel-head">
视频
<Icon title="关闭视频参数" onClick={() => setPanel(null)}>
<X />
</Icon>
</div>
<div className="se-panel-scroll">
<h3>变速</h3>
<ValueField
label="变速倍数"
suffix="x"
value={clip.speed}
min={0.5}
max={2}
precision={2}
onChange={(speed) => changeClip(clipIndex, { speed })}
/>
<ValueField
label="时长"
suffix="s"
value={(clip.sourceOut - clip.sourceIn) / clip.speed / SECOND}
min={0.2 / clip.speed}
max={
(plan.assets.find((a) => a.id === clip.assetId)!.duration - clip.sourceIn) /
clip.speed /
SECOND
}
precision={1}
onChange={(v) =>
changeClip(clipIndex, {
sourceOut: Math.round(clip.sourceIn + v * clip.speed * SECOND),
})
}
/>
<div className="se-divider" />
<h3>音量</h3>
<ValueField
label="音量增益"
suffix="dB"
value={clip.gain ? 20 * Math.log10(clip.gain) : -60}
min={-60}
max={0}
precision={1}
onChange={(v) => changeClip(clipIndex, { gain: v <= -60 ? 0 : 10 ** (v / 20) })}
/>
</div>
</aside>
)}
{panel === "transition" && (
<aside className="se-panel" aria-label="转场参数" style={{ width: panelWidth }}>
<div
className="se-column-resizer"
role="separator"
aria-label="调整参数栏宽度"
aria-orientation="vertical"
onPointerDown={(e) => columnResize(e, "right")}
/>
<div className="se-panel-head">
转场
<Icon title="关闭转场参数" onClick={() => setPanel(null)}>
<X />
</Icon>
</div>
<div className="se-panel-scroll">
<div className="se-field">
<label>名称</label>
<span style={{ padding: "0 12px", fontSize: 13 }}>
{transition?.duration ? transitionNames[transition.type] : "未选择转场"}
</span>
</div>
<div className="se-field">
<label htmlFor="se-transition-length">时长</label>
<input
id="se-transition-length"
aria-label="转场时长(秒)"
type="number"
min="0"
max="0.5"
step="0.05"
disabled={!transition?.duration}
value={transition?.duration || 0}
onChange={(e) => {
if (Number.isFinite(e.target.valueAsNumber))
setTransition(
transition?.type || "fade",
clamp(e.target.valueAsNumber, 0, 0.5),
);
}}
/>
</div>
<div
style={{
background: "#161616",
borderRadius: 8,
aspectRatio: "16/9",
marginTop: 20,
}}
/>
</div>
<div className="se-panel-foot">
<button disabled={!transition?.duration} onClick={() => setTransition("fade", 0)}>
删除转场
</button>
</div>
</aside>
)}
{panel === "caption" && (
<aside className="se-panel" aria-label="字幕参数" style={{ width: panelWidth }}>
<div
className="se-column-resizer"
role="separator"
aria-label="调整参数栏宽度"
aria-orientation="vertical"
onPointerDown={(e) => columnResize(e, "right")}
/>
<div className="se-tabs">
<button
className={subTab === "list" ? "active" : ""}
onClick={() => setSubTab("list")}
>
字幕
</button>
<button
className={subTab === "text" ? "active" : ""}
onClick={() => setSubTab("text")}
>
文本
</button>
</div>
<div className="se-panel-scroll">
{subTab === "list" ? (
<>
<div className="se-search">
<input
aria-label="搜索字幕文本"
placeholder="搜索字幕文本"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<Search size={14} />
</div>
<button className="se-wide-button" onClick={addCaption}>
<Plus size={14} />
新建字幕
</button>
{!captions.length && <div className="se-empty">暂无字幕</div>}
{captions
.filter((s) => s.text.includes(search))
.map((s) => (
<div
key={s.id}
className={`se-caption-item ${s.id === captionId ? "active" : ""}`}
onClick={() => {
setCaptionId(s.id);
seek(s.start / SECOND);
}}
>
<div className="se-caption-times">
<input
aria-label="字幕开始时间"
type="number"
min="0"
step="0.1"
value={s.start / SECOND}
onChange={(e) => {
if (Number.isFinite(e.target.valueAsNumber))
changeCaption(s.id, {
start: Math.round(
clamp(e.target.valueAsNumber * SECOND, 0, s.end - 100000),
),
});
}}
/>
—
<input
aria-label="字幕结束时间"
type="number"
min="0"
step="0.1"
value={s.end / SECOND}
onChange={(e) => {
if (Number.isFinite(e.target.valueAsNumber))
changeCaption(s.id, {
end: Math.round(
clamp(
e.target.valueAsNumber * SECOND,
s.start + 100000,
timeline.duration,
),
),
});
}}
/>
</div>
<p onDoubleClick={() => setSubTab("text")}>{s.text}</p>
</div>
))}
</>
) : caption ? (
<>
<textarea
className="se-textarea"
aria-label="字幕文本"
maxLength={500}
value={caption.text}
onChange={(e) => changeCaption(caption.id, { text: e.target.value })}
/>
<div className="se-style-row">
<label>字体</label>
<select
className="se-font"
aria-label="字幕字体"
value="source-han"
onChange={() => {}}
>
<option value="source-han">Source Han Sans CN</option>
</select>
</div>
<StyleNumber
label="字号"
value={captionStyle.fontSize}
min={8}
max={160}
onChange={(v) => changeStyle({ fontSize: v })}
/>
<StyleColor
label="字体颜色"
value={captionStyle.color}
onChange={(color) => changeStyle({ color })}
/>
<StyleNumber
label="字间距"
value={captionStyle.spacing}
min={-5}
max={30}
onChange={(spacing) => changeStyle({ spacing })}
/>
<div className="se-style-row">
<label>预设样式</label>
<div className="se-presets">
{[
["默认", "#ffffff", "#000000", 0],
["白字黑边", "#ffffff", "#000000", 2],
["黑字白边", "#000000", "#ffffff", 2],
["黄字黑边", "#ffff00", "#000000", 2],
["白字粉边", "#ffffff", "#ff65a0", 2],
["粉字黑边", "#ff65a0", "#000000", 2],
].map(([name, color, outlineColor, outlineWidth]) => (
<button
key={name}
title={String(name)}
aria-label={String(name)}
style={{
color: String(color),
WebkitTextStroke: `${outlineWidth ? 1 : 0}px ${outlineColor}`,
}}
onClick={() =>
changeStyle({
color: String(color),
outlineColor: String(outlineColor),
outlineWidth: Number(outlineWidth),
})
}
>
T
</button>
))}
</div>
</div>
<StyleColor
label="描边颜色"
value={captionStyle.outlineColor}
onChange={(outlineColor) => changeStyle({ outlineColor })}
/>
<StyleNumber
label="粗细"
value={captionStyle.outlineWidth}
min={0}
max={10}
onChange={(outlineWidth) => changeStyle({ outlineWidth })}
/>
<StyleNumber
label="不透明度"
value={Math.round(captionStyle.opacity * 100)}
min={0}
max={100}
onChange={(v) => changeStyle({ opacity: v / 100 })}
/>
</>
) : (
<div className="se-muted">请先选择一条字幕</div>
)}
</div>
{subTab === "text" && caption && (
<div className="se-panel-foot">
<button
onClick={() =>
apply({
...plan,
captions: captions.map((s) => ({ ...s, style: { ...captionStyle } })),
})
}
>
应用到全部字幕
</button>
</div>
)}
</aside>
)}
</div>
)}
<div
className="se-timeline"
style={{
height: collapsed ? "calc(100% - 48px)" : timelineHeight,
flex: collapsed ? 1 : undefined,
}}
onContextMenu={(e) => openContext(e, "timeline")}
>
<div
className="se-resizer"
role="separator"
aria-label="拖拽调整高度"
aria-orientation="horizontal"
onPointerDown={(e) => {
resized.current = true;
const y = e.clientY,
h = timelineHeight;
drag(e, (event) =>
setTimelineHeight(clamp(h + y - event.clientY, 130, innerHeight - 220)),
);
}}
/>
<div className="se-toolbar">
<div className="se-toolbar-group">
<Icon
title="撤销"
extra="se-history"
disabled={!undo.current.length}
onClick={() => history(false)}
>
<Undo2 />
</Icon>
<Icon
title="重做"
extra="se-history"
disabled={!redo.current.length}
onClick={() => history(true)}
>
<Redo2 />
</Icon>
<Icon
title="分割"
disabled={!canCut || plan.clips.length >= 12}
onClick={() => cut("split")}
>
<CutIcon />
</Icon>
<Icon title="向左裁剪" disabled={!canCut} onClick={() => cut("left")}>
<CutIcon side="left" />
</Icon>
<Icon title="向右裁剪" disabled={!canCut} onClick={() => cut("right")}>
<CutIcon side="right" />
</Icon>
<Icon
title="字幕轨道"
active={captionsOpen}
onClick={() => {
setCaptionsOpen((v) => !v);
setPanel(captionsOpen ? null : "caption");
if (!captionsOpen) setSubTab("list");
}}
>
<Type />
</Icon>
<Icon
title="转场"
active={transitionsOpen}
onClick={() => {
setTransitionsOpen((v) => !v);
setPanel(transitionsOpen ? null : "transition");
}}
>
<TransitionIcon />
</Icon>
</div>
<div className="se-toolbar-middle">
<span className="se-current-time">{stamp(time)}</span>
<Icon title={playing ? "暂停" : "播放"} extra="se-play" onClick={togglePlay}>
{playing ? (
<Pause fill="currentColor" size={12} />
) : (
<Play fill="currentColor" size={12} />
)}
</Icon>
<span className="se-duration">{stamp(duration)}</span>
<Icon
title="全屏预览"
onClick={() => {
if (document.fullscreenElement) void document.exitFullscreen();
else
void preview.current
?.requestFullscreen()
.catch(() => setError("浏览器不支持全屏预览"));
}}
>
<Maximize />
</Icon>
</div>
<div className="se-toolbar-group">
<Icon title="吸附" active={snap} onClick={() => setSnap((v) => !v)}>
<Magnet />
</Icon>
<Icon title="缩小" onClick={() => setZoom((v) => Math.max(0, v - 2))}>
<ZoomOut />
</Icon>
<input
aria-label="时间轴缩放"
className="se-zoom"
type="range"
min="0"
max="40"
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
<Icon title="放大" onClick={() => setZoom((v) => Math.min(40, v + 2))}>
<ZoomIn />
</Icon>
<Icon
title={collapsed ? "展开预览" : "收起预览"}
onClick={() => setCollapsed((v) => !v)}
>
{collapsed ? <Expand /> : <Shrink />}
</Icon>
</div>
</div>
<div className="se-tracks">
<div className="se-track-labels">
{captionsOpen && (
<div className="se-track-label caption">
<Type size={14} />
<Icon
title={plan.subtitles ? "隐藏字幕" : "显示字幕"}
onClick={() => apply({ ...plan, subtitles: !plan.subtitles })}
>
{plan.subtitles ? <Eye /> : <EyeOff />}
</Icon>
</div>
)}
<div className="se-track-label">
<span className="se-track-type">
<TransitionIcon />
</span>
<Icon
title={plan.clips.some((c) => c.gain > 0) ? "静音视频原声" : "恢复视频原声"}
onClick={() =>
apply({
...plan,
clips: plan.clips.map((c) => ({
...c,
gain: plan.clips.some((c) => c.gain > 0) ? 0 : 1,
})),
})
}
>
{plan.clips.some((c) => c.gain > 0) ? <Volume2 /> : <VolumeX />}
</Icon>
</div>
</div>
<div className="se-scroll" ref={scroll}>
<div
className="se-track-content"
style={{ width: Math.max(duration + 1, (viewportWidth - 112) / pps) * pps }}
>
<div
className="se-ruler"
aria-label="时间刻度"
onPointerDown={(e) => {
seek(position(e.clientX));
drag(e, (event) => seek(snapped(position(event.clientX))));
}}
>
{Array.from(
{ length: Math.ceil(Math.max(duration + 1, (viewportWidth - 112) / pps) * 10) },
(_, i) => (
<div
key={i}
className={`se-tick ${i % 10 === 0 ? "major" : ""}`}
style={{ left: (i * pps) / 10 }}
>
{i % 10 === 0 && <span>{stamp(i / 10)}</span>}
</div>
),
)}
</div>
{captionsOpen && (
<div className="se-caption-track" aria-label="字幕时间轴">
{captions.map((s) => (
<div
key={s.id}
role="button"
tabIndex={0}
aria-label={`字幕:${s.text}`}
className={`se-caption-block ${captionId === s.id ? "selected" : ""}`}
style={{
left: (s.start / SECOND) * pps,
width: ((s.end - s.start) / SECOND) * pps,
opacity: plan.subtitles ? 1 : 0.4,
}}
onClick={() => {
setCaptionId(s.id);
setPanel("caption");
setSubTab("text");
}}
onContextMenu={(e) => {
setCaptionId(s.id);
setPanel("caption");
openContext(e, "caption");
}}
onPointerDown={(e) => {
const x = e.clientX,
original = plan;
let moved = false;
drag(
e,
(event) => {
moved = true;
const start = Math.round(
clamp(
snapped(s.start / SECOND + (event.clientX - x) / pps) * SECOND,
0,
timeline.duration - (s.end - s.start),
),
);
onChange({
...original,
captions: captions.map((c) =>
c.id === s.id ? { ...c, start, end: start + s.end - s.start } : c,
),
});
},
() => {
if (moved) {
undo.current.push(original);
redo.current = [];
}
},
);
}}
>
{s.text}
{captionId === s.id && (
<>
<div
className="se-handle left"
role="slider"
aria-label="字幕起点"
aria-valuenow={s.start / SECOND}
onPointerDown={(e) => dragCaptionEdge(e, s, "start")}
/>
<div
className="se-handle right"
role="slider"
aria-label="字幕终点"
aria-valuenow={s.end / SECOND}
onPointerDown={(e) => dragCaptionEdge(e, s, "end")}
/>
</>
)}
</div>
))}
</div>
)}
<div className="se-video-track" aria-label="视频时间轴">
{timeline.clips.map((c, i) => {
const asset = plan.assets.find((a) => a.id === c.assetId)!,
width = (c.duration / SECOND) * pps;
return (
<div
key={c.id}
role="button"
tabIndex={0}
aria-label={`视频片段 ${i + 1}:${asset.name}`}
className={`se-clip ${selected === c.id ? "selected" : ""}`}
style={{
left: (c.start / SECOND) * pps,
width,
transform:
draggedClip?.id === c.id
? `translateX(${draggedClip.offset}px)`
: undefined,
zIndex: draggedClip?.id === c.id ? 8 : undefined,
opacity: draggedClip?.id === c.id ? 0.8 : 1,
}}
onPointerDown={(e) => dragClip(e, i)}
onClick={() => {
setSelected(c.id);
setPanel("video");
setCaptionId(undefined);
}}
onContextMenu={(e) => {
setSelected(c.id);
setPanel("video");
openContext(e, "clip");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
setSelected(c.id);
setPanel("video");
}
}}
>
<div className="se-filmstrip">
{Array.from({ length: Math.min(24, Math.ceil(width / 88)) }, (_, j) => (
<Thumb
key={j}
url={asset.url}
width={width / Math.min(24, Math.ceil(width / 88))}
time={Math.min(
c.sourceOut / SECOND - 0.01,
c.sourceIn / SECOND +
(j * (c.sourceOut - c.sourceIn)) /
SECOND /
Math.min(24, Math.ceil(width / 88)),
)}
/>
))}
</div>
<div className="se-clip-name">
{asset.name} {frameStamp(c.duration / SECOND)}
</div>
<div className={`se-wave ${c.gain ? "" : "muted"}`} />
{selected === c.id && (
<>
<div
className="se-handle left"
role="slider"
aria-label="片段起点"
onPointerDown={(e) => dragTrim(e, i, "left")}
/>
<div
className="se-handle right"
role="slider"
aria-label="片段终点"
onPointerDown={(e) => dragTrim(e, i, "right")}
/>
</>
)}
</div>
);
})}
{timeline.clips.slice(1).map(
(c, i) =>
(transitionsOpen || c.overlap > 0) && (
<button
key={c.id}
className="se-junction"
aria-label={`片段 ${i + 1} 与 ${i + 2} 的转场`}
style={{ left: ((c.start + c.overlap / 2) / SECOND) * pps }}
onClick={() => {
setTransitionIndex(i + 1);
setTransitionsOpen(true);
setPanel("transition");
}}
>
<TransitionIcon />
</button>
),
)}
</div>
<div className="se-playhead" aria-label="播放头" style={{ left: time * pps }} />
</div>
</div>
</div>
</div>
{context && (
<div
className="se-context"
role="menu"
style={{ left: context.x, top: context.y }}
onClick={(e) => e.stopPropagation()}
>
{context.kind !== "timeline" && (
<button role="menuitem" onClick={remove}>
删除{context.kind === "clip" ? "片段" : "字幕"}
</button>
)}
<button
role="menuitem"
disabled={!undo.current.length}
onClick={() => {
history(false);
setContext(undefined);
}}
>
撤销
</button>
<button
role="menuitem"
disabled={!redo.current.length}
onClick={() => {
history(true);
setContext(undefined);
}}
>
重做
</button>
<button role="menuitem" onClick={onConfigure}>
智能剪辑设置
</button>
</div>
)}
</div>
);
}
function StyleNumber({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (v: number) => void;
}) {
return (
<div className="se-style-row">
<label>{label}</label>
<div className="se-style-controls">
<input
aria-label={`${label}滑块`}
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<input
aria-label={label}
type="number"
min={min}
max={max}
value={value}
onChange={(e) => {
if (Number.isFinite(e.target.valueAsNumber))
onChange(clamp(e.target.valueAsNumber, min, max));
}}
/>
</div>
</div>
);
}
function StyleColor({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="se-style-row">
<label>{label}</label>
<div className="se-style-controls">
<input
className="se-color"
aria-label={label}
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<input
aria-label={`${label}十六进制`}
type="text"
maxLength={7}
defaultValue={value.toUpperCase().slice(1)}
key={value}
onBlur={(e) => {
const v = `#${e.target.value.replace(/^#/, "")}`;
if (/^#[0-9a-f]{6}$/i.test(v)) onChange(v);
else e.target.value = value.slice(1).toUpperCase();
}}
/>
</div>
</div>
);
}
function ValueField({
label,
suffix,
value,
min,
max,
precision,
onChange,
}: {
label: string;
suffix: string;
value: number;
min: number;
max: number;
precision: number;
onChange: (v: number) => void;
}) {
const [text, setText] = useState(value.toFixed(precision));
useEffect(() => setText(value.toFixed(precision)), [value, precision]);
const commit = () => {
const v = Number(text);
if (text.trim() && Number.isFinite(v)) onChange(clamp(v, min, max));
else setText(value.toFixed(precision));
};
return (
<div className="se-field">
<label htmlFor={`se-value-${suffix}`}>{label}</label>
<div className="se-value">
<input
id={`se-value-${suffix}`}
aria-label={label}
type="number"
min={min}
max={max}
step={10 ** -precision}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
<span>{suffix}</span>
<div className="se-number-steps">
<button
type="button"
aria-label={`增加${label}`}
onClick={() =>
onChange(clamp(value + (suffix === "dB" ? 1 : suffix === "x" ? 0.05 : 0.1), min, max))
}
>
⌃
</button>
<button
type="button"
aria-label={`减少${label}`}
onClick={() =>
onChange(clamp(value - (suffix === "dB" ? 1 : suffix === "x" ? 0.05 : 0.1), min, max))
}
>
⌄
</button>
</div>
</div>
</div>
);
}
function CutIcon({ side }: { side?: "left" | "right" }) {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2.75 2.22h3.44v11.56H2.75"
stroke="currentColor"
strokeWidth="1.06"
strokeDasharray={side === "left" ? "1.05 .7" : undefined}
/>
<path
d="M13.25 2.22H9.8v11.56h3.45"
stroke="currentColor"
strokeWidth="1.06"
strokeDasharray={side === "right" ? "1.05 .7" : undefined}
/>
</svg>
);
}
function TransitionIcon() {
return (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect
x=".9"
y=".9"
width="18.2"
height="18.2"
rx="2.53"
stroke="currentColor"
strokeWidth="1.8"
/>
<path d="M7.3 5.7 14.5 10l-7.2 4.3Z" fill="currentColor" />
</svg>
);
}
export function SmartEditEmptyEditor({
onClose,
onConfigure,
onRetry,
loading,
message,
}: {
onClose: () => void;
onConfigure: () => void;
onRetry: () => void;
loading: boolean;
message: string;
}) {
return (
<div className={styles.root} role="dialog" aria-modal="true" aria-label="智能剪辑">
<header className="se-header">
<span className="se-title">智能剪辑</span>
<div className="se-actions">
<button className="se-export" disabled>
导出
<ChevronDown />
</button>
<Icon title="退出剪辑" extra="se-exit" onClick={onClose}>
<X />
</Icon>
</div>
</header>
<div className="se-body">
<div className="se-preview">
<div className="se-empty-start" role="status">
<p>{message}</p>
{!loading && (
<div>
<button className="se-wide-button" onClick={onConfigure}>
添加素材
</button>
{/失败|错误|中断/.test(message) && (
<button className="se-wide-button" onClick={onRetry}>
重试读取素材
</button>
)}
</div>
)}
</div>
</div>
</div>
<div className="se-timeline" style={{ height: "calc(35vh - 8px)" }}>
<div className="se-toolbar">
<div className="se-toolbar-group">
{["撤销", "重做", "分割", "向左裁剪", "向右裁剪", "字幕轨道", "转场"].map(
(title, i) => (
<Icon
key={title}
title={title}
extra={i < 2 ? "se-history" : ""}
disabled
onClick={() => {}}
>
{i === 0 ? (
<Undo2 />
) : i === 1 ? (
<Redo2 />
) : i < 5 ? (
<CutIcon side={i === 3 ? "left" : i === 4 ? "right" : undefined} />
) : i === 5 ? (
<Type />
) : (
<TransitionIcon />
)}
</Icon>
),
)}
</div>
<div className="se-toolbar-middle">
<span className="se-current-time">00:00</span>
<Icon title="播放" extra="se-play" disabled onClick={() => {}}>
<Play fill="currentColor" />
</Icon>
<span className="se-duration">00:00</span>
<Icon title="全屏预览" disabled onClick={() => {}}>
<Maximize />
</Icon>
</div>
<div className="se-toolbar-group">
<Icon title="吸附" disabled onClick={() => {}}>
<Magnet />
</Icon>
<Icon title="缩小" disabled onClick={() => {}}>
<ZoomOut />
</Icon>
<input
className="se-zoom"
aria-label="时间轴缩放"
type="range"
value={20}
min={0}
max={40}
disabled
/>
<Icon title="放大" disabled onClick={() => {}}>
<ZoomIn />
</Icon>
<Icon title="收起预览" disabled onClick={() => {}}>
<Shrink />
</Icon>
</div>
</div>
</div>
</div>
);
}