2397 lines
82 KiB
TypeScript
2397 lines
82 KiB
TypeScript
"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();
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
}
|