feat: 增加轨迹
This commit is contained in:
@@ -1,5 +1,27 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type {
|
||||
CSSProperties,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Box,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
DiamondPlus,
|
||||
Eraser,
|
||||
Film,
|
||||
MoveHorizontal,
|
||||
Pause,
|
||||
PersonStanding,
|
||||
Play,
|
||||
RefreshCw,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Trash2,
|
||||
Video,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline";
|
||||
import { useDirectorStore } from "../store/directorStore";
|
||||
import { usePlaybackStore } from "../runtime/playbackStore";
|
||||
@@ -11,31 +33,55 @@ interface SelectedKeyframe {
|
||||
keyframeId: string;
|
||||
}
|
||||
|
||||
const TIMELINE_MIN_HEIGHT = 160;
|
||||
const TIMELINE_DEFAULT_HEIGHT = 300;
|
||||
/** 面板最大高度固定值:内容再多也在面板内部滚动,不把 3D 视口压没 */
|
||||
const TIMELINE_MAX_HEIGHT = 420;
|
||||
const TIMELINE_HEIGHT_STORAGE_KEY = "director.timeline.height";
|
||||
|
||||
function clampTimelineHeight(height: number) {
|
||||
return Math.min(Math.max(Math.round(height), TIMELINE_MIN_HEIGHT), TIMELINE_MAX_HEIGHT);
|
||||
}
|
||||
|
||||
function loadTimelineHeight() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(TIMELINE_HEIGHT_STORAGE_KEY);
|
||||
if (!raw) return TIMELINE_DEFAULT_HEIGHT;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? clampTimelineHeight(value) : TIMELINE_DEFAULT_HEIGHT;
|
||||
} catch {
|
||||
return TIMELINE_DEFAULT_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(t: number) {
|
||||
return `${t.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** 刻度候选步长(秒)。先按目标刻度数算出「每个刻度大约多少秒」,再取不小于它的最小候选值,
|
||||
* 这样 5s / 8s / 30s 等不同时长都能得到疏密稳定的刻度。 */
|
||||
const TIMELINE_RULER_STEP_CANDIDATES = [0.1, 0.2, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
|
||||
const TIMELINE_RULER_TARGET_TICKS = 8;
|
||||
|
||||
export interface TimelineRulerTick {
|
||||
value: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export function getTimelineRulerTicks(duration: number): TimelineRulerTick[] {
|
||||
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 0;
|
||||
if (safeDuration === 0) return [{ value: 0, percent: 0 }];
|
||||
|
||||
const roughStep = safeDuration / TIMELINE_RULER_TARGET_TICKS;
|
||||
const step =
|
||||
TIMELINE_RULER_STEP_CANDIDATES.find((candidate) => candidate >= roughStep) ??
|
||||
TIMELINE_RULER_STEP_CANDIDATES[TIMELINE_RULER_STEP_CANDIDATES.length - 1];
|
||||
|
||||
const ticks: TimelineRulerTick[] = [];
|
||||
for (let value = 0; value <= safeDuration + 1e-6; value += step) {
|
||||
const rounded = Number(value.toFixed(4));
|
||||
ticks.push({ value: rounded, percent: (rounded / safeDuration) * 100 });
|
||||
}
|
||||
|
||||
const lastTick = ticks[ticks.length - 1];
|
||||
// 时长不是步长整数倍时补上末尾刻度,保证最右侧一定有刻度线
|
||||
if (lastTick && lastTick.value < safeDuration) {
|
||||
ticks.push({ value: safeDuration, percent: 100 });
|
||||
}
|
||||
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function formatRulerTick(value: number) {
|
||||
return `${Number.isInteger(value) ? value : value.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** 轨道左侧标签列的类型图标:和「对象树 / 添加角色」保持一致的语义 */
|
||||
const OBJECT_KIND_ICONS: Record<string, LucideIcon> = {
|
||||
character: PersonStanding,
|
||||
camera: Video,
|
||||
prop: Box,
|
||||
};
|
||||
|
||||
export function TimelinePanel() {
|
||||
const project = useDirectorStore((state) => state.project);
|
||||
const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
|
||||
@@ -60,52 +106,62 @@ export function TimelinePanel() {
|
||||
|
||||
const [selectedKeyframe, setSelectedKeyframe] = useState<SelectedKeyframe | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||
|
||||
// 时间轴面板高度:可拖动顶部手柄调整(向上拖变大),并持久化到 localStorage
|
||||
const [timelineHeight, setTimelineHeight] = useState(loadTimelineHeight);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeStartRef = useRef<{ pointerY: number; startHeight: number } | null>(null);
|
||||
const timelineHeightRef = useRef(timelineHeight);
|
||||
timelineHeightRef.current = timelineHeight;
|
||||
const panelRef = useRef<HTMLElement | null>(null);
|
||||
const exportMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 高度由内容决定(不再手动拖拽):把实测高度发布成 CSS 变量,
|
||||
// 让视口快捷工具条、画幅浮层和左右悬浮卡片自动让出时间轴占用的空间。
|
||||
useEffect(() => {
|
||||
const element = panelRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const publishHeight = () => {
|
||||
const height = element.getBoundingClientRect().height;
|
||||
if (height > 0) root.style.setProperty("--timeline-panel-height", `${Math.round(height)}px`);
|
||||
};
|
||||
|
||||
publishHeight();
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", publishHeight);
|
||||
return () => {
|
||||
window.removeEventListener("resize", publishHeight);
|
||||
root.style.removeProperty("--timeline-panel-height");
|
||||
};
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(publishHeight);
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
root.style.removeProperty("--timeline-panel-height");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
document.body.classList.add("is-resizing-timeline");
|
||||
return () => document.body.classList.remove("is-resizing-timeline");
|
||||
}, [isResizing]);
|
||||
if (!exportMenuOpen) return;
|
||||
|
||||
function handleResizeStart(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
resizeStartRef.current = { pointerY: event.clientY, startHeight: timelineHeight };
|
||||
setIsResizing(true);
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// 某些环境(如 jsdom)不支持 pointer capture,忽略后仍可通过 move/up 事件拖拽
|
||||
function closeExportMenuOnOutsidePointerDown(event: PointerEvent) {
|
||||
if (event.target instanceof Node && exportMenuRef.current?.contains(event.target)) return;
|
||||
setExportMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResizeMove(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
const start = resizeStartRef.current;
|
||||
if (!start) return;
|
||||
setTimelineHeight(clampTimelineHeight(start.startHeight + (start.pointerY - event.clientY)));
|
||||
}
|
||||
function closeExportMenuOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setExportMenuOpen(false);
|
||||
}
|
||||
|
||||
function handleResizeEnd(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!resizeStartRef.current) return;
|
||||
resizeStartRef.current = null;
|
||||
setIsResizing(false);
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// 同上,忽略不支持的环境
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(TIMELINE_HEIGHT_STORAGE_KEY, String(timelineHeightRef.current));
|
||||
} catch {
|
||||
// 持久化失败(如隐私模式)不影响功能
|
||||
}
|
||||
}
|
||||
document.addEventListener("pointerdown", closeExportMenuOnOutsidePointerDown);
|
||||
document.addEventListener("keydown", closeExportMenuOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeExportMenuOnOutsidePointerDown);
|
||||
document.removeEventListener("keydown", closeExportMenuOnEscape);
|
||||
};
|
||||
}, [exportMenuOpen]);
|
||||
|
||||
const timeline = project.timeline;
|
||||
|
||||
@@ -196,180 +252,372 @@ export function TimelinePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// 刻度尺本身就是时间指针:点击 / 拖动定位,方向键 / Home / End 微调
|
||||
const rulerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
|
||||
const rulerTicks = getTimelineRulerTicks(duration);
|
||||
const playheadPercent = duration === 0 ? 0 : Math.min(100, Math.max(0, (playhead / duration) * 100));
|
||||
const hasKeyframes = timeline.tracks.length > 0 || timeline.cameraTracks.length > 0;
|
||||
// 相邻刻度间距(百分比)交给 CSS:轨道底色用它画贯穿网格线,与刻度尺天然对齐
|
||||
const tickPercent = rulerTicks.length > 1 ? rulerTicks[1].percent : 100;
|
||||
const gridStyle = { "--timeline-tick-percent": `${tickPercent}%` } as CSSProperties;
|
||||
|
||||
function getTimeFromPointer(clientX: number) {
|
||||
const element = rulerRef.current;
|
||||
if (!element) return null;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 0) return null;
|
||||
|
||||
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||
return ratio * duration;
|
||||
}
|
||||
|
||||
/** 手动定位一律先暂停播放,避免播放循环把指针立刻推走 */
|
||||
function seekTo(time: number) {
|
||||
setPlaying(false);
|
||||
setPlayhead(time);
|
||||
}
|
||||
|
||||
function handleRulerPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
const time = getTimeFromPointer(event.clientX);
|
||||
if (time === null) return;
|
||||
|
||||
event.preventDefault();
|
||||
setIsScrubbing(true);
|
||||
seekTo(time);
|
||||
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// jsdom 等环境不支持 pointer capture,忽略后仍可通过 move/up 拖动
|
||||
}
|
||||
}
|
||||
|
||||
function handleRulerPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
const time = getTimeFromPointer(event.clientX);
|
||||
if (time !== null) setPlayhead(time);
|
||||
}
|
||||
|
||||
function handleRulerPointerEnd(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
setIsScrubbing(false);
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// 同上,忽略不支持的环境
|
||||
}
|
||||
}
|
||||
|
||||
function handleRulerKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
const step = duration <= 10 ? 0.1 : 0.5;
|
||||
const seekActions: Record<string, () => void> = {
|
||||
ArrowLeft: () => seekTo(playhead - step),
|
||||
ArrowRight: () => seekTo(playhead + step),
|
||||
Home: () => seekTo(0),
|
||||
End: () => seekTo(duration),
|
||||
};
|
||||
|
||||
const action = seekActions[event.key];
|
||||
if (!action) return;
|
||||
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
|
||||
const addKeyframeDisabled = selectionIsManualCamera ? !canAddCameraKeyframe : !selectedObject;
|
||||
const addKeyframeTitle = selectionIsManualCamera
|
||||
? canAddCameraKeyframe
|
||||
? `把相机「${selectedShot?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧`
|
||||
? `把相机「${selectedShot?.name}」当前机位记录为 ${formatTime(playhead)} 处的运镜关键帧(快捷键 K)`
|
||||
: "锁定物体的相机(targetMode=object)不支持运镜关键帧"
|
||||
: selectedObject
|
||||
? `把「${selectedObject.name}」当前位置记录为 ${formatTime(playhead)} 处的关键帧`
|
||||
? `把「${selectedObject.name}」当前位置记录为 ${formatTime(playhead)} 处的关键帧(快捷键 K)`
|
||||
: "请先选择一个对象或相机";
|
||||
|
||||
return (
|
||||
<section
|
||||
className="panel-card timeline-panel"
|
||||
aria-label="时间轴"
|
||||
style={{ height: timelineHeight }}
|
||||
>
|
||||
<div
|
||||
className={`timeline-resizer${isResizing ? " is-active" : ""}`}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="拖动调整时间轴高度"
|
||||
title="向上拖动增大时间轴高度"
|
||||
onPointerDown={handleResizeStart}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
onPointerCancel={handleResizeEnd}
|
||||
/>
|
||||
<section className="timeline-panel" aria-label="时间轴" ref={panelRef}>
|
||||
<div className="timeline-toolbar">
|
||||
<button type="button" onClick={togglePlaying}>
|
||||
{isPlaying ? "暂停" : "播放"}
|
||||
</button>
|
||||
<span className="timeline-time-readout">
|
||||
{formatTime(playhead)} / {formatTime(duration)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration}
|
||||
step={0.05}
|
||||
value={playhead}
|
||||
onChange={(event) => setPlayhead(Number(event.target.value))}
|
||||
aria-label="时间指针"
|
||||
/>
|
||||
<label className="timeline-duration">
|
||||
时长
|
||||
<input
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.5}
|
||||
value={duration}
|
||||
onChange={(event) => setTimelineDuration(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" disabled={addKeyframeDisabled} onClick={handleAddKeyframe} title={addKeyframeTitle}>
|
||||
在指针处打关键帧
|
||||
</button>
|
||||
{selectedKeyframe ? (
|
||||
<>
|
||||
<button type="button" onClick={handleUpdateSelected}>更新选中关键帧</button>
|
||||
<button type="button" onClick={handleDeleteSelected}>删除选中关键帧</button>
|
||||
</>
|
||||
) : null}
|
||||
{timeline.tracks.length > 0 || timeline.cameraTracks.length > 0 ? (
|
||||
<button type="button" className="timeline-clear-all" onClick={handleClearAll}>
|
||||
清空全部
|
||||
</button>
|
||||
) : null}
|
||||
<div className="timeline-export-row">
|
||||
<div className="timeline-toolbar-group">
|
||||
<span className="timeline-time-readout">
|
||||
{formatTime(playhead)} / {formatTime(duration)}
|
||||
</span>
|
||||
<label className="timeline-duration-chip" title="时间轴总时长(秒)">
|
||||
<MoveHorizontal aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
<input
|
||||
className="timeline-duration-input"
|
||||
aria-label="时间轴总时长"
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.5}
|
||||
value={duration}
|
||||
onChange={(event) => setTimelineDuration(Number(event.target.value))}
|
||||
/>
|
||||
<span aria-hidden="true">s</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="timeline-toolbar-group timeline-toolbar-center">
|
||||
<div className="timeline-transport" role="group" aria-label="播放控制">
|
||||
<button
|
||||
className="timeline-icon-button"
|
||||
type="button"
|
||||
aria-label="回到首帧"
|
||||
title="回到首帧"
|
||||
onClick={() => seekTo(0)}
|
||||
>
|
||||
<SkipBack aria-hidden="true" size={15} strokeWidth={1.8} />
|
||||
</button>
|
||||
<button
|
||||
className="timeline-icon-button timeline-play-button"
|
||||
type="button"
|
||||
aria-label={isPlaying ? "暂停" : "播放"}
|
||||
aria-pressed={isPlaying}
|
||||
onClick={togglePlaying}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause aria-hidden="true" size={15} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Play aria-hidden="true" size={15} strokeWidth={1.8} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="timeline-icon-button"
|
||||
type="button"
|
||||
aria-label="回到尾帧"
|
||||
title="回到尾帧"
|
||||
onClick={() => seekTo(duration)}
|
||||
>
|
||||
<SkipForward aria-hidden="true" size={15} strokeWidth={1.8} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="timeline-toolbar-divider" aria-hidden="true" />
|
||||
|
||||
<button
|
||||
className="timeline-action-button"
|
||||
type="button"
|
||||
className="timeline-export-video"
|
||||
disabled={isRecording}
|
||||
onClick={() => void handleExportVideo("director")}
|
||||
title="从导演视角逐帧快速导出整段动画为 MP4 视频"
|
||||
disabled={addKeyframeDisabled}
|
||||
title={addKeyframeTitle}
|
||||
onClick={handleAddKeyframe}
|
||||
>
|
||||
{isRecording ? "导出中…" : "导出视频·上帝视角"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-export-video"
|
||||
disabled={isRecording}
|
||||
onClick={() => void handleExportVideo("camera")}
|
||||
title="从当前选中相机的运镜画面逐帧快速导出为 MP4 视频"
|
||||
>
|
||||
{isRecording ? "录制中…" : "导出视频·机位视角"}
|
||||
<DiamondPlus aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
在指针处打关键帧
|
||||
</button>
|
||||
{selectedKeyframe ? (
|
||||
<>
|
||||
<button className="timeline-action-button" type="button" onClick={handleUpdateSelected}>
|
||||
<RefreshCw aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
更新选中关键帧
|
||||
</button>
|
||||
<button className="timeline-action-button" type="button" onClick={handleDeleteSelected}>
|
||||
<Trash2 aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
删除选中关键帧
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="timeline-toolbar-group timeline-toolbar-end">
|
||||
{hasKeyframes ? (
|
||||
<button className="timeline-action-button" type="button" onClick={handleClearAll}>
|
||||
<Eraser aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
清空全部
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="timeline-export" ref={exportMenuRef}>
|
||||
<button
|
||||
className="timeline-export-button"
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={exportMenuOpen}
|
||||
disabled={isRecording}
|
||||
title="导出整段动画为 MP4 视频"
|
||||
onClick={() => setExportMenuOpen((isOpen) => !isOpen)}
|
||||
>
|
||||
<Film aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
{isRecording ? "导出中…" : "导出"}
|
||||
<ChevronDown aria-hidden="true" className="timeline-export-chevron" size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
{exportMenuOpen ? (
|
||||
<div className="timeline-export-menu" role="menu" aria-label="选择导出视角">
|
||||
<button
|
||||
className="timeline-export-option"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
title="按导演视角逐帧快速导出整段动画"
|
||||
onClick={() => {
|
||||
setExportMenuOpen(false);
|
||||
void handleExportVideo("director");
|
||||
}}
|
||||
>
|
||||
<Film aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
上帝视角
|
||||
<span className="timeline-export-option-hint">导演视角整段动画</span>
|
||||
</button>
|
||||
<button
|
||||
className="timeline-export-option"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
title="按当前选中相机的运镜画面逐帧导出"
|
||||
onClick={() => {
|
||||
setExportMenuOpen(false);
|
||||
void handleExportVideo("camera");
|
||||
}}
|
||||
>
|
||||
<Camera aria-hidden="true" size={14} strokeWidth={1.8} />
|
||||
机位视角
|
||||
<span className="timeline-export-option-hint">当前相机运镜画面</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="timeline-tracks">
|
||||
{timeline.tracks.length === 0 && timeline.cameraTracks.length === 0 ? (
|
||||
<p className="timeline-empty">还没有关键帧。选中对象或相机,摆好状态后点「在指针处打关键帧」。</p>
|
||||
) : null}
|
||||
<div className="timeline-body">
|
||||
{hasKeyframes ? null : (
|
||||
<p className="timeline-empty">还没有关键帧。选中对象或相机,摆好姿态后点「在指针处打关键帧」。</p>
|
||||
)}
|
||||
|
||||
{timeline.tracks.map((track) => {
|
||||
const obj = project.objects.find((o) => o.id === track.objectId);
|
||||
return (
|
||||
<div className="timeline-track" key={track.objectId}>
|
||||
<span className="timeline-track-label">{obj?.name ?? track.objectId}</span>
|
||||
<div className="timeline-track-lane">
|
||||
{track.keyframes.map((kf) => {
|
||||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||||
const isSelected =
|
||||
selectedKeyframe?.kind === "object" &&
|
||||
selectedKeyframe.targetId === track.objectId &&
|
||||
selectedKeyframe.keyframeId === kf.id;
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
|
||||
style={{ left: `${leftPercent}%` }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-keyframe-delete"
|
||||
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeKeyframe(track.objectId, kf.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="timeline-grid" style={gridStyle}>
|
||||
<div className="timeline-grid-head">
|
||||
<div className="timeline-gutter-cell" aria-hidden="true">
|
||||
轨道
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={`timeline-ruler${isScrubbing ? " is-scrubbing" : ""}`}
|
||||
ref={rulerRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="时间指针"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={duration}
|
||||
aria-valuenow={Number(playhead.toFixed(2))}
|
||||
aria-valuetext={formatTime(playhead)}
|
||||
onKeyDown={handleRulerKeyDown}
|
||||
onPointerDown={handleRulerPointerDown}
|
||||
onPointerMove={handleRulerPointerMove}
|
||||
onPointerUp={handleRulerPointerEnd}
|
||||
onPointerCancel={handleRulerPointerEnd}
|
||||
>
|
||||
{rulerTicks.map((tick) => (
|
||||
<span className="timeline-ruler-tick" key={tick.value} style={{ left: `${tick.percent}%` }}>
|
||||
{formatRulerTick(tick.value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeline.cameraTracks.map((track) => {
|
||||
const shot = project.cameras.find((c) => c.id === track.cameraId);
|
||||
return (
|
||||
<div className="timeline-track timeline-track-camera" key={track.cameraId}>
|
||||
<span className="timeline-track-label">📷 {shot?.name ?? track.cameraId}</span>
|
||||
<div className="timeline-track-lane">
|
||||
{track.keyframes.map((kf) => {
|
||||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||||
const isSelected =
|
||||
selectedKeyframe?.kind === "camera" &&
|
||||
selectedKeyframe.targetId === track.cameraId &&
|
||||
selectedKeyframe.keyframeId === kf.id;
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
|
||||
style={{ left: `${leftPercent}%` }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-keyframe-delete"
|
||||
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeCameraKeyframe(track.cameraId, kf.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="timeline-grid-body">
|
||||
{timeline.tracks.map((track) => {
|
||||
const obj = project.objects.find((o) => o.id === track.objectId);
|
||||
const KindIcon = (obj ? OBJECT_KIND_ICONS[obj.kind] : undefined) ?? Box;
|
||||
const trackName = obj?.name ?? track.objectId;
|
||||
|
||||
return (
|
||||
<div className="timeline-track" key={track.objectId}>
|
||||
<div className="timeline-track-label" title={trackName}>
|
||||
<KindIcon aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
|
||||
<span className="timeline-track-name">{trackName}</span>
|
||||
<span className="timeline-track-count">{track.keyframes.length}</span>
|
||||
</div>
|
||||
<div className="timeline-track-lane">
|
||||
{track.keyframes.map((kf) => {
|
||||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||||
const isSelected =
|
||||
selectedKeyframe?.kind === "object" &&
|
||||
selectedKeyframe.targetId === track.objectId &&
|
||||
selectedKeyframe.keyframeId === kf.id;
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`timeline-keyframe${isSelected ? " is-selected" : ""}`}
|
||||
style={{ left: `${leftPercent}%` }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={() => handleSelectObjectKeyframe(track.objectId, kf.id, kf.time, kf.value)}
|
||||
>
|
||||
<span className="timeline-keyframe-dot" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-keyframe-delete"
|
||||
aria-label={`删除关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeKeyframe(track.objectId, kf.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{timeline.cameraTracks.map((track) => {
|
||||
const shot = project.cameras.find((c) => c.id === track.cameraId);
|
||||
const trackName = shot?.name ?? track.cameraId;
|
||||
|
||||
return (
|
||||
<div className="timeline-track timeline-track-camera" key={track.cameraId}>
|
||||
<div className="timeline-track-label" title={trackName}>
|
||||
<Video aria-hidden="true" className="timeline-track-icon" size={14} strokeWidth={1.8} />
|
||||
<span className="timeline-track-name">{trackName}</span>
|
||||
<span className="timeline-track-count">{track.keyframes.length}</span>
|
||||
</div>
|
||||
<div className="timeline-track-lane">
|
||||
{track.keyframes.map((kf) => {
|
||||
const leftPercent = duration === 0 ? 0 : (kf.time / duration) * 100;
|
||||
const isSelected =
|
||||
selectedKeyframe?.kind === "camera" &&
|
||||
selectedKeyframe.targetId === track.cameraId &&
|
||||
selectedKeyframe.keyframeId === kf.id;
|
||||
return (
|
||||
<div
|
||||
key={kf.id}
|
||||
className={`timeline-keyframe timeline-keyframe-camera${isSelected ? " is-selected" : ""}`}
|
||||
style={{ left: `${leftPercent}%` }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`运镜关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={() => handleSelectCameraKeyframe(track.cameraId, kf.id, kf.time, kf.value)}
|
||||
>
|
||||
<span className="timeline-keyframe-dot" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="timeline-keyframe-delete"
|
||||
aria-label={`删除运镜关键帧 ${formatTime(kf.time)}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeCameraKeyframe(track.cameraId, kf.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="timeline-playhead-layer" aria-hidden="true">
|
||||
<div className="timeline-playhead" style={{ left: `${playheadPercent}%` }}>
|
||||
<span className="timeline-playhead-knob" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user