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