ai-chatbot/components/canvas/VideoCanvasStoryboardCompositionEditor.tsx

948 lines
39 KiB
TypeScript

"use client";
import {
Check,
ChevronDown,
Download,
Eye,
EyeOff,
Gauge,
Link2,
Loader2,
PanelsTopLeft,
Maximize2,
Minus,
Play,
Plus,
Redo2,
Scissors,
TextCursorInput,
Trash2,
Undo2,
Video,
X,
ZoomIn,
ZoomOut,
} from "lucide-react";
import {
type DragEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import {
createVideoCanvasCompositionClip,
getVideoCanvasCompositionClipDuration,
getVideoCanvasCompositionClipStart,
getVideoCanvasCompositionDuration,
splitVideoCanvasCompositionClip,
type VideoCanvasStoryboardCompositionProject,
type VideoCanvasStoryboardCompositionTransition,
} from "@/lib/video-canvas/storyboard-composition";
import type {
VideoCanvasStoryboardCompositionItem,
VideoCanvasStoryboardItem,
} from "@/lib/video-canvas/storyboard-overview";
type CompositionEditorProps = {
composition: VideoCanvasStoryboardCompositionItem;
compositionItems: VideoCanvasStoryboardCompositionItem[];
exporting: boolean;
onChange: (project: VideoCanvasStoryboardCompositionProject) => void;
onClose: () => void;
onExport: (destination: "canvas" | "local") => void;
onSwitchComposition: (compositionId: string) => void;
project: VideoCanvasStoryboardCompositionProject;
videoItems: VideoCanvasStoryboardItem[];
};
const TRANSITION_OPTIONS: Array<{
label: string;
value: VideoCanvasStoryboardCompositionTransition;
}> = [
{ label: "淡入淡出", value: "fade" },
{ label: "黑场过渡", value: "fade-black" },
{ label: "白场过渡", value: "fade-white" },
];
const SPEED_OPTIONS = [0.5, 1, 1.5, 2];
const formatTime = (seconds: number) => {
const safeSeconds = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(safeSeconds / 60);
const remainder = safeSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
};
export function VideoCanvasStoryboardCompositionEditor({
composition,
compositionItems,
exporting,
onChange,
onClose,
onExport,
onSwitchComposition,
project,
videoItems,
}: CompositionEditorProps) {
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const compositionMenuRef = useRef<HTMLDivElement | null>(null);
const [selectedClipId, setSelectedClipId] = useState<string | null>(null);
const [selectedSubtitleId, setSelectedSubtitleId] = useState<string | null>(null);
const [playheadSeconds, setPlayheadSeconds] = useState(0);
const [undoStack, setUndoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
const [redoStack, setRedoStack] = useState<VideoCanvasStoryboardCompositionProject[]>([]);
const [subtitlePanelOpen, setSubtitlePanelOpen] = useState(false);
const [subtitleTab, setSubtitleTab] = useState<"subtitle" | "text">("subtitle");
const [transitionPanelOpen, setTransitionPanelOpen] = useState(false);
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const [compositionMenuOpen, setCompositionMenuOpen] = useState(false);
const [previewCollapsed, setPreviewCollapsed] = useState(false);
const [previewFullscreen, setPreviewFullscreen] = useState(false);
const [snapEnabled, setSnapEnabled] = useState(true);
const [subtitleTrackVisible, setSubtitleTrackVisible] = useState(true);
const [timelineZoom, setTimelineZoom] = useState(20);
const totalDuration = getVideoCanvasCompositionDuration(project);
const timelineDuration = Math.max(totalDuration, 10);
const selectedClip = project.clips.find((clip) => clip.id === selectedClipId) || null;
const selectedSubtitle =
project.subtitles.find((subtitle) => subtitle.id === selectedSubtitleId) || null;
const previewClip = selectedClip || project.clips[0] || null;
useEffect(() => {
setSelectedClipId(null);
setSelectedSubtitleId(null);
setPlayheadSeconds(0);
setUndoStack([]);
setRedoStack([]);
setSubtitlePanelOpen(false);
setTransitionPanelOpen(false);
setSpeedMenuOpen(false);
setExportMenuOpen(false);
setCompositionMenuOpen(false);
setPreviewCollapsed(false);
}, [composition.id]);
useEffect(() => {
if (selectedClipId && !selectedClip) setSelectedClipId(null);
}, [selectedClip, selectedClipId]);
useEffect(() => {
if (selectedSubtitleId && !selectedSubtitle) setSelectedSubtitleId(null);
}, [selectedSubtitle, selectedSubtitleId]);
useEffect(() => {
setPlayheadSeconds((current) => Math.min(current, totalDuration));
}, [totalDuration]);
useEffect(() => {
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (previewFullscreen) {
setPreviewFullscreen(false);
return;
}
if (exportMenuOpen || speedMenuOpen) {
setExportMenuOpen(false);
setSpeedMenuOpen(false);
}
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [exportMenuOpen, previewFullscreen, speedMenuOpen]);
useEffect(() => {
if (!compositionMenuOpen) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!compositionMenuRef.current?.contains(event.target)
) {
setCompositionMenuOpen(false);
}
};
document.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () =>
document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
}, [compositionMenuOpen]);
const applyProject = useCallback(
(nextProject: VideoCanvasStoryboardCompositionProject) => {
if (nextProject === project) return;
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
setRedoStack([]);
onChange(nextProject);
},
[onChange, project],
);
const undo = useCallback(() => {
const previous = undoStack.at(-1);
if (!previous) return;
setUndoStack((current) => current.slice(0, -1));
setRedoStack((current) => [...current.slice(-49), structuredClone(project)]);
onChange(previous);
}, [onChange, project, undoStack]);
const redo = useCallback(() => {
const next = redoStack.at(-1);
if (!next) return;
setRedoStack((current) => current.slice(0, -1));
setUndoStack((current) => [...current.slice(-49), structuredClone(project)]);
onChange(next);
}, [onChange, project, redoStack]);
const addVideoItem = useCallback(
(item: VideoCanvasStoryboardItem) => {
if (!item.mediaUrl) return;
const clip = createVideoCanvasCompositionClip({
durationSeconds: item.durationSeconds || 5,
existingIds: project.clips.map((candidate) => candidate.id),
mediaUrl: item.mediaUrl,
nodeId: item.id,
title: item.title,
});
applyProject({ ...project, clips: [...project.clips, clip] });
setSelectedClipId(clip.id);
setPlayheadSeconds(totalDuration);
},
[applyProject, project, totalDuration],
);
const updateSelectedClip = useCallback(
(update: (clip: NonNullable<typeof selectedClip>) => NonNullable<typeof selectedClip>) => {
if (!selectedClip) return;
applyProject({
...project,
clips: project.clips.map((clip) =>
clip.id === selectedClip.id ? update(clip) : clip,
),
});
},
[applyProject, project, selectedClip],
);
const removeSelectedClip = useCallback(() => {
if (!selectedClip) return;
applyProject({
...project,
clips: project.clips.filter((clip) => clip.id !== selectedClip.id),
});
setSelectedClipId(null);
}, [applyProject, project, selectedClip]);
const trimSelectedClip = useCallback(
(side: "left" | "right") => {
if (!selectedClip) return;
const clipStart = getVideoCanvasCompositionClipStart(project, selectedClip.id);
if (clipStart === null) return;
const sourcePoint =
selectedClip.inPointSeconds +
Math.max(0, playheadSeconds - clipStart) * selectedClip.speed;
if (side === "left") {
if (sourcePoint >= selectedClip.outPointSeconds - 0.1) return;
updateSelectedClip((clip) => ({ ...clip, inPointSeconds: sourcePoint }));
} else {
if (sourcePoint <= selectedClip.inPointSeconds + 0.1) return;
updateSelectedClip((clip) => ({ ...clip, outPointSeconds: sourcePoint }));
}
},
[playheadSeconds, project, selectedClip, updateSelectedClip],
);
const splitSelectedClip = useCallback(() => {
if (!selectedClip) return;
const next = splitVideoCanvasCompositionClip(
project,
selectedClip.id,
playheadSeconds,
);
if (next !== project) applyProject(next);
}, [applyProject, playheadSeconds, project, selectedClip]);
const addSubtitle = useCallback(() => {
const id = `subtitle-${Date.now()}`;
const startSeconds = Math.min(playheadSeconds, totalDuration);
const subtitle = {
endSeconds: Math.min(Math.max(totalDuration, 2), startSeconds + 2),
id,
startSeconds,
text: "新建字幕",
};
applyProject({ ...project, subtitles: [...project.subtitles, subtitle] });
setSelectedSubtitleId(id);
setSubtitleTab("text");
}, [applyProject, playheadSeconds, project, totalDuration]);
const seekTimeline = (event: ReactPointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
if (!rect.width) return;
let next = ((event.clientX - rect.left) / rect.width) * timelineDuration;
if (snapEnabled) next = Math.round(next * 2) / 2;
setPlayheadSeconds(Math.min(totalDuration, Math.max(0, next)));
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const nodeId = event.dataTransfer.getData("application/x-video-canvas-node");
const item = videoItems.find((candidate) => candidate.id === nodeId);
if (item) addVideoItem(item);
};
const togglePlayback = async () => {
const video = previewVideoRef.current;
if (!video) return;
if (video.paused) await video.play().catch(() => undefined);
else video.pause();
};
const renderPreview = (fullscreen = false) => (
<div
className={cn(
"relative flex min-h-0 items-center justify-center overflow-hidden bg-[#1b1b1b]",
fullscreen && "fixed inset-0 z-[300] bg-black",
)}
>
{previewClip ? (
<video
className={cn(
"max-h-full max-w-full object-contain",
fullscreen ? "h-full w-full" : "h-full",
)}
key={`${composition.id}-${previewClip.id}`}
onTimeUpdate={(event) => {
const clipStart = getVideoCanvasCompositionClipStart(project, previewClip.id) || 0;
const relative = Math.max(
0,
event.currentTarget.currentTime - previewClip.inPointSeconds,
);
setPlayheadSeconds(
Math.min(totalDuration, clipStart + relative / previewClip.speed),
);
if (event.currentTarget.currentTime >= previewClip.outPointSeconds) {
event.currentTarget.pause();
}
}}
playsInline
preload="metadata"
ref={previewVideoRef}
src={previewClip.mediaUrl}
/>
) : (
<Video className="h-7 w-7 text-white/10" />
)}
{fullscreen ? (
<button
aria-label="退出全屏预览"
className="absolute right-5 top-5 flex size-9 items-center justify-center rounded-[6px] bg-black/60 text-white/72 hover:bg-black/80 hover:text-white"
onClick={() => setPreviewFullscreen(false)}
type="button"
>
<X className="h-5 w-5" />
</button>
) : null}
</div>
);
return (
<div className="grid min-h-0 min-w-[760px] flex-1 grid-cols-[minmax(248px,0.5fr)_minmax(520px,1fr)] gap-3 pb-4">
<section className="relative flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
<header className="flex h-[52px] shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2 text-[14px] font-medium text-white/78">
<span>视频</span>
<ChevronDown className="h-3 w-3 text-white/42" />
</div>
<button className="flex items-center gap-1 text-[12px] text-white/42" type="button">
全部
<ChevronDown className="h-3 w-3" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-20">
{videoItems.length ? (
<div className="space-y-3">
{videoItems.map((item) => (
<article
className="group relative border-b border-white/7 pb-3"
draggable={Boolean(item.mediaUrl)}
key={item.id}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(
"application/x-video-canvas-node",
item.id,
);
}}
>
<div className="text-[12px] text-white/48">{item.title}</div>
<div className="mt-2 flex gap-2">
<div className="relative aspect-[2/3] w-[106px] overflow-hidden rounded-[7px] bg-[#191919]">
{item.mediaUrl ? (
<video
className="h-full w-full object-cover"
muted
playsInline
preload="metadata"
src={item.mediaUrl}
/>
) : (
<div className="flex h-full items-center justify-center">
<Video className="h-5 w-5 text-white/15" />
</div>
)}
<button
aria-label={`添加 ${item.title} 到时间线`}
className="absolute bottom-2 right-2 flex size-7 items-center justify-center rounded-full bg-black/65 text-white opacity-0 transition-opacity hover:bg-black/85 focus-visible:opacity-100 group-hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-25"
disabled={!item.mediaUrl}
onClick={() => addVideoItem(item)}
title="添加到时间线"
type="button"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="min-w-0 flex-1 pt-1 text-[11px] text-white/32">
<div>{item.modelName || "视频素材"}</div>
{item.durationSeconds ? (
<div className="mt-2">{item.durationSeconds}秒</div>
) : null}
<div className="mt-8 truncate">{item.subtitle || "参考素材"}</div>
</div>
</div>
</article>
))}
</div>
) : (
<div className="flex h-36 items-center justify-center text-[12px] text-white/28">
暂无视频素材
</div>
)}
</div>
<div className="absolute bottom-5 right-4" ref={compositionMenuRef}>
<button
aria-expanded={compositionMenuOpen}
aria-haspopup="menu"
className="flex size-[50px] flex-col items-center justify-center gap-0.5 rounded-full bg-[#2b2b2b] text-[11px] text-white/72 shadow-[0_10px_30px_rgba(0,0,0,0.36)] hover:bg-[#343434] hover:text-white"
onClick={() => setCompositionMenuOpen(true)}
type="button"
>
<Video className="h-4 w-4" />
<span>剪辑</span>
</button>
{compositionMenuOpen ? (
<div
aria-label="剪辑"
className="absolute bottom-[58px] right-0 w-[208px] overflow-hidden rounded-[8px] border border-white/8 bg-[#242424] py-1 shadow-[0_16px_40px_rgba(0,0,0,0.46)]"
role="menu"
>
{compositionItems.map((item) => (
<button
className="flex h-9 w-full items-center gap-2 px-3 text-left text-[13px] text-white/76 hover:bg-white/7 hover:text-white"
key={item.id}
onClick={() => {
setCompositionMenuOpen(false);
onSwitchComposition(item.id);
}}
type="button"
>
<Video className="h-4 w-4 text-white/42" />
<span className="min-w-0 flex-1 truncate">{item.title}</span>
{item.id === composition.id ? <Check className="h-4 w-4" /> : null}
</button>
))}
</div>
) : null}
</div>
</section>
<section className="flex min-h-0 flex-col overflow-hidden rounded-[8px] bg-[#242424]">
{!previewCollapsed ? (
<>
<header className="flex h-[48px] shrink-0 items-center justify-between px-3">
<span className="text-[14px] font-medium text-white/78">视频合成</span>
<div className="flex items-center gap-2">
<div className="relative">
<button
aria-expanded={exportMenuOpen}
className="flex h-8 items-center gap-2 rounded-[8px] bg-white px-3 text-[13px] font-medium text-black hover:bg-white/88"
disabled={exporting}
onClick={() => setExportMenuOpen((current) => !current)}
type="button"
>
{exporting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
导出
{!exporting ? <ChevronDown className="h-3.5 w-3.5" /> : null}
</button>
{exportMenuOpen ? (
<div
aria-label="导出"
className="absolute right-0 top-10 z-40 w-36 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-[0_12px_30px_rgba(0,0,0,0.42)]"
role="menu"
>
<div className="flex h-8 items-center px-3 text-[12px] text-white/34">
导出位置
</div>
<button
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
disabled={!project.clips.length || exporting}
onClick={() => {
setExportMenuOpen(false);
onExport("local");
}}
type="button"
>
<Download className="h-4 w-4" />
导出到本地
</button>
<button
className="flex h-9 w-full items-center gap-2 rounded-[6px] px-3 text-left text-[12px] text-white/68 hover:bg-white/7 disabled:cursor-not-allowed disabled:opacity-30"
disabled={!project.clips.length || exporting}
onClick={() => {
setExportMenuOpen(false);
onExport("canvas");
}}
type="button"
>
<PanelsTopLeft className="h-4 w-4" />
导出到画布
</button>
</div>
) : null}
</div>
<button
aria-label="退出剪辑"
className="flex size-8 items-center justify-center rounded-[6px] text-white/50 hover:bg-white/8 hover:text-white"
onClick={onClose}
type="button"
>
<X className="h-5 w-5" />
</button>
</div>
</header>
<div
className={cn(
"grid h-[230px] shrink-0 gap-2 px-2 pb-2",
transitionPanelOpen
? "grid-cols-[minmax(300px,1.2fr)_minmax(220px,0.9fr)_296px]"
: subtitlePanelOpen
? "grid-cols-[minmax(0,1fr)_296px]"
: "grid-cols-1",
)}
>
{transitionPanelOpen ? (
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
<span>转场库</span>
<button
aria-label="关闭转场库"
className="text-white/42 hover:text-white"
onClick={() => setTransitionPanelOpen(false)}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-3">
<div className="mb-3 text-[12px] text-white/34">基础转场</div>
<div className="grid grid-cols-3 gap-2">
{TRANSITION_OPTIONS.map((option, index) => (
<button
aria-label={`${option.label} ${option.label}`}
className={cn(
"text-left text-[11px] text-white/54 hover:text-white",
!selectedClip && "cursor-not-allowed opacity-35",
)}
disabled={!selectedClip}
key={option.value}
onClick={() =>
updateSelectedClip((clip) => ({
...clip,
transition: option.value,
}))
}
type="button"
>
<span
className={cn(
"mb-1.5 block aspect-[1.45] rounded-[6px] border border-white/7",
index === 0 && "bg-[linear-gradient(90deg,#665d51,#202020)]",
index === 1 && "bg-[linear-gradient(90deg,#342d28,#050505)]",
index === 2 && "bg-[linear-gradient(90deg,#5c554d,#dedede)]",
selectedClip?.transition === option.value &&
"border-white/70 ring-1 ring-white/40",
)}
/>
<span className="block truncate">{option.label}</span>
</button>
))}
</div>
</div>
</aside>
) : null}
{renderPreview()}
{transitionPanelOpen ? (
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
<div className="flex h-10 items-center justify-between border-b border-white/7 px-3 text-[13px] text-white/76">
<span>转场</span>
<button
aria-label="关闭转场"
className="text-white/42 hover:text-white"
onClick={() => setTransitionPanelOpen(false)}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-3 text-[12px]">
<div className="text-white/32">名称</div>
<div className="mt-1 text-white/68">
{TRANSITION_OPTIONS.find((item) => item.value === selectedClip?.transition)?.label || "未选择转场"}
</div>
<div className="mt-4 text-white/32">时长</div>
<div className="mt-1 text-white/68">{selectedClip?.transition ? "0.5 秒" : "-"}</div>
<button
className="mt-5 flex h-8 w-full items-center justify-center gap-2 rounded-[6px] bg-white/6 text-white/56 hover:bg-white/9 disabled:opacity-30"
disabled={!selectedClip?.transition}
onClick={() => updateSelectedClip((clip) => ({ ...clip, transition: null }))}
type="button"
>
<Trash2 className="h-3.5 w-3.5" />
删除
</button>
</div>
</aside>
) : subtitlePanelOpen ? (
<aside className="overflow-hidden rounded-[7px] border border-white/7 bg-[#202020]">
<div className="flex h-10 items-center gap-4 border-b border-white/7 px-3 text-[13px]">
<button
className={subtitleTab === "subtitle" ? "text-white" : "text-white/40"}
onClick={() => setSubtitleTab("subtitle")}
type="button"
>
字幕
</button>
<button
className={subtitleTab === "text" ? "text-white" : "text-white/40"}
onClick={() => setSubtitleTab("text")}
type="button"
>
文本
</button>
</div>
{subtitleTab === "subtitle" ? (
<div className="p-3">
<input
aria-label="搜索字幕文本"
className="h-8 w-full rounded-[6px] bg-white/8 px-3 text-[12px] text-white outline-none placeholder:text-white/30"
placeholder="搜索字幕文本"
/>
<button
className="mt-2 flex h-8 w-full items-center justify-center gap-1 rounded-[6px] bg-white/9 text-[12px] text-white/78 hover:bg-white/12"
onClick={addSubtitle}
type="button"
>
<Plus className="h-4 w-4" />
新建字幕
</button>
<div className="mt-3 space-y-1">
{project.subtitles.length ? project.subtitles.map((subtitle) => (
<button
className={cn(
"w-full truncate rounded-[5px] px-2 py-1.5 text-left text-[12px] text-white/58 hover:bg-white/6",
subtitle.id === selectedSubtitleId && "bg-white/8 text-white/86",
)}
key={subtitle.id}
onClick={() => {
setSelectedSubtitleId(subtitle.id);
setSubtitleTab("text");
}}
type="button"
>
{subtitle.text}
</button>
)) : (
<div className="py-3 text-[12px] text-white/28">暂无字幕</div>
)}
</div>
</div>
) : selectedSubtitle ? (
<div className="p-3">
<textarea
aria-label="字幕文本"
className="h-24 w-full resize-none rounded-[6px] bg-white/7 p-3 text-[12px] leading-5 text-white outline-none"
onChange={(event) =>
applyProject({
...project,
subtitles: project.subtitles.map((subtitle) =>
subtitle.id === selectedSubtitle.id
? { ...subtitle, text: event.target.value }
: subtitle,
),
})
}
value={selectedSubtitle.text}
/>
</div>
) : (
<div className="p-3 text-[12px] text-white/34">请先选择一条字幕</div>
)}
</aside>
) : null}
</div>
</>
) : null}
<div className="flex h-12 shrink-0 items-center gap-1 border-y border-white/7 px-3">
<EditorIconButton disabled={!undoStack.length} label="撤销" onClick={undo}>
<Undo2 className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton disabled={!redoStack.length} label="重做" onClick={redo}>
<Redo2 className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton disabled={!selectedClip} label="分割" onClick={splitSelectedClip}>
<Scissors className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton disabled={!selectedClip} label="向左裁剪" onClick={() => trimSelectedClip("left")}>
<span className="text-[16px]">⌈</span>
</EditorIconButton>
<EditorIconButton disabled={!selectedClip} label="向右裁剪" onClick={() => trimSelectedClip("right")}>
<span className="text-[16px]">⌉</span>
</EditorIconButton>
<EditorIconButton
active={subtitlePanelOpen}
label="字幕轨道"
onClick={() => {
setSubtitlePanelOpen((current) => !current);
setTransitionPanelOpen(false);
}}
>
<TextCursorInput className="h-4 w-4" />
</EditorIconButton>
<EditorIconButton
active={transitionPanelOpen}
label="转场"
onClick={() => {
setTransitionPanelOpen((current) => !current);
setSubtitlePanelOpen(false);
}}
>
<Video className="h-4 w-4" />
</EditorIconButton>
<div className="ml-auto flex items-center gap-3 text-[12px] tabular-nums text-white/58">
<span>{formatTime(playheadSeconds)}</span>
<button
aria-label={previewVideoRef.current?.paused === false ? "暂停" : "播放"}
className="flex size-8 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/16"
onClick={() => void togglePlayback()}
type="button"
>
<Play className="h-3.5 w-3.5" />
</button>
<span>{formatTime(totalDuration)}</span>
<EditorIconButton label="全屏预览" onClick={() => setPreviewFullscreen(true)}>
<Maximize2 className="h-4 w-4" />
</EditorIconButton>
</div>
<div className="relative ml-auto flex items-center gap-1">
<EditorIconButton
disabled={!selectedClip}
label="变速"
onClick={() => setSpeedMenuOpen((current) => !current)}
>
<Gauge className="h-4 w-4" />
</EditorIconButton>
{speedMenuOpen ? (
<div
aria-label="变速"
className="absolute bottom-10 right-0 z-40 w-28 rounded-[8px] border border-white/8 bg-[#202020] p-1 shadow-xl"
role="menu"
>
{SPEED_OPTIONS.map((speed) => (
<button
className="flex h-8 w-full items-center justify-between rounded-[5px] px-2 text-[12px] text-white/68 hover:bg-white/7"
key={speed}
onClick={() => {
updateSelectedClip((clip) => ({ ...clip, speed }));
setSpeedMenuOpen(false);
}}
type="button"
>
{speed}x
{selectedClip?.speed === speed ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
) : null}
<EditorIconButton active={snapEnabled} label="吸附" onClick={() => setSnapEnabled((current) => !current)}>
<Link2 className="h-4 w-4" />
</EditorIconButton>
<ZoomOut className="h-4 w-4 text-white/48" />
<input
aria-label="时间线缩放"
className="h-1 w-20 accent-white"
max={100}
min={0}
onChange={(event) => setTimelineZoom(Number(event.target.value))}
type="range"
value={timelineZoom}
/>
<ZoomIn className="h-4 w-4 text-white/48" />
<EditorIconButton
label={previewCollapsed ? "展开预览(占满时间轴上方)" : "收起预览"}
onClick={() => setPreviewCollapsed((current) => !current)}
>
{previewCollapsed ? <Plus className="h-4 w-4" /> : <Minus className="h-4 w-4" />}
</EditorIconButton>
{previewCollapsed ? (
<EditorIconButton label="退出剪辑" onClick={onClose}>
<X className="h-4 w-4" />
</EditorIconButton>
) : null}
</div>
</div>
<div
className="relative min-h-[180px] flex-1 overflow-auto bg-[#1c1c1c]"
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
<div
aria-label="时间线"
className="relative min-h-full"
onPointerDown={seekTimeline}
style={{ minWidth: `${Math.max(100, timelineZoom + 80)}%` }}
>
<div className="absolute left-[88px] right-4 top-8 h-px bg-white/7" />
<div className="absolute left-0 top-[50px] flex h-14 w-[72px] items-center justify-center text-[20px] text-white/50">
<Video className="h-5 w-5" />
</div>
<div className="absolute left-[88px] right-4 top-[50px] flex h-14 gap-px bg-white/[0.025]">
{project.clips.map((clip) => (
<button
aria-label={`${clip.title} 时间线片段`}
className={cn(
"relative min-w-[52px] overflow-hidden border border-white/10 bg-[#303030] text-left",
clip.id === selectedClipId && "border-[#7aa7ff] ring-1 ring-[#7aa7ff]",
)}
key={clip.id}
onClick={(event) => {
event.stopPropagation();
setSelectedClipId(clip.id);
setPlayheadSeconds(getVideoCanvasCompositionClipStart(project, clip.id) || 0);
}}
style={{
width: `${Math.max(4, (getVideoCanvasCompositionClipDuration(clip) / timelineDuration) * 100)}%`,
}}
type="button"
>
<video
className="absolute inset-0 h-full w-full object-cover opacity-55"
muted
playsInline
preload="metadata"
src={clip.mediaUrl}
/>
<span className="absolute inset-x-1 bottom-1 truncate text-[10px] text-white/86">
{clip.title}
</span>
{clip.transition ? (
<span className="absolute left-0 top-0 h-full w-3 bg-white/25" />
) : null}
</button>
))}
</div>
{subtitlePanelOpen ? (
<>
<div className="absolute left-0 top-[118px] flex h-10 w-[72px] items-center justify-between px-3 text-white/50">
<TextCursorInput className="h-5 w-5" />
<button
aria-label={subtitleTrackVisible ? "关闭字幕" : "显示字幕"}
onClick={(event) => {
event.stopPropagation();
setSubtitleTrackVisible((current) => !current);
}}
type="button"
>
{subtitleTrackVisible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
<div className="absolute left-[88px] right-4 top-[118px] h-10 bg-white/[0.025]">
{subtitleTrackVisible ? project.subtitles.map((subtitle) => (
<button
className={cn(
"absolute top-1 h-8 truncate border border-white/10 bg-[#505050] px-2 text-left text-[10px] text-white/80",
subtitle.id === selectedSubtitleId && "border-[#7aa7ff]",
)}
key={subtitle.id}
onClick={(event) => {
event.stopPropagation();
setSelectedSubtitleId(subtitle.id);
setSubtitleTab("text");
}}
style={{
left: `${(subtitle.startSeconds / timelineDuration) * 100}%`,
width: `${Math.max(3, ((subtitle.endSeconds - subtitle.startSeconds) / timelineDuration) * 100)}%`,
}}
type="button"
>
{subtitle.text}
</button>
)) : null}
</div>
</>
) : null}
<div
className="pointer-events-none absolute bottom-0 top-0 z-20 w-px bg-white/90"
style={{
left: `calc(88px + (100% - 104px) * ${timelineDuration ? playheadSeconds / timelineDuration : 0})`,
}}
/>
</div>
</div>
</section>
{previewFullscreen ? renderPreview(true) : null}
</div>
);
}
function EditorIconButton({
active = false,
children,
disabled = false,
label,
onClick,
}: {
active?: boolean;
children: React.ReactNode;
disabled?: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
aria-label={label}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-[6px] text-white/46 hover:bg-white/7 hover:text-white disabled:cursor-not-allowed disabled:opacity-20",
active && "bg-white/9 text-white/88",
)}
disabled={disabled}
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
);
}