2397 lines
82 KiB
TypeScript
Raw Permalink 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,
Trash2,
} from "lucide-react";
import { CaptionFontPicker } from "./CaptionFonts";
import { EditPreview } from "./EditPreview";
import { TransitionPreview } from "./TransitionPreview";
import styles from "./SmartEditEditor.module.css";
import {
planTimeline,
editableCaptions,
defaultCaptionStyle,
newCaptionStyle,
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,
onCancel,
onProcess,
canSeparate = false,
canTranscribe = false,
processError,
tasks,
canRender,
exportState,
onExportState,
}: {
plan: EditPlan;
onChange: (p: EditPlan) => void;
onClose: () => void;
onConfigure: () => void;
onRender: (p: EditPlan) => Promise<unknown>;
onCancel?: (taskId: string) => Promise<unknown>;
onProcess?: (
operation: "separate" | "transcribe",
plan: EditPlan,
clipId?: string,
track?: "video" | "audio",
keep?: "both" | "vocals" | "background",
) => Promise<unknown>;
canSeparate?: boolean;
canTranscribe?: boolean;
processError?: string;
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((100 - 2) / 4.98),
[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);
useEffect(() => {
const resize = () => {
setViewportWidth(window.innerWidth);
setTimelineHeight((height) => Math.min(height, Math.max(130, window.innerHeight - 220)));
};
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 = 2 + 4.98 * zoom,
tickSeconds = [1, 2, 5, 10, 30, 60, 120].find((step) => step * pps >= 60) || 120,
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,
captionUnit = 1080 / (captionStyle.referenceHeight || 720);
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)),
});
useEffect(() => {
if (processError) setError(processError);
}, [processError]);
const [separationMenu, setSeparationMenu] = useState(false);
useEffect(() => setSeparationMenu(false), [context]);
const processAudio = async (
operation: "separate" | "transcribe",
keep: "both" | "vocals" | "background" = "both",
) => {
if (!onProcess || busy) return;
setBusy(true);
setPlaying(false);
setContext(undefined);
setError("");
try {
await onProcess(
operation,
plan,
context?.kind === "audio" ? audioClip?.id : clip?.id,
context?.kind === "audio" ? "audio" : "video",
keep,
);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
};
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],
next = timeline.clips[clipIndex + 1];
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,
muted: clip.muted,
start: t.start,
fadeIn: (t.overlap - t.padBefore) / SECOND,
fadeInStart: t.overlap ? t.padBefore / t.overlap : 0,
fadeOut: next ? (next.overlap - t.padAfter) / SECOND : 0,
fadeOutEnd: next?.overlap ? t.padAfter / next.overlap : 0,
},
],
});
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 (collapsed && e.key !== "Escape" && !root.current?.contains(e.target as Node)) return;
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);
setPlaying(false);
onClose();
}
};
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 || "导出失败,请重试");
if (pending?.status === "cancelled") {
setError("导出已取消");
onExportState(undefined);
}
}, [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 = (after?: TimelineCaption) => {
if (captions.length >= 500) {
setError("字幕最多 500 条");
return;
}
if (after && after.end >= timeline.duration) {
setError("这条字幕已到片尾,请先缩短它或延长视频,再在后面新增字幕");
return;
}
const start = after
? after.end
: 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,
...captions.filter((c) => c.start > start).map((c) => c.start),
),
text: "默认文本",
style: { ...newCaptionStyle },
},
].sort((a, b) => a.start - b.start),
});
setCaptionId(id);
setTime(start / SECOND);
setSubTab(after ? "list" : "text");
setPanel("caption");
};
const transition = plan.clips[transitionIndex]?.transition;
const setTransition = (
type: "fade" | "fadeblack" | "fadewhite",
value = transition?.duration || 1,
) => {
if (!plan.clips[transitionIndex]) {
setError("至少需要两个片段才能添加转场");
return;
}
changeClip(transitionIndex, { transition: { type, duration: value, alignment: "center" } });
};
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} ${collapsed ? styles.collapsed : ""}`}
role="dialog"
aria-modal={!collapsed}
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" hidden={collapsed}>
<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>
)}
{
<div className="se-body" style={collapsed ? { display: "none" } : undefined}>
{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 se-library-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">
<img
src={`/video-canvas/transitions/${type}.webp`}
alt={name}
draggable={false}
/>
</span>
<span className="se-transition-caption">{name}</span>
</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 ||
"正在导出,任务已保存,可返回画布继续工作"}
{pending?.status === "pending" && onCancel && (
<button
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await onCancel(pending.id);
onExportState(undefined);
} catch (e) {
setError(e instanceof Error ? e.message : "取消失败");
} finally {
setBusy(false);
}
}}
>
取消排队
</button>
)}
</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={12}
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-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="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={12}
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, fadeInStart: 0 })}
/>
<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, fadeOutEnd: 0 })}
/>
</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 se-transition-settings">
<div className="se-small-label">名称</div>
<div className="se-transition-name">
{transition?.duration ? transitionNames[transition.type] : "未选择转场"}
</div>
<label className="se-small-label" htmlFor="se-transition-length">
时长
</label>
<TransitionDuration
value={transition?.duration || 0}
disabled={!transition?.duration}
onChange={(value) => setTransition(transition?.type || "fade", value)}
/>
{transition?.duration && transitionIndex > 0 ? (
<TransitionPreview plan={plan} index={transitionIndex} cover />
) : (
<div className="se-transition-placeholder">
选择片段接点和转场,预览实际效果
</div>
)}
{transition?.duration &&
Math.abs(
timeline.clips[transitionIndex].overlap / SECOND - transition.duration,
) > 0.04 ? (
<p className="se-muted">
相邻片段较短,实际转场时长为{" "}
{(timeline.clips[transitionIndex].overlap / SECOND).toFixed(1)} 秒
</p>
) : null}
</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>
<button
type="button"
disabled={busy || !canTranscribe || !onProcess}
title={
canTranscribe
? "识别视频人声并添加字幕,保留手动字幕"
: "语音识别模型尚未配置"
}
onClick={() => void processAudio("transcribe")}
>
自动转写
</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">
<CaptionTime
label="字幕开始时间"
value={s.start}
min={0}
max={s.end - 100000}
onChange={(start) => changeCaption(s.id, { start })}
/>
—
<CaptionTime
label="字幕结束时间"
value={s.end}
min={s.start + 100000}
max={timeline.duration}
onChange={(end) => changeCaption(s.id, { end })}
/>
<button
title="在后面新增字幕"
aria-label="在后面新增字幕"
onClick={(e) => {
e.stopPropagation();
addCaption(s);
}}
>
<Plus size={14} />
</button>
<button
title="删除字幕"
aria-label="删除字幕"
onClick={(e) => {
e.stopPropagation();
apply({
...plan,
captions: captions.filter((c) => c.id !== s.id),
});
if (captionId === s.id) setCaptionId(undefined);
}}
>
<Trash2 size={14} />
</button>
</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>
<CaptionFontPicker
value={captionStyle.fontFamily}
onChange={(fontFamily) => changeStyle({ fontFamily })}
onError={setError}
/>
</div>
<StyleNumber
label="字号"
value={Math.round(captionStyle.fontSize * captionUnit)}
min={12}
max={Math.max(72, Math.round(captionStyle.fontSize * captionUnit))}
onChange={(v) => changeStyle({ fontSize: v / captionUnit })}
/>
<StyleColor
label="字体颜色"
value={captionStyle.color}
onChange={(color) => changeStyle({ color })}
/>
<StyleNumber
label="字间距"
value={captionStyle.spacing * captionUnit}
min={Math.min(0, captionStyle.spacing * captionUnit)}
max={100}
onChange={(spacing) => changeStyle({ spacing: spacing / captionUnit })}
/>
<div className="se-style-row">
<label>预设样式</label>
<div className="se-presets">
{[
["默认", "#ffffff", "#ffffff", 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-pressed={
captionStyle.color === color &&
captionStyle.outlineColor === outlineColor &&
captionStyle.outlineWidth === outlineWidth
}
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),
})
}
>
{name === "默认" ? "默认" : "T"}
</button>
))}
</div>
</div>
<StyleColor
label="描边颜色"
value={captionStyle.outlineColor}
onChange={(outlineColor) => changeStyle({ outlineColor })}
/>
<StyleNumber
label="描边粗细"
value={captionStyle.outlineWidth * captionUnit}
min={0}
max={20}
onChange={(outlineWidth) =>
changeStyle({ outlineWidth: outlineWidth / captionUnit })
}
/>
<StyleNumber
label="描边不透明度"
value={Math.round((captionStyle.outlineOpacity ?? 1) * 100)}
min={0}
max={100}
onChange={(v) => changeStyle({ outlineOpacity: 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: timelineHeight,
}}
onContextMenu={(e) => openContext(e, "timeline")}
>
<div
className="se-resizer"
role="separator"
aria-label="拖拽调整高度"
aria-orientation="horizontal"
onPointerDown={(e) => {
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");
setPlaying(false);
}}
>
<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="100"
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
/>
<Icon title="放大" onClick={() => setZoom((v) => Math.min(100, v + 2))}>
<ZoomIn />
</Icon>
<Icon
title={collapsed ? "展开预览" : "收起预览"}
onClick={() => {
setPlaying(false);
setCollapsed((v) => !v);
}}
>
{collapsed ? <Expand /> : <Shrink />}
</Icon>
{collapsed && (
<Icon title="关闭智能剪辑" onClick={onClose}>
<X />
</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.muted && !c.audioDetached && c.gain > 0)
? "静音视频原声"
: "恢复视频原声"
}
onClick={() =>
apply({
...plan,
clips: plan.clips.map((c) => ({
...c,
muted: plan.clips.some((c) => !c.muted && !c.audioDetached && c.gain > 0),
})),
})
}
>
{plan.clips.some((c) => !c.muted && !c.audioDetached && 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.muted ? "静音独立音轨" : "恢复独立音轨"}
onClick={() => changeAudio(c.id, { muted: !c.muted })}
>
{!c.muted ? <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) / tickSeconds) * 10,
),
},
(_, i) => (
<div
key={i}
className={`se-tick ${i % 10 === 0 ? "major" : ""}`}
style={{ left: (i * pps * tickSeconds) / 10 }}
>
{i % 10 === 0 && <span>{stamp((i * tickSeconds) / 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.muted && 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 ${c.overlap ? "has-transition" : ""}`}
aria-pressed={panel === "transition" && transitionIndex === i + 1}
aria-label={`片段 ${i + 1} 与 ${i + 2} 的转场`}
style={{
left: ((c.transitionStart + c.overlap / 2) / SECOND) * pps,
width: c.overlap ? Math.max(44, (c.overlap / SECOND) * pps) : 24,
}}
onContextMenu={(e) => {
const at = position(e.clientX) * SECOND;
const underlying =
timeline.clips.find((part) => at >= part.start && at < part.end) ||
c;
setSelected(underlying.id);
setPanel("video");
openContext(e, "clip");
}}
title={
c.overlap
? `${transitionNames[c.transitionType]} ${(c.overlap / SECOND).toFixed(1)}s`
: "添加转场"
}
onClick={() => {
setTransitionIndex(i + 1);
setPlaying(false);
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 === "clip" || context.kind === "audio") && (
<div className="se-separation-group">
<button
role="menuitem"
aria-haspopup="menu"
aria-expanded={separationMenu}
disabled={
busy ||
!canSeparate ||
!onProcess ||
(context.kind === "audio"
? !audioClip
: !clip ||
clip.audioDetached ||
!plan.assets.find((a) => a.id === clip.assetId)?.hasAudio)
}
title={
canSeparate ? "生成可单独调节的人声和背景声音轨" : "人声分离模型尚未配置"
}
onClick={() => setSeparationMenu(!separationMenu)}
>
人声分离 <span aria-hidden="true">›</span>
</button>
{separationMenu && (
<div
role="menu"
aria-label="人声分离选项"
className="se-separation-menu"
style={
context.x < 170 ? { left: "calc(100% + 6px)", right: "auto" } : undefined
}
>
<button
role="menuitem"
onClick={() => void processAudio("separate", "vocals")}
>
仅保留人声
</button>
<button
role="menuitem"
onClick={() => void processAudio("separate", "background")}
>
仅保留背景音
</button>
<button role="menuitem" onClick={() => void processAudio("separate", "both")}>
分离为两个音轨
</button>
</div>
)}
</div>
)}
{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;
}) {
const [draft, setDraft] = useState(String(Number(value.toFixed(2))));
useEffect(() => setDraft(String(Number(value.toFixed(2)))), [value]);
const commit = () => {
const n = Number(draft),
next = draft.trim() && Number.isFinite(n) ? clamp(n, min, max) : value;
setDraft(String(Number(next.toFixed(2))));
if (next !== value) onChange(next);
};
const spacing = label === "字间距";
return (
<div className="se-style-row">
<label>{label}</label>
<div className="se-style-controls">
{!spacing && (
<input
aria-label={`${label}滑块`}
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
)}
<div className={`se-style-number ${spacing ? "spacing" : ""}`}>
{spacing && <span>X</span>}
<input
aria-label={label}
type="number"
min={min}
max={max}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
{!spacing && (
<>
<span>{label.includes("不透明度") ? "%" : "px"}</span>
<div className="se-style-steppers">
<button
aria-label={`增加${label}`}
onClick={() => onChange(clamp(value + 1, min, max))}
>
⌃
</button>
<button
aria-label={`减少${label}`}
onClick={() => onChange(clamp(value - 1, min, max))}
>
⌄
</button>
</div>
</>
)}
</div>
</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 TransitionDuration({
value,
disabled,
onChange,
}: {
value: number;
disabled: boolean;
onChange: (value: number) => void;
}) {
const [text, setText] = useState(value.toFixed(1));
useEffect(() => setText(value.toFixed(1)), [value]);
const commit = () => {
const n = Number(text);
if (text.trim() && Number.isFinite(n)) {
const v = clamp(Math.round(n * 10) / 10, 0.2, 4);
setText(v.toFixed(1));
onChange(v);
} else setText(value.toFixed(1));
};
return (
<div className="se-transition-duration">
<button
aria-label="减少转场时长"
disabled={disabled || value <= 0.2}
onClick={() => onChange(Math.max(0.2, Math.round((value - 0.1) * 10) / 10))}
>
−
</button>
<div>
<input
id="se-transition-length"
aria-label="转场时长(秒)"
type="number"
min={0.2}
max={4}
step={0.1}
disabled={disabled}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
}}
/>
<span>s</span>
</div>
<button
aria-label="增加转场时长"
disabled={disabled || value >= 4}
onClick={() => onChange(Math.min(4, Math.round((value + 0.1) * 10) / 10))}
>
+
</button>
</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)) {
const bounded = clamp(v, min, max);
setText(bounded.toFixed(precision));
onChange(bounded);
} else setText(value.toFixed(precision));
};
const step = suffix.endsWith("dB") ? 0.5 : suffix.endsWith("x") ? 0.01 : 0.1;
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={step}
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 + step, min, max))}
>
⌃
</button>
<button
type="button"
aria-label={`减少${label}`}
onClick={() => onChange(clamp(value - step, 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">
<path
d="M16.57 0C18.47 0 20 1.54 20 3.43v13.14c0 1.9-1.54 3.43-3.43 3.43H3.25A3.43 3.43 0 0 1 0 16.57V3.43A3.43 3.43 0 0 1 3.25 0zM3.43 1.8c-.9 0-1.63.73-1.63 1.63v13.14c0 .9.73 1.63 1.63 1.63h13.14c.9 0 1.63-.73 1.63-1.63V3.43c0-.9-.73-1.63-1.63-1.63zm3.8 4.7a.86.86 0 0 1 1.29-.75l6.01 3.51a.86.86 0 0 1 0 1.48l-6.01 3.5a.86.86 0 0 1-1.29-.73z"
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>
);
}
function CaptionTime({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
const format = (v: number) =>
`${stamp(v / SECOND)}.${String(Math.floor((v % SECOND) / 10000)).padStart(2, "0")}`;
const [draft, setDraft] = useState(format(value));
const cancelled = useRef(false);
useEffect(() => setDraft(format(value)), [value]);
const commit = () => {
if (cancelled.current) {
cancelled.current = false;
setDraft(format(value));
return;
}
const match = draft.trim().match(/^(?:(\d+):)?(\d{1,2})(?:\.(\d{1,3}))?$/);
if (!match || (match[1] && Number(match[2]) >= 60)) {
setDraft(format(value));
return;
}
const next = clamp(
Math.round(
(Number(match[1] || 0) * 60 + Number(match[2]) + Number(`0.${match[3] || 0}`)) * SECOND,
),
min,
max,
);
setDraft(format(next));
if (next !== value) onChange(next);
};
return (
<input
aria-label={label}
inputMode="decimal"
value={draft}
onClick={(e) => e.stopPropagation()}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") {
cancelled.current = true;
setDraft(format(value));
e.currentTarget.blur();
}
}}
/>
);
}