948 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|