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

2014 lines
69 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,
type AudioClip,
defaultVideoTransform,
} 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" | "audio" | "caption" | "transition" | null>(null),
[audioId, setAudioId] = useState<string>(),
[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" | "audio" | "caption" | "timeline";
}>();
const root = useRef<HTMLDivElement>(null),
preview = useRef<HTMLDivElement>(null),
scroll = useRef<HTMLDivElement>(null),
trackLabels = 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],
audioClip = plan.audioClips?.find((c) => c.id === audioId),
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 changeAudio = (id: string, patch: Partial<AudioClip>) =>
apply({
...plan,
audioClips: (plan.audioClips || []).map((c) => (c.id === id ? { ...c, ...patch } : c)),
});
const detachAudio = () => {
if (!clip || clip.audioDetached || !plan.assets.find((a) => a.id === clip.assetId)?.hasAudio)
return;
if ((plan.audioClips?.length || 0) >= 24) {
setError("最多24个独立音频片段");
return;
}
const id = crypto.randomUUID(),
t = timeline.clips[clipIndex];
apply({
...plan,
clips: plan.clips.map((c) => (c.id === clip.id ? { ...c, audioDetached: true } : c)),
audioClips: [
...(plan.audioClips || []),
{
id,
assetId: clip.assetId,
sourceIn: clip.sourceIn,
sourceOut: clip.sourceOut,
speed: clip.speed,
gain: clip.gain,
start: t.start,
fadeIn: t.overlap / SECOND,
fadeOut: (timeline.clips[clipIndex + 1]?.overlap || 0) / SECOND,
},
],
});
setSelected(undefined);
setAudioId(id);
setPanel("audio");
setContext(undefined);
};
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]),
...timeline.audioClips.flatMap((c) => [c.start / SECOND, c.end / SECOND]),
];
return values.find((v) => Math.abs(v - seconds) * pps < 8) ?? seconds;
};
const [, setHistoryRevision] = useState(0);
const dragCleanup = useRef<() => void>();
useEffect(() => () => dragCleanup.current?.(), []);
const drag = (
e: PointerEvent,
move: (event: globalThis.PointerEvent) => void,
finish?: () => void,
) => {
e.preventDefault();
e.stopPropagation();
dragCleanup.current?.();
const original = latest.current;
const el = e.currentTarget as HTMLElement;
el.setPointerCapture(e.pointerId);
const cleanup = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
window.removeEventListener("pointercancel", cancel);
dragCleanup.current = undefined;
};
const end = () => {
cleanup();
finish?.();
setHistoryRevision((value) => value + 1);
};
const cancel = () => {
setDraggedClip(undefined);
if (latest.current !== original) onChange(original);
cleanup();
};
dragCleanup.current = cleanup;
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 dragAudio = (e: PointerEvent, c: AudioClip, side?: "left" | "right") => {
const original = plan,
x = e.clientX,
asset = plan.assets.find((a) => a.id === c.assetId)!;
setAudioId(c.id);
setSelected(undefined);
setPanel("audio");
setPlaying(false);
drag(
e,
(event) => {
const delta = ((event.clientX - x) / pps) * SECOND;
let patch: Partial<AudioClip>;
if (side === "left") {
const sourceIn = Math.round(
clamp(
c.sourceIn + delta * c.speed,
Math.max(0, c.sourceIn - c.start * c.speed),
c.sourceOut - 200000,
),
);
patch = { sourceIn, start: Math.round(c.start + (sourceIn - c.sourceIn) / c.speed) };
} else if (side === "right")
patch = {
sourceOut: Math.round(
clamp(
c.sourceOut + delta * c.speed,
c.sourceIn + 200000,
Math.min(asset.duration, c.sourceIn + (300 * SECOND - c.start) * c.speed),
),
),
};
else
patch = {
start: Math.round(
clamp(
snapped((c.start + delta) / SECOND) * SECOND,
0,
300 * SECOND - (c.sourceOut - c.sourceIn) / c.speed,
),
),
};
const next = {
...original,
audioClips: original.audioClips!.map((a) => (a.id === c.id ? { ...a, ...patch } : a)),
};
try {
planTimeline(next);
onChange(next);
} catch {}
},
() => {
undo.current.push(original);
redo.current = [];
},
);
};
const audioCutPoint = audioClip
? Math.round(audioClip.sourceIn + (time * SECOND - audioClip.start) * audioClip.speed)
: 0;
const canCutAudio = Boolean(
panel === "audio" &&
audioClip &&
audioCutPoint - audioClip.sourceIn >= 200000 &&
audioClip.sourceOut - audioCutPoint >= 200000,
);
const cutPoint = clip
? Math.round(clip.sourceIn + (time * SECOND - timeline.clips[clipIndex].start) * clip.speed)
: 0;
const canCut =
canCutAudio ||
Boolean(clip && cutPoint - clip.sourceIn >= 200000 && clip.sourceOut - cutPoint >= 200000);
const cut = (kind: "split" | "left" | "right") => {
if (panel === "audio" && audioClip && canCutAudio) {
if (kind === "split") {
if ((plan.audioClips?.length || 0) >= 24) {
setError("最多24个独立音频片段");
return;
}
apply({
...plan,
audioClips: plan.audioClips!.flatMap((c) =>
c.id !== audioClip.id
? [c]
: [
{ ...c, sourceOut: audioCutPoint, fadeOut: 0 },
{
...c,
id: crypto.randomUUID(),
sourceIn: audioCutPoint,
start: Math.round(time * SECOND),
fadeIn: 0,
},
],
),
});
} else
changeAudio(
audioClip.id,
kind === "left"
? { sourceIn: audioCutPoint, start: Math.round(time * SECOND) }
: { sourceOut: audioCutPoint },
);
return;
}
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 === "audio" && audioClip) {
apply({ ...plan, audioClips: plan.audioClips!.filter((c) => c.id !== audioClip.id) });
setAudioId(undefined);
setPanel(null);
} else 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" | "audio" | "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
selectedClipId={selected}
selectedCaptionId={captionId}
onSelectClip={(id) => {
setSelected(id);
setPanel("video");
setPlaying(false);
}}
onSelectCaption={(id) => {
setCaptionId(id);
setPanel("caption");
setSubTab("text");
setCaptionsOpen(true);
setPlaying(false);
}}
onTransform={(id, transform) =>
changeClip(
plan.clips.findIndex((c) => c.id === id),
{ transform },
)
}
onCaptionPosition={(id, position) => {
const c = captions.find((c) => c.id === id);
if (c)
changeCaption(id, {
style: { ...(c.style || defaultCaptionStyle), ...position },
});
}}
/>
</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">
{clip.transform && (
<button
className="se-reset-transform"
onClick={() => changeClip(clipIndex, { transform: defaultVideoTransform })}
>
重置画面位置与缩放
</button>
)}
<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 === "audio" && audioClip && (
<aside className="se-panel" aria-label="音频参数" style={{ width: panelWidth }}>
<div className="se-panel-head">
音频
<Icon title="关闭音频参数" onClick={() => setPanel(null)}>
<X />
</Icon>
</div>
<div className="se-panel-scroll">
<h3>变速</h3>
<ValueField
label="音频变速倍数"
suffix="ax"
value={audioClip.speed}
min={0.5}
max={2}
precision={2}
onChange={(speed) => changeAudio(audioClip.id, { speed })}
/>
<div className="se-divider" />
<h3>音量</h3>
<ValueField
label="音频音量增益"
suffix="adB"
value={audioClip.gain ? 20 * Math.log10(audioClip.gain) : -60}
min={-60}
max={0}
precision={1}
onChange={(v) =>
changeAudio(audioClip.id, { gain: v <= -60 ? 0 : 10 ** (v / 20) })
}
/>
<div className="se-divider" />
<h3>淡入淡出</h3>
<ValueField
label="淡入时长"
suffix="in"
value={audioClip.fadeIn}
min={0}
max={Math.min(
5,
(audioClip.sourceOut - audioClip.sourceIn) / audioClip.speed / SECOND,
)}
precision={1}
onChange={(fadeIn) => changeAudio(audioClip.id, { fadeIn })}
/>
<ValueField
label="淡出时长"
suffix="out"
value={audioClip.fadeOut}
min={0}
max={Math.min(
5,
(audioClip.sourceOut - audioClip.sourceIn) / audioClip.speed / SECOND,
)}
precision={1}
onChange={(fadeOut) => changeAudio(audioClip.id, { fadeOut })}
/>
</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 ||
(panel === "audio" ? (plan.audioClips?.length || 0) >= 24 : 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" ref={trackLabels}>
{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>
{(plan.audioClips || []).map((c) => (
<div className="se-track-label audio" key={c.id}>
<Volume2 size={14} />
<Icon
title={c.gain ? "静音独立音轨" : "恢复独立音轨"}
onClick={() => changeAudio(c.id, { gain: c.gain ? 0 : 1 })}
>
{c.gain ? <Volume2 /> : <VolumeX />}
</Icon>
</div>
))}
</div>
<div
className="se-scroll"
ref={scroll}
onScroll={(e) => {
if (trackLabels.current) trackLabels.current.scrollTop = e.currentTarget.scrollTop;
}}
>
<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>
{timeline.audioClips.map((c) => (
<div className="se-audio-track" key={c.id} aria-label="独立音频时间轴">
<div
role="button"
tabIndex={0}
aria-label={`独立音频:${plan.assets.find((a) => a.id === c.assetId)?.name}`}
className={`se-audio-clip ${audioId === c.id ? "selected" : ""}`}
style={{ left: (c.start / SECOND) * pps, width: (c.duration / SECOND) * pps }}
onPointerDown={(e) => dragAudio(e, c)}
onClick={() => {
setAudioId(c.id);
setSelected(undefined);
setPanel("audio");
}}
onContextMenu={(e) => {
setAudioId(c.id);
setSelected(undefined);
setPanel("audio");
openContext(e, "audio");
}}
>
<span>
{plan.assets.find((a) => a.id === c.assetId)?.name}{" "}
{frameStamp(c.duration / SECOND)}
</span>
{audioId === c.id && (
<>
<div
role="slider"
aria-label="音频起点"
className="se-handle left"
onPointerDown={(e) => dragAudio(e, c, "left")}
/>
<div
role="slider"
aria-label="音频终点"
className="se-handle right"
onPointerDown={(e) => dragAudio(e, c, "right")}
/>
</>
)}
</div>
</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 === "clip" && (
<button
role="menuitem"
disabled={
!clip ||
clip.audioDetached ||
!plan.assets.find((a) => a.id === clip.assetId)?.hasAudio
}
title={
!plan.assets.find((a) => a.id === clip?.assetId)?.hasAudio
? "当前视频无音轨,无法分离音视频"
: undefined
}
onClick={detachAudio}
>
音视频分离
</button>
)}
{context.kind !== "timeline" && (
<button role="menuitem" onClick={remove}>
删除{context.kind === "clip" ? "片段" : context.kind === "audio" ? "音频" : "字幕"}
</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 === "ax"
? "x"
: suffix === "adB"
? "dB"
: suffix === "in" || suffix === "out"
? "s"
: suffix}
</span>
<div className="se-number-steps">
<button
type="button"
aria-label={`增加${label}`}
onClick={() =>
onChange(
clamp(
value + (suffix.endsWith("dB") ? 1 : suffix.endsWith("x") ? 0.05 : 0.1),
min,
max,
),
)
}
>
⌃
</button>
<button
type="button"
aria-label={`减少${label}`}
onClick={() =>
onChange(
clamp(
value - (suffix.endsWith("dB") ? 1 : suffix.endsWith("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>
);
}