feat: 增加轨迹

This commit is contained in:
2026-09-17 09:09:06 +08:00
parent b9fd289caf
commit e74cf6d28b
42 changed files with 4010 additions and 570 deletions

View File

@@ -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>
);