summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/components
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/components
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/components')
-rw-r--r--apps/web/src/timeline/components/audio-tab.tsx151
-rw-r--r--apps/web/src/timeline/components/audio-volume-line.tsx275
-rw-r--r--apps/web/src/timeline/components/audio-waveform.tsx345
-rw-r--r--apps/web/src/timeline/components/drag-line.tsx29
-rw-r--r--apps/web/src/timeline/components/drop-target.ts262
-rw-r--r--apps/web/src/timeline/components/expanded-layout.ts117
-rw-r--r--apps/web/src/timeline/components/graph-editor/bezier-graph.tsx235
-rw-r--r--apps/web/src/timeline/components/graph-editor/custom-presets-store.ts101
-rw-r--r--apps/web/src/timeline/components/graph-editor/easing-presets.ts19
-rw-r--r--apps/web/src/timeline/components/graph-editor/popover.tsx334
-rw-r--r--apps/web/src/timeline/components/graph-editor/session.ts704
-rw-r--r--apps/web/src/timeline/components/graph-editor/use-controller.ts178
-rw-r--r--apps/web/src/timeline/components/index.tsx946
-rw-r--r--apps/web/src/timeline/components/interaction.ts4
-rw-r--r--apps/web/src/timeline/components/layers.ts6
-rw-r--r--apps/web/src/timeline/components/layout.ts20
-rw-r--r--apps/web/src/timeline/components/selection-hit-testing.ts135
-rw-r--r--apps/web/src/timeline/components/snap-indicator.tsx50
-rw-r--r--apps/web/src/timeline/components/theme.ts31
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx1302
-rw-r--r--apps/web/src/timeline/components/timeline-playhead.tsx117
-rw-r--r--apps/web/src/timeline/components/timeline-ruler.tsx146
-rw-r--r--apps/web/src/timeline/components/timeline-tick.tsx42
-rw-r--r--apps/web/src/timeline/components/timeline-toolbar.tsx376
-rw-r--r--apps/web/src/timeline/components/timeline-track.tsx132
-rw-r--r--apps/web/src/timeline/components/track-layout.ts60
26 files changed, 6117 insertions, 0 deletions
diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx
new file mode 100644
index 00000000..1dbb3d1a
--- /dev/null
+++ b/apps/web/src/timeline/components/audio-tab.tsx
@@ -0,0 +1,151 @@
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { isSourceAudioSeparated } from "@/timeline/audio-separation";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/timeline";
+import { resolveNumberAtTime } from "@/animation";
+import { useEditor } from "@/editor/use-editor";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: formatNumberForDisplay({
+ value: resolvedVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({
+ value: snapToStep({ value: parsed, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: VOLUME_STEP,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+ const isSeparated =
+ element.type === "video" && isSourceAudioSeparated({ element });
+
+ return (
+ <>
+ {isSeparated && (
+ <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
+ <p className="text-sm">Audio has been separated.</p>
+ <Button
+ className="mt-3"
+ size="sm"
+ variant="secondary"
+ onClick={() =>
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId,
+ elementId: element.id,
+ })
+ }
+ >
+ Recover audio
+ </Button>
+ </div>
+ )}
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ </>
+ );
+}
diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx
new file mode 100644
index 00000000..1baac2bd
--- /dev/null
+++ b/apps/web/src/timeline/components/audio-volume-line.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { useEditor } from "@/editor/use-editor";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ getDbFromLinePos,
+ getLinePosFromDb,
+} from "@/timeline/audio-display";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { hasAnimatedVolume } from "@/timeline/audio-state";
+import type { AudioElement } from "@/timeline/types";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import { cn } from "@/utils/ui";
+
+const HIT_AREA_HEIGHT_PX = 14;
+const TOOLTIP_OFFSET_PX = 10;
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+function clampVolume({ value }: { value: number }): number {
+ return clamp({
+ value: snapToStep({ value, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+}
+
+function getVolumeFromPointer({
+ clientY,
+ rect,
+}: {
+ clientY: number;
+ rect: DOMRect;
+}): number {
+ const clampedOffset = clamp({
+ value: clientY - rect.top,
+ min: 0,
+ max: rect.height,
+ });
+ const progressPercent =
+ rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100;
+ return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) });
+}
+
+export function AudioVolumeLine({
+ element,
+ trackId,
+}: {
+ element: AudioElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const surfaceRef = useRef<HTMLDivElement>(null);
+ const activePointerIdRef = useRef<number | null>(null);
+ const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume);
+ const lastPreviewVolumeRef = useRef(
+ element.volume ?? DEFAULTS.element.volume,
+ );
+ const hasChangedRef = useRef(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [tooltipClientPos, setTooltipClientPos] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ const hasAnimatedEnvelope = hasAnimatedVolume({ element });
+ const currentVolume = element.volume ?? DEFAULTS.element.volume;
+ const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`;
+
+ const volumeLabel = `${formatNumberForDisplay({
+ value: currentVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ })} dB`;
+
+ const previewVolume = useCallback(
+ (nextVolume: number) => {
+ if (
+ isNearlyEqual({
+ leftValue: nextVolume,
+ rightValue: lastPreviewVolumeRef.current,
+ })
+ ) {
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { volume: nextVolume },
+ },
+ ],
+ });
+ lastPreviewVolumeRef.current = nextVolume;
+ hasChangedRef.current = !isNearlyEqual({
+ leftValue: startVolumeRef.current,
+ rightValue: nextVolume,
+ });
+ },
+ [editor, element.id, trackId],
+ );
+
+ const finishDrag = useCallback(
+ ({ shouldCommit }: { shouldCommit: boolean }) => {
+ activePointerIdRef.current = null;
+ setIsDragging(false);
+
+ if (shouldCommit && hasChangedRef.current) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.discardPreview();
+ }
+
+ hasChangedRef.current = false;
+ lastPreviewVolumeRef.current = startVolumeRef.current;
+ setTooltipClientPos(null);
+ },
+ [editor],
+ );
+
+ const updateFromPointer = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const rect = surfaceRef.current?.getBoundingClientRect();
+ if (!rect) {
+ return;
+ }
+
+ setTooltipClientPos({
+ x: clientX + TOOLTIP_OFFSET_PX,
+ y: clientY - TOOLTIP_OFFSET_PX,
+ });
+ previewVolume(getVolumeFromPointer({ clientY, rect }));
+ },
+ [previewVolume],
+ );
+
+ const handleClick = useCallback((event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }, []);
+
+ const handleMouseDown = useCallback((event: React.MouseEvent) => {
+ event.stopPropagation();
+ }, []);
+
+ const handlePointerDown = useCallback(
+ (event: React.PointerEvent<HTMLDivElement>) => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ editor.selection.setSelectedElements({
+ elements: [{ trackId, elementId: element.id }],
+ });
+ activePointerIdRef.current = event.pointerId;
+ startVolumeRef.current = currentVolume;
+ lastPreviewVolumeRef.current = currentVolume;
+ hasChangedRef.current = false;
+ setIsDragging(true);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ updateFromPointer({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ },
+ [currentVolume, editor.selection, element.id, trackId, updateFromPointer],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: React.PointerEvent) => {
+ if (activePointerIdRef.current !== event.pointerId) {
+ return;
+ }
+
+ event.preventDefault();
+ updateFromPointer({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ },
+ [updateFromPointer],
+ );
+
+ const handlePointerUp = useCallback(
+ (event: React.PointerEvent) => {
+ if (activePointerIdRef.current !== event.pointerId) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ finishDrag({ shouldCommit: true });
+ },
+ [finishDrag],
+ );
+
+ const handlePointerCancel = useCallback(
+ (event: React.PointerEvent) => {
+ if (activePointerIdRef.current !== event.pointerId) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ finishDrag({ shouldCommit: false });
+ },
+ [finishDrag],
+ );
+
+ const handleLostPointerCapture = useCallback(() => {
+ if (activePointerIdRef.current === null) {
+ return;
+ }
+
+ finishDrag({ shouldCommit: hasChangedRef.current });
+ }, [finishDrag]);
+
+ if (hasAnimatedEnvelope) {
+ return null;
+ }
+
+ return (
+ <div className="pointer-events-none absolute inset-0">
+ <div ref={surfaceRef} className="absolute inset-0">
+ <div
+ className={cn(
+ "pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors",
+ isDragging
+ ? "border-white"
+ : "border-white/50 group-hover/audio:border-white/80",
+ )}
+ style={{ top: lineTop }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */}
+ <div
+ className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto"
+ style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }}
+ onClick={handleClick}
+ onMouseDown={handleMouseDown}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerCancel}
+ onLostPointerCapture={handleLostPointerCapture}
+ title="Drag to adjust clip volume"
+ />
+ {isDragging &&
+ tooltipClientPos &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap"
+ style={{
+ transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`,
+ }}
+ >
+ {volumeLabel}
+ </div>,
+ document.body,
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx
new file mode 100644
index 00000000..7777bc09
--- /dev/null
+++ b/apps/web/src/timeline/components/audio-waveform.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
+import { useResizeObserver } from "@/hooks/use-resize-observer";
+import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme";
+import {
+ buildWaveformSampleBuckets,
+ sampleSourceWaveformSummary,
+ type SourceWaveformSummary,
+} from "@/media/waveform-summary";
+import type { RetimeConfig } from "@/timeline";
+import { getBarFractionFromOutputAmplitude } from "@/timeline/audio-display";
+import { waveformCache } from "@/services/waveform-cache/service";
+import { findScrollParent } from "@/utils/browser";
+import { cn } from "@/utils/ui";
+
+const BAR_WIDTH = 1;
+const BAR_GAP = 1;
+const BAR_STEP = BAR_WIDTH + BAR_GAP;
+const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)";
+export const WAVEFORM_GAIN_SAMPLE_COUNT = 200;
+
+function sampleGainAtClipTime({
+ samples,
+ clipTimeSec,
+ clipDurationSec,
+}: {
+ samples: number[];
+ clipTimeSec: number;
+ clipDurationSec: number;
+}): number {
+ if (samples.length === 0 || clipDurationSec <= 0) {
+ return 1;
+ }
+
+ const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec));
+ const rawIndex = progress * (samples.length - 1);
+ const lo = Math.floor(rawIndex);
+ const hi = Math.min(samples.length - 1, lo + 1);
+ return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo);
+}
+
+interface AudioWaveformProps {
+ sourceKey: string;
+ sourceFile?: File;
+ audioUrl?: string;
+ audioBuffer?: AudioBuffer;
+ gainSamples?: number[];
+ pixelsPerSecond: number;
+ clipDurationSec: number;
+ retime?: RetimeConfig;
+ sourceStartSec: number;
+ color?: string;
+ burnColor?: string;
+ className?: string;
+}
+
+export function AudioWaveform({
+ sourceKey,
+ sourceFile,
+ audioUrl,
+ audioBuffer,
+ gainSamples,
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ color = TIMELINE_AUDIO_WAVEFORM_COLOR,
+ burnColor = WAVEFORM_BURN_COLOR,
+ className = "",
+}: AudioWaveformProps) {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const summaryRef = useRef<SourceWaveformSummary | null>(null);
+ const gainSamplesRef = useRef<number[] | undefined>(gainSamples);
+ const pixelsPerSecondRef = useRef<number>(pixelsPerSecond);
+ const clipDurationSecRef = useRef<number>(clipDurationSec);
+ const retimeRef = useRef<RetimeConfig | undefined>(retime);
+ const sourceStartSecRef = useRef<number>(sourceStartSec);
+ const scrollParentRef = useRef<HTMLElement | null>(null);
+ const heightRef = useRef<number>(0);
+ const lastRenderSignatureRef = useRef<string | null>(null);
+
+ gainSamplesRef.current = gainSamples;
+ pixelsPerSecondRef.current = pixelsPerSecond;
+ clipDurationSecRef.current = clipDurationSec;
+ retimeRef.current = retime;
+ sourceStartSecRef.current = sourceStartSec;
+
+ const clearCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) {
+ return;
+ }
+
+ const ctx = canvas.getContext("2d");
+ if (ctx) {
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ }
+ lastRenderSignatureRef.current = null;
+ }, []);
+
+ const drawVisible = useCallback(() => {
+ const container = containerRef.current;
+ const canvas = canvasRef.current;
+ const summary = summaryRef.current;
+ const height = heightRef.current;
+
+ if (!container || !canvas || !summary || height <= 0) {
+ clearCanvas();
+ return;
+ }
+
+ const containerRect = container.getBoundingClientRect();
+ const elementWidth = containerRect.width;
+ if (elementWidth <= 0) {
+ clearCanvas();
+ return;
+ }
+
+ const scrollParent = scrollParentRef.current;
+
+ let clipLeft: number;
+ let clipRight: number;
+
+ if (scrollParent) {
+ const parentRect = scrollParent.getBoundingClientRect();
+ clipLeft = Math.max(0, parentRect.left - containerRect.left);
+ clipRight = Math.min(elementWidth, parentRect.right - containerRect.left);
+ } else {
+ clipLeft = Math.max(0, -containerRect.left);
+ clipRight = Math.min(
+ elementWidth,
+ window.innerWidth - containerRect.left,
+ );
+ }
+
+ const visibleWidth = clipRight - clipLeft;
+ if (visibleWidth <= 0) {
+ clearCanvas();
+ return;
+ }
+
+ const dpr = window.devicePixelRatio || 1;
+ const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr));
+ const canvasH = Math.max(1, Math.round(height * dpr));
+ const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP));
+ const pixelsPerSecondValue = pixelsPerSecondRef.current;
+ const clipDurationSecValue = clipDurationSecRef.current;
+ const samples = gainSamplesRef.current;
+ const renderSignature = JSON.stringify({
+ elementWidth,
+ clipLeft,
+ clipRight,
+ visibleWidth,
+ canvasW,
+ canvasH,
+ barCount,
+ dpr,
+ clipDurationSec: clipDurationSecValue,
+ sourceStartSec: sourceStartSecRef.current,
+ pixelsPerSecond: pixelsPerSecondValue,
+ retime: retimeRef.current ?? null,
+ summarySourceKey: summary.sourceKey,
+ summarySampleRate: summary.sampleRate,
+ summaryTotalSamples: summary.totalSamples,
+ summaryBucketSize: summary.bucketSize,
+ gainSamples: samples ?? null,
+ color,
+ burnColor,
+ });
+ if (lastRenderSignatureRef.current === renderSignature) {
+ return;
+ }
+ lastRenderSignatureRef.current = renderSignature;
+
+ canvas.width = canvasW;
+ canvas.height = canvasH;
+ canvas.style.width = `${visibleWidth}px`;
+ canvas.style.height = `${height}px`;
+ canvas.style.left = `${clipLeft}px`;
+
+ const backingScaleX = dpr;
+ const backingScaleY = canvasH / height;
+
+ const sampleBuckets = buildWaveformSampleBuckets({
+ clipLeftPx: clipLeft,
+ clipRightPx: clipRight,
+ barCount,
+ pixelsPerSecond: pixelsPerSecondValue,
+ clipDurationSec: clipDurationSecValue,
+ sourceStartSec: sourceStartSecRef.current,
+ retime: retimeRef.current,
+ sampleRate: summary.sampleRate,
+ maxSampleExclusive: summary.totalSamples,
+ barStepPx: BAR_STEP,
+ });
+ const amplitudes = sampleSourceWaveformSummary({
+ summary,
+ buckets: sampleBuckets,
+ });
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ return;
+ }
+
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, canvasW, canvasH);
+
+ const clipBottom = canvasH;
+
+ for (let i = 0; i < barCount; i++) {
+ const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5;
+ const clipCenterSec = Math.max(
+ 0,
+ Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue),
+ );
+ const gain =
+ samples != null
+ ? sampleGainAtClipTime({
+ samples,
+ clipTimeSec: clipCenterSec,
+ clipDurationSec: clipDurationSecValue,
+ })
+ : 1;
+ const amplitude = Math.max(0, amplitudes[i] ?? 0);
+ const outputAmplitude = amplitude * Math.max(0, gain);
+ const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude });
+ const barH = fraction > 0 ? Math.max(1, fraction * height) : 0;
+ if (barH <= 0) {
+ continue;
+ }
+
+ const barLeft = i * BAR_STEP;
+ const barRight = barLeft + BAR_WIDTH;
+ const deviceLeft = Math.round(barLeft * backingScaleX);
+ const deviceRight = Math.max(
+ deviceLeft + 1,
+ Math.round(barRight * backingScaleX),
+ );
+ const deviceTop = Math.round((height - barH) * backingScaleY);
+ const deviceHeight = Math.max(1, clipBottom - deviceTop);
+
+ ctx.fillStyle = color;
+ ctx.fillRect(
+ deviceLeft,
+ deviceTop,
+ deviceRight - deviceLeft,
+ deviceHeight,
+ );
+
+ if (outputAmplitude > 1) {
+ const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY));
+ ctx.fillStyle = burnColor;
+ ctx.fillRect(
+ deviceLeft,
+ deviceTop,
+ deviceRight - deviceLeft,
+ burnHeight,
+ );
+ }
+ }
+ }, [burnColor, clearCanvas, color]);
+
+ useEffect(() => {
+ let isCancelled = false;
+ summaryRef.current = null;
+ clearCanvas();
+
+ void waveformCache
+ .getSourceSummary({
+ sourceKey,
+ audioBuffer,
+ sourceFile,
+ audioUrl,
+ })
+ .then((summary) => {
+ if (isCancelled) {
+ return;
+ }
+ summaryRef.current = summary;
+ drawVisible();
+ })
+ .catch(() => {
+ // Waveform loading failed (e.g. corrupt file, unsupported format).
+ // Fail silently — a missing waveform is preferable to an error state.
+ if (!isCancelled) {
+ clearCanvas();
+ }
+ });
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change.
+ useLayoutEffect(() => {
+ drawVisible();
+ }, [
+ drawVisible,
+ gainSamples,
+ pixelsPerSecond,
+ clipDurationSec,
+ retime,
+ sourceStartSec,
+ ]);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) {
+ return;
+ }
+
+ scrollParentRef.current = findScrollParent({ element: container });
+ const scrollParent = scrollParentRef.current;
+ if (!scrollParent) {
+ return;
+ }
+
+ const handleScroll = () => {
+ drawVisible();
+ };
+ scrollParent.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollParent.removeEventListener("scroll", handleScroll);
+ }, [drawVisible]);
+
+ const onResize = useCallback(
+ (entry: ResizeObserverEntry) => {
+ heightRef.current = entry.contentRect.height;
+ drawVisible();
+ },
+ [drawVisible],
+ );
+
+ useResizeObserver({ ref: containerRef, onResize });
+
+ return (
+ <div ref={containerRef} className={cn("relative size-full", className)}>
+ <canvas ref={canvasRef} className="absolute bottom-0" />
+ </div>
+ );
+}
diff --git a/apps/web/src/timeline/components/drag-line.tsx b/apps/web/src/timeline/components/drag-line.tsx
new file mode 100644
index 00000000..49621f5c
--- /dev/null
+++ b/apps/web/src/timeline/components/drag-line.tsx
@@ -0,0 +1,29 @@
+import { getDropLineY } from "./drop-target";
+import type { TimelineTrack, DropTarget } from "@/timeline";
+import { TIMELINE_LAYERS } from "./layers";
+
+interface DragLineProps {
+ dropTarget: DropTarget | null;
+ tracks: TimelineTrack[];
+ isVisible: boolean;
+ headerHeight?: number;
+}
+
+export function DragLine({
+ dropTarget,
+ tracks,
+ isVisible,
+ headerHeight = 0,
+}: DragLineProps) {
+ if (!isVisible || !dropTarget) return null;
+
+ const y = getDropLineY({ dropTarget, tracks });
+ const lineTop = y + headerHeight;
+
+ return (
+ <div
+ className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
+ style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
+ />
+ );
+}
diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts
new file mode 100644
index 00000000..be2495e4
--- /dev/null
+++ b/apps/web/src/timeline/components/drop-target.ts
@@ -0,0 +1,262 @@
+import type { TimelineTrack, TimelineElement } from "@/timeline";
+import type { ComputeDropTargetParams, DropTarget } from "@/timeline";
+import { resolveTrackPlacement } from "@/timeline/placement";
+import { TIMELINE_TRACK_GAP_PX } from "./layout";
+import { getTrackHeight } from "./track-layout";
+import { TICKS_PER_SECOND } from "@/wasm";
+
+function findElementAtPosition({
+ mouseX,
+ tracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+}: {
+ mouseX: number;
+ tracks: TimelineTrack[];
+ trackIndex: number;
+ targetElementTypes: string[];
+ pixelsPerSecond: number;
+ zoomLevel: number;
+}): { elementId: string; trackId: string } | null {
+ const time = Math.round(
+ (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
+ );
+ const track = tracks[trackIndex];
+ if (!track || !("elements" in track)) return null;
+
+ const hit = track.elements.find(
+ (element: TimelineElement) =>
+ targetElementTypes.includes(element.type) &&
+ element.startTime <= time &&
+ time < element.startTime + element.duration,
+ );
+ if (!hit) return null;
+ return { elementId: hit.id, trackId: track.id };
+}
+
+function getTrackAtY({
+ mouseY,
+ tracks,
+ verticalDragDirection,
+}: {
+ mouseY: number;
+ tracks: TimelineTrack[];
+ verticalDragDirection?: "up" | "down" | null;
+}): { trackIndex: number; relativeY: number } | null {
+ let cumulativeHeight = 0;
+
+ for (let i = 0; i < tracks.length; i++) {
+ const trackHeight = getTrackHeight({ type: tracks[i].type });
+ const trackTop = cumulativeHeight;
+ const trackBottom = trackTop + trackHeight;
+
+ if (mouseY >= trackTop && mouseY < trackBottom) {
+ return {
+ trackIndex: i,
+ relativeY: mouseY - trackTop,
+ };
+ }
+
+ if (i < tracks.length - 1 && verticalDragDirection) {
+ const gapTop = trackBottom;
+ const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX;
+ if (mouseY >= gapTop && mouseY < gapBottom) {
+ const isDraggingUp = verticalDragDirection === "up";
+ return {
+ trackIndex: isDraggingUp ? i : i + 1,
+ relativeY: isDraggingUp ? trackHeight - 1 : 0,
+ };
+ }
+ }
+
+ cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX;
+ }
+
+ return null;
+}
+
+const EMPTY_TARGET_ELEMENT = null;
+
+function fallbackNewTrackDropTarget({
+ xPosition,
+}: {
+ xPosition: number;
+}): DropTarget {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: null,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+}
+
+export function computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime,
+ isExternalDrop,
+ elementDuration,
+ pixelsPerSecond,
+ zoomLevel,
+ verticalDragDirection,
+ startTimeOverride,
+ excludeElementId,
+ targetElementTypes,
+}: ComputeDropTargetParams): DropTarget {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ const mainTrackIndex = tracks.overlay.length;
+ const xPosition =
+ typeof startTimeOverride === "number"
+ ? startTimeOverride
+ : isExternalDrop
+ ? playheadTime
+ : Math.round(
+ Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND,
+ );
+
+ if (orderedTracks.length === 0) {
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ elementType,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: 0,
+ hoverDirection: "below",
+ createNewTrackOnly: true,
+ },
+ });
+ const emptyTimelineResult =
+ placementResult?.kind === "newTrack" ? placementResult : null;
+ if (!emptyTimelineResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
+ }
+
+ return {
+ trackIndex: emptyTimelineResult.insertIndex,
+ isNewTrack: true,
+ insertPosition: emptyTimelineResult.insertPosition,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ const trackAtMouse = getTrackAtY({
+ mouseY,
+ tracks: orderedTracks,
+ verticalDragDirection,
+ });
+
+ if (!trackAtMouse) {
+ const isAboveAllTracks = mouseY < 0;
+
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ elementType,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1,
+ hoverDirection: isAboveAllTracks ? "above" : "below",
+ createNewTrackOnly: true,
+ },
+ });
+ const outOfBoundsResult =
+ placementResult?.kind === "newTrack" ? placementResult : null;
+ if (!outOfBoundsResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
+ }
+
+ return {
+ trackIndex: outOfBoundsResult.insertIndex,
+ isNewTrack: true,
+ insertPosition: outOfBoundsResult.insertPosition,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ const { trackIndex, relativeY } = trackAtMouse;
+ const track = orderedTracks[trackIndex];
+
+ if (targetElementTypes && targetElementTypes.length > 0) {
+ const targetElement = findElementAtPosition({
+ mouseX,
+ tracks: orderedTracks,
+ trackIndex,
+ targetElementTypes,
+ pixelsPerSecond,
+ zoomLevel,
+ });
+ if (targetElement) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ targetElement,
+ };
+ }
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ elementType,
+ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }],
+ strategy: {
+ type: "preferIndex",
+ trackIndex,
+ hoverDirection: relativeY < trackHeight / 2 ? "above" : "below",
+ verticalDragDirection,
+ },
+ });
+ if (!placementResult) {
+ return fallbackNewTrackDropTarget({ xPosition });
+ }
+
+ if (placementResult.kind === "existingTrack") {
+ const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition;
+
+ return {
+ trackIndex: placementResult.trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition: adjustedXPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+ }
+
+ return {
+ trackIndex: placementResult.insertIndex,
+ isNewTrack: true,
+ insertPosition: placementResult.insertPosition,
+ xPosition,
+ targetElement: EMPTY_TARGET_ELEMENT,
+ };
+}
+
+export function getDropLineY({
+ dropTarget,
+ tracks,
+}: {
+ dropTarget: DropTarget;
+ tracks: TimelineTrack[];
+}): number {
+ const safeTrackIndex = Math.min(
+ Math.max(dropTarget.trackIndex, 0),
+ tracks.length,
+ );
+ let y = 0;
+
+ for (let i = 0; i < safeTrackIndex; i++) {
+ y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX;
+ }
+
+ return y;
+}
diff --git a/apps/web/src/timeline/components/expanded-layout.ts b/apps/web/src/timeline/components/expanded-layout.ts
new file mode 100644
index 00000000..fae0e50c
--- /dev/null
+++ b/apps/web/src/timeline/components/expanded-layout.ts
@@ -0,0 +1,117 @@
+import type {
+ AnimationPath,
+ ElementAnimations,
+} from "@/animation/types";
+import type { TimelineTrack } from "@/timeline";
+import { getElementKeyframes } from "@/animation";
+import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
+
+export interface ExpandedRow {
+ propertyPath: AnimationPath;
+ label: string;
+}
+
+interface PropertyGroupDefinition {
+ matchesPath: (path: AnimationPath) => boolean;
+}
+
+const PROPERTY_GROUPS: PropertyGroupDefinition[] = [
+ { matchesPath: (path) => path.startsWith("transform.") || path === "opacity" },
+ { matchesPath: (path) => path === "volume" || path === "color" },
+ { matchesPath: (path) => path.startsWith("background.") },
+ { matchesPath: (path) => path.startsWith("params.") },
+ { matchesPath: (path) => path.startsWith("effects.") },
+];
+
+const PROPERTY_LABELS: Partial<Record<string, string>> = {
+ "transform.positionX": "Position X",
+ "transform.positionY": "Position Y",
+ "transform.scaleX": "Scale X",
+ "transform.scaleY": "Scale Y",
+ "transform.rotate": "Rotation",
+ opacity: "Opacity",
+ volume: "Volume",
+ color: "Color",
+ "background.color": "BG Color",
+ "background.paddingX": "BG Pad X",
+ "background.paddingY": "BG Pad Y",
+ "background.offsetX": "BG Offset X",
+ "background.offsetY": "BG Offset Y",
+ "background.cornerRadius": "Corner Radius",
+};
+
+export function getPropertyLabel(path: AnimationPath): string {
+ if (PROPERTY_LABELS[path]) return PROPERTY_LABELS[path];
+ if (path.startsWith("params.")) return path.slice("params.".length);
+ if (path.startsWith("effects.")) {
+ const parts = path.split(".");
+ return parts[parts.length - 1];
+ }
+ return path;
+}
+
+export function getExpandedRows({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ExpandedRow[] {
+ const keyframes = getElementKeyframes({ animations });
+ const propertyPaths = [...new Set(keyframes.map((kf) => kf.propertyPath))];
+ if (propertyPaths.length === 0) return [];
+
+ const rows: ExpandedRow[] = [];
+
+ for (const group of PROPERTY_GROUPS) {
+ const groupPaths = propertyPaths.filter((path) =>
+ group.matchesPath(path),
+ );
+ for (const path of groupPaths) {
+ rows.push({ propertyPath: path, label: getPropertyLabel(path) });
+ }
+ }
+
+ return rows;
+}
+
+export function getExpansionHeight({ rows }: { rows: ExpandedRow[] }): number {
+ return rows.length * KEYFRAME_LANE_HEIGHT_PX;
+}
+
+export function computeTrackExpansionHeight({
+ track,
+ expandedElementIds,
+}: {
+ track: TimelineTrack;
+ expandedElementIds: Set<string>;
+}): number {
+ let maxHeight = 0;
+ for (const element of track.elements) {
+ if (!expandedElementIds.has(element.id)) continue;
+ const rows = getExpandedRows({ animations: element.animations });
+ maxHeight = Math.max(maxHeight, getExpansionHeight({ rows }));
+ }
+ return maxHeight;
+}
+
+export function getTrackExpandedRows({
+ track,
+ expandedElementIds,
+}: {
+ track: TimelineTrack;
+ expandedElementIds: Set<string>;
+}): ExpandedRow[] {
+ let maxHeight = 0;
+ let maxRows: ExpandedRow[] = [];
+
+ for (const element of track.elements) {
+ if (!expandedElementIds.has(element.id)) continue;
+ const rows = getExpandedRows({ animations: element.animations });
+ const height = getExpansionHeight({ rows });
+ if (height > maxHeight) {
+ maxHeight = height;
+ maxRows = rows;
+ }
+ }
+
+ return maxRows;
+}
diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx
new file mode 100644
index 00000000..7d77b985
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { useRef, useState, type PointerEvent } from "react";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { getBezierPoint } from "@/animation/bezier";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import { cn } from "@/utils/ui";
+
+const GRAPH_WIDTH = 140;
+const GRAPH_HEIGHT = 94;
+const GRAPH_PADDING = 12;
+const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
+const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
+const HANDLE_RADIUS = 3.5;
+const ENDPOINT_RADIUS = 2;
+const SNAP_THRESHOLD = 0.06;
+const SNAP_TARGETS = [0, 1];
+const CURVE_SEGMENTS = 64;
+const Y_CLAMP_MIN = -0.5;
+const Y_CLAMP_MAX = 1.5;
+
+type BezierHandle = "c1" | "c2";
+
+export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
+
+function snap({
+ value,
+ targets,
+ isEnabled,
+}: {
+ value: number;
+ targets: number[];
+ isEnabled: boolean;
+}) {
+ if (!isEnabled) return value;
+ for (const target of targets) {
+ if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
+ }
+ return value;
+}
+
+function toSvgX({ value }: { value: number }) {
+ return GRAPH_PADDING + value * GRAPH_WIDTH;
+}
+
+function toSvgY({ value }: { value: number }) {
+ return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
+}
+
+function fromSvgX({ svgX }: { svgX: number }) {
+ return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
+}
+
+function fromSvgY({ svgY }: { svgY: number }) {
+ return Math.max(
+ Y_CLAMP_MIN,
+ Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
+ );
+}
+
+function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
+ const points: string[] = [];
+ for (let i = 0; i <= CURVE_SEGMENTS; i++) {
+ const progress = i / CURVE_SEGMENTS;
+ const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) });
+ const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) });
+ points.push(`${x},${y}`);
+ }
+ return `M${points.join("L")}`;
+}
+
+function clampHandleY({ svgY }: { svgY: number }) {
+ return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
+}
+
+export function BezierGraph({
+ value,
+ onChange,
+ onChangeEnd,
+ onCancel,
+}: {
+ value: NormalizedCubicBezier;
+ onChange?: (value: NormalizedCubicBezier) => void;
+ onChangeEnd?: (value: NormalizedCubicBezier) => void;
+ onCancel?: () => void;
+}) {
+ const svgRef = useRef<SVGSVGElement>(null);
+ const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
+ const isShiftPressedRef = useShiftKey();
+ const latestValueRef = useRef(value);
+
+ latestValueRef.current = value;
+
+ function getPointerPosition({
+ event,
+ }: {
+ event: PointerEvent;
+ }): { x: number; y: number } {
+ const svg = svgRef.current;
+ if (!svg) return { x: 0, y: 0 };
+ const rect = svg.getBoundingClientRect();
+ const scale = SVG_WIDTH / rect.width;
+ return {
+ x: (event.clientX - rect.left) * scale,
+ y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
+ };
+ }
+
+ function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
+ return (event: PointerEvent<SVGCircleElement>) => {
+ event.preventDefault();
+ event.stopPropagation();
+ setActiveHandle(handle);
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ }
+
+ function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
+ if (!activeHandle) return;
+ const pointerPos = getPointerPosition({ event });
+ const x = fromSvgX({ svgX: pointerPos.x });
+ const y = snap({
+ value: fromSvgY({ svgY: pointerPos.y }),
+ targets: SNAP_TARGETS,
+ isEnabled: !isShiftPressedRef.current,
+ });
+ const next: NormalizedCubicBezier = [...value];
+ if (activeHandle === "c1") {
+ next[0] = x;
+ next[1] = y;
+ } else {
+ next[2] = x;
+ next[3] = y;
+ }
+ latestValueRef.current = next;
+ onChange?.(next);
+ }
+
+ function onPointerUp() {
+ if (!activeHandle) return;
+ setActiveHandle(null);
+ onChangeEnd?.(latestValueRef.current);
+ }
+
+ function onPointerCancel() {
+ if (!activeHandle) return;
+ setActiveHandle(null);
+ onCancel?.();
+ }
+
+ const path = curvePath({ curve: value });
+ const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
+ const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
+ const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
+ const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
+ const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
+ const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
+
+ return (
+ <svg
+ ref={svgRef}
+ viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
+ className="bg-foreground/3 w-full cursor-crosshair select-none"
+ onPointerMove={(event) => onPointerMove({ event })}
+ onPointerUp={onPointerUp}
+ onPointerCancel={onPointerCancel}
+ >
+ <title>Bezier curve editor</title>
+ <line
+ x1={p0.x}
+ y1={p0.y}
+ x2={p1.x}
+ y2={p1.y}
+ className="stroke-foreground/8"
+ strokeWidth={1}
+ strokeDasharray="3 3"
+ />
+ <line
+ x1={p0.x}
+ y1={p0.y}
+ x2={c1Clamped.x}
+ y2={c1Clamped.y}
+ className="stroke-primary/30"
+ strokeWidth={1}
+ />
+ <line
+ x1={p1.x}
+ y1={p1.y}
+ x2={c2Clamped.x}
+ y2={c2Clamped.y}
+ className="stroke-primary/30"
+ strokeWidth={1}
+ />
+ <path
+ d={path}
+ fill="none"
+ className="stroke-primary"
+ strokeWidth={2}
+ strokeLinecap="round"
+ />
+ <circle
+ cx={p0.x}
+ cy={p0.y}
+ r={ENDPOINT_RADIUS}
+ className="fill-foreground/20"
+ />
+ <circle
+ cx={p1.x}
+ cy={p1.y}
+ r={ENDPOINT_RADIUS}
+ className="fill-foreground/20"
+ />
+ <circle
+ cx={c1Clamped.x}
+ cy={c1Clamped.y}
+ r={HANDLE_RADIUS}
+ className={cn(
+ "fill-primary cursor-grab",
+ activeHandle === "c1" && "cursor-grabbing",
+ )}
+ onPointerDown={onHandlePointerDown({ handle: "c1" })}
+ />
+ <circle
+ cx={c2Clamped.x}
+ cy={c2Clamped.y}
+ r={HANDLE_RADIUS}
+ className={cn(
+ "fill-primary cursor-grab",
+ activeHandle === "c2" && "cursor-grabbing",
+ )}
+ onPointerDown={onHandlePointerDown({ handle: "c2" })}
+ />
+ </svg>
+ );
+}
diff --git a/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts
new file mode 100644
index 00000000..4be2adfd
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { generateUUID } from "@/utils/id";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import type { EasingPreset } from "./easing-presets";
+
+const STORAGE_KEY = "graph-editor-presets";
+
+let cachedPresets: EasingPreset[] | null = null;
+const listeners = new Set<() => void>();
+
+function isValidPresetArray(value: unknown): value is EasingPreset[] {
+ return (
+ Array.isArray(value) &&
+ value.every(
+ (item) =>
+ typeof item === "object" &&
+ item !== null &&
+ typeof item.id === "string" &&
+ typeof item.label === "string" &&
+ Array.isArray(item.value) &&
+ item.value.length === 4 &&
+ item.value.every((number: unknown) => typeof number === "number"),
+ )
+ );
+}
+
+function readFromStorage(): EasingPreset[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ return isValidPresetArray(parsed) ? parsed : [];
+ } catch {
+ // Silently recover — corrupted localStorage shouldn't crash the editor
+ return [];
+ }
+}
+
+function writeToStorage({ presets }: { presets: EasingPreset[] }): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+}
+
+function getSnapshot(): EasingPreset[] {
+ cachedPresets ??= readFromStorage();
+ return cachedPresets;
+}
+
+function getServerSnapshot(): EasingPreset[] {
+ return [];
+}
+
+function notify(): void {
+ cachedPresets = null;
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function onStorageChange(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) notify();
+}
+
+function subscribe(listener: () => void): () => void {
+ if (listeners.size === 0 && typeof window !== "undefined") {
+ window.addEventListener("storage", onStorageChange);
+ }
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0 && typeof window !== "undefined") {
+ window.removeEventListener("storage", onStorageChange);
+ }
+ };
+}
+
+export function useCustomPresets(): EasingPreset[] {
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+}
+
+export function savePreset({ value }: { value: NormalizedCubicBezier }): void {
+ const current = getSnapshot();
+ writeToStorage({
+ presets: [
+ ...current,
+ {
+ id: generateUUID(),
+ label: `Custom ${current.length + 1}`,
+ value,
+ isCustom: true,
+ },
+ ],
+ });
+ notify();
+}
+
+export function removePreset({ id }: { id: string }): void {
+ writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) });
+ notify();
+}
diff --git a/apps/web/src/timeline/components/graph-editor/easing-presets.ts b/apps/web/src/timeline/components/graph-editor/easing-presets.ts
new file mode 100644
index 00000000..78a605d4
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/easing-presets.ts
@@ -0,0 +1,19 @@
+import type { NormalizedCubicBezier } from "@/animation/types";
+
+export const PRESET_MATCH_TOLERANCE = 0.02;
+
+export interface EasingPreset {
+ id: string;
+ label: string;
+ value: NormalizedCubicBezier;
+ isCustom?: boolean;
+}
+
+export const BUILTIN_PRESETS: EasingPreset[] = [
+ { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] },
+ { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] },
+ { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] },
+ { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] },
+ { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] },
+ { id: "linear", label: "Linear", value: [0, 0, 1, 1] },
+];
diff --git a/apps/web/src/timeline/components/graph-editor/popover.tsx b/apps/web/src/timeline/components/graph-editor/popover.tsx
new file mode 100644
index 00000000..1b8194c0
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/popover.tsx
@@ -0,0 +1,334 @@
+"use client";
+
+import { useState } from "react";
+import { Popover, PopoverContent } from "@/components/ui/popover";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowDown01Icon,
+ Delete02Icon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { getBezierPoint } from "@/animation/bezier";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import type { GraphEditorComponentOption } from "./session";
+import {
+ BUILTIN_PRESETS,
+ PRESET_MATCH_TOLERANCE,
+ type EasingPreset,
+} from "./easing-presets";
+import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store";
+import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph";
+
+const COLLAPSED_MAX = 6;
+const THUMB_SEGMENTS = 24;
+const THUMB_WIDTH = 40;
+const THUMB_HEIGHT = 22;
+const THUMB_PADDING_X = 4;
+const THUMB_PADDING_Y = 3;
+const COLLAPSED_GRID_MAX_HEIGHT = 120;
+const EXPANDED_GRID_MAX_HEIGHT = 240;
+
+export function GraphEditorPopover({
+ children,
+ side,
+ open,
+ onOpenChange,
+ value,
+ message,
+ componentOptions,
+ activeComponentKey,
+ onActiveComponentKeyChange,
+ onPreviewValue,
+ onCommitValue,
+ onCancelPreview,
+}: {
+ children: React.ReactNode;
+ side?: "top" | "bottom" | "left" | "right";
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ value: NormalizedCubicBezier | null;
+ message: string;
+ componentOptions: GraphEditorComponentOption[];
+ activeComponentKey: string | null;
+ onActiveComponentKeyChange?: (componentKey: string) => void;
+ onPreviewValue?: (value: NormalizedCubicBezier) => void;
+ onCommitValue?: (value: NormalizedCubicBezier) => void;
+ onCancelPreview?: () => void;
+}) {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const custom = useCustomPresets();
+ const allPresets = [...BUILTIN_PRESETS, ...custom];
+ const canEdit = value !== null;
+ const activePresetId =
+ value == null
+ ? null
+ : (allPresets.find((preset) =>
+ preset.value.every(
+ (presetValue, index) =>
+ Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE,
+ ),
+ )?.id ?? null);
+
+ return (
+ <Popover
+ open={open}
+ onOpenChange={(nextOpen) => {
+ if (!nextOpen) {
+ onCancelPreview?.();
+ }
+ onOpenChange?.(nextOpen);
+ }}
+ >
+ {children}
+ <PopoverContent
+ side={side}
+ sideOffset={8}
+ className="w-60 overflow-hidden px-0"
+ >
+ {componentOptions.length > 1 && (
+ <div className="border-b px-3 py-2">
+ <div className="bg-muted/40 inline-flex rounded-md p-0.5">
+ {componentOptions.map((component) => (
+ <button
+ key={component.key}
+ type="button"
+ onClick={() => onActiveComponentKeyChange?.(component.key)}
+ className={cn(
+ "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium",
+ activeComponentKey === component.key
+ ? "bg-background text-foreground shadow-xs"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {component.label}
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="px-3 py-3">
+ {value ? (
+ <BezierGraph
+ value={value}
+ onChange={onPreviewValue}
+ onChangeEnd={onCommitValue}
+ onCancel={onCancelPreview}
+ />
+ ) : (
+ <GraphEditorEmptyState message={message} />
+ )}
+ </div>
+
+ <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2">
+ <TabsList className="px-3">
+ <TabsTrigger value="presets" className="text-xs">
+ Presets
+ </TabsTrigger>
+ <TabsTrigger value="saved" className="text-xs">
+ Saved
+ </TabsTrigger>
+ </TabsList>
+ <TabsContent value="presets" className="px-3 pb-0">
+ <ExpandableGrid
+ isExpanded={isExpanded}
+ shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX}
+ onExpand={() => setIsExpanded(true)}
+ >
+ {BUILTIN_PRESETS.map((preset) => (
+ <PresetItem
+ key={preset.id}
+ preset={preset}
+ isActive={activePresetId === preset.id}
+ disabled={!canEdit}
+ onSelect={() => onCommitValue?.(preset.value)}
+ />
+ ))}
+ </ExpandableGrid>
+ </TabsContent>
+ <TabsContent value="saved" className="px-3">
+ <div className="grid grid-cols-3 gap-1">
+ {custom.map((preset) => (
+ <PresetItem
+ key={preset.id}
+ preset={preset}
+ isActive={activePresetId === preset.id}
+ disabled={!canEdit}
+ onSelect={() => onCommitValue?.(preset.value)}
+ onDelete={() => removePreset({ id: preset.id })}
+ />
+ ))}
+ <button
+ type="button"
+ onClick={() => value && savePreset({ value })}
+ disabled={!canEdit}
+ className={cn(
+ "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1",
+ canEdit
+ ? "hover:bg-foreground/5 cursor-pointer"
+ : "cursor-not-allowed opacity-50",
+ )}
+ >
+ <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed">
+ <HugeiconsIcon
+ icon={PlusSignIcon}
+ className="size-3.5 opacity-40"
+ />
+ </div>
+ <span className="text-[10px] leading-tight">Save</span>
+ </button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function GraphEditorEmptyState({ message }: { message: string }) {
+ return (
+ <div
+ style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }}
+ className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed"
+ >
+ {message}
+ </div>
+ );
+}
+
+function ExpandableGrid({
+ children,
+ isExpanded,
+ shouldExpand,
+ onExpand,
+}: {
+ children: React.ReactNode;
+ isExpanded: boolean;
+ shouldExpand: boolean;
+ onExpand: () => void;
+}) {
+ const gridStyle = shouldExpand
+ ? isExpanded
+ ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const }
+ : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const }
+ : undefined;
+
+ return (
+ <div className="relative">
+ <div className="grid grid-cols-3 gap-1" style={gridStyle}>
+ {children}
+ </div>
+ {!isExpanded && shouldExpand && (
+ <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5"
+ onClick={onExpand}
+ >
+ <HugeiconsIcon
+ icon={ArrowDown01Icon}
+ className="text-muted-foreground size-3"
+ />
+ </Button>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function PresetItem({
+ preset,
+ isActive,
+ onSelect,
+ onDelete,
+ disabled,
+}: {
+ preset: EasingPreset;
+ isActive: boolean;
+ onSelect: () => void;
+ onDelete?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onSelect}
+ disabled={disabled}
+ className={cn(
+ "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1",
+ disabled
+ ? "cursor-not-allowed opacity-50"
+ : "hover:bg-foreground/5 cursor-pointer",
+ isActive && "bg-primary/5! text-primary",
+ )}
+ >
+ <div
+ className={cn(
+ "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5",
+ isActive && "bg-primary/5!",
+ )}
+ >
+ <CurveThumb value={preset.value} />
+ </div>
+ <span
+ className={cn(
+ "text-[10px] leading-tight",
+ isActive ? "text-primary" : "text-muted-foreground",
+ )}
+ >
+ {preset.label}
+ </span>
+ {onDelete && (
+ <Button
+ variant="destructive"
+ size="icon"
+ className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex"
+ onClick={(event) => {
+ event.stopPropagation();
+ onDelete();
+ }}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ )}
+ </button>
+ );
+}
+
+function toThumbX({ value }: { value: number }) {
+ return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2);
+}
+
+function toThumbY({ value }: { value: number }) {
+ return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2);
+}
+
+function CurveThumb({ value }: { value: NormalizedCubicBezier }) {
+ const points: string[] = [];
+ for (let i = 0; i <= THUMB_SEGMENTS; i++) {
+ const progress = i / THUMB_SEGMENTS;
+ const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) });
+ const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) });
+ points.push(`${x},${y}`);
+ }
+ return (
+ <svg
+ width={THUMB_WIDTH}
+ height={THUMB_HEIGHT}
+ viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
+ >
+ <title>Curve preset preview</title>
+ <path
+ d={`M${points.join("L")}`}
+ fill="none"
+ className="stroke-current"
+ strokeWidth={1.5}
+ strokeLinecap="round"
+ />
+ </svg>
+ );
+}
diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts
new file mode 100644
index 00000000..d7c6b001
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/session.ts
@@ -0,0 +1,704 @@
+import {
+ getCurveHandlesForNormalizedCubicBezier,
+ getEditableScalarChannels,
+ getEasingModeForKind,
+ getNormalizedCubicBezierForScalarSegment,
+ getScalarKeyframeContext,
+ updateScalarKeyframeCurve,
+} from "@/animation";
+import type {
+ AnimationPath,
+ ElementAnimations,
+ NormalizedCubicBezier,
+ ScalarCurveKeyframePatch,
+ ScalarGraphKeyframeContext,
+ SelectedKeyframeRef,
+} from "@/animation/types";
+import type { SceneTracks, TimelineElement } from "@/timeline";
+
+const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1];
+const FLAT_VALUE_EPSILON = 1e-6;
+const LINEAR_CURVE_EPSILON = 1e-6;
+
+export type GraphEditorUnavailableReason =
+ | "no-keyframe-selected"
+ | "multiple-keyframes-selected"
+ | "selected-keyframes-span-multiple-elements"
+ | "selected-keyframes-are-not-adjacent"
+ | "selected-properties-have-no-shared-component"
+ | "selected-element-missing"
+ | "selected-element-has-no-animations"
+ | "selected-keyframe-has-no-scalar-channel"
+ | "selected-keyframe-missing-on-channel"
+ | "selected-keyframe-has-no-next-segment"
+ | "selected-segment-is-hold"
+ | "selected-segment-is-flat";
+
+export interface GraphEditorComponentOption {
+ key: string;
+ label: string;
+}
+
+interface GraphEditorPropertyOption {
+ key: string;
+ label: string;
+ context: ScalarGraphKeyframeContext;
+ allContexts: ScalarGraphKeyframeContext[];
+}
+
+export interface GraphEditorResolvedSegment {
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ context: ScalarGraphKeyframeContext;
+ allContexts: ScalarGraphKeyframeContext[];
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}
+
+interface GraphEditorBaseSelectionState {
+ componentOptions: GraphEditorComponentOption[];
+ activeComponentKey: string | null;
+ message: string;
+}
+
+export interface GraphEditorUnavailableState
+ extends GraphEditorBaseSelectionState {
+ status: "unavailable";
+ reason: GraphEditorUnavailableReason;
+}
+
+export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
+ status: "ready";
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ segments: GraphEditorResolvedSegment[];
+ cubicBezier: NormalizedCubicBezier;
+}
+
+export type GraphEditorSelectionState =
+ | GraphEditorUnavailableState
+ | GraphEditorReadyState;
+
+export interface GraphEditorCurvePatch {
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}
+
+function createUnavailableState({
+ reason,
+ message,
+ componentOptions = [],
+ activeComponentKey = null,
+}: {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ componentOptions?: GraphEditorComponentOption[];
+ activeComponentKey?: string | null;
+}): GraphEditorUnavailableState {
+ return {
+ status: "unavailable",
+ reason,
+ message,
+ componentOptions,
+ activeComponentKey,
+ };
+}
+
+function findElementByKeyframe({
+ tracks,
+ keyframe,
+}: {
+ tracks: SceneTracks;
+ keyframe: SelectedKeyframeRef;
+}): { element: TimelineElement; trackId: string; elementId: string } | null {
+ for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
+ if (track.id !== keyframe.trackId) {
+ continue;
+ }
+
+ const element = track.elements.find(
+ (trackElement) => trackElement.id === keyframe.elementId,
+ );
+ if (!element) {
+ return null;
+ }
+
+ return {
+ element,
+ trackId: track.id,
+ elementId: element.id,
+ };
+ }
+
+ return null;
+}
+
+function findKeyframeTime({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): number | null {
+ const binding = animations.bindings[propertyPath];
+ if (!binding) return null;
+
+ for (const component of binding.components) {
+ const channel = animations.channels[component.channelId];
+ if (channel?.kind !== "scalar") continue;
+ const key = channel.keys.find((k) => k.id === keyframeId);
+ if (key !== undefined) return key.time;
+ }
+
+ return null;
+}
+
+function groupSelectedKeyframesByProperty({
+ selectedKeyframes,
+}: {
+ selectedKeyframes: SelectedKeyframeRef[];
+}) {
+ const groups = new Map<
+ string,
+ {
+ trackId: string;
+ elementId: string;
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframes: SelectedKeyframeRef[];
+ }
+ >();
+
+ for (const keyframe of selectedKeyframes) {
+ const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`;
+ const existingGroup = groups.get(groupKey);
+ if (existingGroup) {
+ existingGroup.keyframes.push(keyframe);
+ continue;
+ }
+
+ groups.set(groupKey, {
+ trackId: keyframe.trackId,
+ elementId: keyframe.elementId,
+ propertyPath: keyframe.propertyPath,
+ keyframes: [keyframe],
+ });
+ }
+
+ return [...groups.values()];
+}
+
+function getComponentLabel({ componentKey }: { componentKey: string }): string {
+ switch (componentKey) {
+ case "value":
+ return "Value";
+ default:
+ return componentKey.toUpperCase();
+ }
+}
+
+/**
+ * Returns the absolute value span of the nearest non-flat adjacent segment,
+ * used as the Y-axis scale when editing a flat segment in the graph editor.
+ * Falls back to 1.0 if all surrounding segments are also flat.
+ */
+function getReferenceSpanValue({
+ context,
+}: {
+ context: ScalarGraphKeyframeContext;
+}): number {
+ const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time);
+ const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id);
+ const rightIndex = context.nextKey
+ ? sorted.findIndex((k) => k.id === context.nextKey?.id)
+ : -1;
+
+ for (let i = leftIndex - 1; i >= 0; i--) {
+ const span = Math.abs(sorted[i + 1].value - sorted[i].value);
+ if (span > FLAT_VALUE_EPSILON) return span;
+ }
+
+ if (rightIndex !== -1) {
+ for (let i = rightIndex; i < sorted.length - 1; i++) {
+ const span = Math.abs(sorted[i + 1].value - sorted[i].value);
+ if (span > FLAT_VALUE_EPSILON) return span;
+ }
+ }
+
+ return 1.0;
+}
+
+interface GraphEditorPropertySelection {
+ propertyPath: SelectedKeyframeRef["propertyPath"];
+ keyframeId: string;
+ secondaryKeyframeId: string | null;
+ options: GraphEditorPropertyOption[];
+}
+
+function resolvePropertySelection({
+ element,
+ propertyKeyframes,
+}: {
+ element: TimelineElement;
+ propertyKeyframes: ReturnType<
+ typeof groupSelectedKeyframesByProperty
+ >[number];
+}):
+ | GraphEditorPropertySelection
+ | {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ } {
+ if (propertyKeyframes.keyframes.length > 2) {
+ return {
+ reason: "multiple-keyframes-selected",
+ message: "Select at most two adjacent keyframes per property.",
+ };
+ }
+
+ if (!element.animations) {
+ return {
+ reason: "selected-element-has-no-animations",
+ message: "The selected keyframe has no editable graph.",
+ };
+ }
+
+ const scalarResult = getEditableScalarChannels({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ });
+ if (!scalarResult || scalarResult.channels.length === 0) {
+ return {
+ reason: "selected-keyframe-has-no-scalar-channel",
+ message: "The selected keyframe has no editable graph channel.",
+ };
+ }
+
+ const primaryKeyframe = propertyKeyframes.keyframes[0];
+ let resolvedKeyframeId = primaryKeyframe.keyframeId;
+ let secondaryKeyframeId =
+ propertyKeyframes.keyframes.length === 2
+ ? propertyKeyframes.keyframes[1].keyframeId
+ : null;
+
+ if (secondaryKeyframeId !== null) {
+ const time1 = findKeyframeTime({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: primaryKeyframe.keyframeId,
+ });
+ const time2 = findKeyframeTime({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: secondaryKeyframeId,
+ });
+ if (time2 !== null && (time1 === null || time2 < time1)) {
+ resolvedKeyframeId = secondaryKeyframeId;
+ secondaryKeyframeId = primaryKeyframe.keyframeId;
+ }
+ }
+
+ const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
+ const easingMode = getEasingModeForKind(resolvedBinding.kind);
+ const contexts = scalarChannels.flatMap((channel) => {
+ const context = getScalarKeyframeContext({
+ animations: element.animations,
+ propertyPath: propertyKeyframes.propertyPath,
+ componentKey: channel.componentKey,
+ keyframeId: resolvedKeyframeId,
+ });
+ if (!context) {
+ return [];
+ }
+
+ return [
+ {
+ context,
+ option: {
+ key: channel.componentKey,
+ label: getComponentLabel({ componentKey: channel.componentKey }),
+ },
+ },
+ ];
+ });
+
+ if (contexts.length === 0) {
+ return {
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ };
+ }
+
+ // For shared-easing bindings (e.g. color), all components always use the same
+ // curve. Collapse them to a single "value" option so the key is compatible with
+ // single-component scalar bindings (e.g. opacity), enabling mixed selections.
+ const options =
+ easingMode === "shared"
+ ? [
+ {
+ key: "value",
+ label: "Curve",
+ context: contexts[0].context,
+ allContexts: contexts.map(({ context }) => context),
+ },
+ ]
+ : contexts.map(({ context, option }) => ({
+ key: option.key,
+ label: option.label,
+ context,
+ allContexts: [context],
+ }));
+
+ return {
+ propertyPath: propertyKeyframes.propertyPath,
+ keyframeId: resolvedKeyframeId,
+ secondaryKeyframeId,
+ options,
+ };
+}
+
+function isLinearCurve({
+ cubicBezier,
+}: {
+ cubicBezier: NormalizedCubicBezier;
+}): boolean {
+ return (
+ Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
+ Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
+ );
+}
+
+function resolveSegmentForOption({
+ propertySelection,
+ componentKey,
+}: {
+ propertySelection: GraphEditorPropertySelection;
+ componentKey: string;
+}):
+ | {
+ segment: GraphEditorResolvedSegment;
+ }
+ | {
+ reason: GraphEditorUnavailableReason;
+ message: string;
+ } {
+ const option = propertySelection.options.find(
+ (propertyOption) => propertyOption.key === componentKey,
+ );
+ if (!option) {
+ return {
+ reason: "selected-properties-have-no-shared-component",
+ message: "Selected properties do not share a graph-editable channel.",
+ };
+ }
+
+ if (!option.context.nextKey) {
+ return {
+ reason: "selected-keyframe-has-no-next-segment",
+ message: "Select a keyframe that has an outgoing segment.",
+ };
+ }
+
+ if (
+ propertySelection.secondaryKeyframeId !== null &&
+ option.context.nextKey.id !== propertySelection.secondaryKeyframeId
+ ) {
+ return {
+ reason: "selected-keyframes-are-not-adjacent",
+ message: "Selected keyframes must be adjacent on each property.",
+ };
+ }
+
+ if (option.context.keyframe.segmentToNext === "step") {
+ return {
+ reason: "selected-segment-is-hold",
+ message: "Hold segments have a fixed value - easing has no effect here.",
+ };
+ }
+
+ const referenceSpanValue = getReferenceSpanValue({ context: option.context });
+ const cubicBezier =
+ option.context.keyframe.segmentToNext === "linear"
+ ? GRAPH_LINEAR_CURVE
+ : getNormalizedCubicBezierForScalarSegment({
+ leftKey: option.context.keyframe,
+ rightKey: option.context.nextKey,
+ referenceSpanValue,
+ });
+ if (!cubicBezier) {
+ return {
+ reason: "selected-segment-is-flat",
+ message:
+ "Cannot edit a segment where both keyframes are at the same time.",
+ };
+ }
+
+ return {
+ segment: {
+ propertyPath: propertySelection.propertyPath,
+ keyframeId: propertySelection.keyframeId,
+ context: option.context,
+ allContexts: option.allContexts,
+ cubicBezier,
+ referenceSpanValue,
+ },
+ };
+}
+
+export function resolveGraphEditorSelectionState({
+ tracks,
+ selectedKeyframes,
+ preferredComponentKey,
+}: {
+ tracks: SceneTracks;
+ selectedKeyframes: SelectedKeyframeRef[];
+ preferredComponentKey?: string | null;
+}): GraphEditorSelectionState {
+ if (selectedKeyframes.length === 0) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ const propertyKeyframes = groupSelectedKeyframesByProperty({
+ selectedKeyframes,
+ });
+ const primaryKeyframe = propertyKeyframes[0]?.keyframes[0];
+ if (!primaryKeyframe) {
+ return createUnavailableState({
+ reason: "no-keyframe-selected",
+ message: "Select a keyframe to edit its curve.",
+ });
+ }
+
+ const selectedElement = findElementByKeyframe({
+ tracks,
+ keyframe: primaryKeyframe,
+ });
+ if (!selectedElement) {
+ return createUnavailableState({
+ reason: "selected-element-missing",
+ message: "The selected keyframe could not be resolved.",
+ });
+ }
+
+ const spansMultipleElements = propertyKeyframes.some(
+ (propertySelection) =>
+ propertySelection.trackId !== selectedElement.trackId ||
+ propertySelection.elementId !== selectedElement.elementId,
+ );
+ if (spansMultipleElements) {
+ return createUnavailableState({
+ reason: "selected-keyframes-span-multiple-elements",
+ message: "Selected keyframes must be on the same element.",
+ });
+ }
+
+ const propertySelections = propertyKeyframes.map((propertySelection) =>
+ resolvePropertySelection({
+ element: selectedElement.element,
+ propertyKeyframes: propertySelection,
+ }),
+ );
+ const unavailablePropertySelection = propertySelections.find(
+ (propertySelection) => "reason" in propertySelection,
+ );
+ if (
+ unavailablePropertySelection &&
+ "reason" in unavailablePropertySelection
+ ) {
+ return createUnavailableState({
+ reason: unavailablePropertySelection.reason,
+ message: unavailablePropertySelection.message,
+ });
+ }
+
+ const resolvedPropertySelections = propertySelections.filter(
+ (propertySelection): propertySelection is GraphEditorPropertySelection =>
+ !("reason" in propertySelection),
+ );
+ const sharedComponentOptions =
+ resolvedPropertySelections[0]?.options.filter((componentOption) =>
+ resolvedPropertySelections.every((propertySelection) =>
+ propertySelection.options.some(
+ (option) => option.key === componentOption.key,
+ ),
+ ),
+ ) ?? [];
+ const componentOptions = sharedComponentOptions.map(({ key, label }) => ({
+ key,
+ label,
+ }));
+ if (componentOptions.length === 0) {
+ return createUnavailableState({
+ reason: "selected-properties-have-no-shared-component",
+ message: "Selected properties do not share a graph-editable channel.",
+ });
+ }
+
+ // Try each component option in preference order (preferred first, then the rest)
+ // and stop at the first key where every property resolves to a valid segment.
+ // This single pass both selects the active key and produces the segment list.
+ const candidateKeys = [
+ ...(preferredComponentKey &&
+ componentOptions.some((option) => option.key === preferredComponentKey)
+ ? [preferredComponentKey]
+ : []),
+ ...componentOptions
+ .filter((option) => option.key !== preferredComponentKey)
+ .map((option) => option.key),
+ ];
+
+ let activeComponentKey = componentOptions[0].key;
+ let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = [];
+
+ for (const candidateKey of candidateKeys) {
+ const results = resolvedPropertySelections.map((propertySelection) =>
+ resolveSegmentForOption({
+ propertySelection,
+ componentKey: candidateKey,
+ }),
+ );
+ activeComponentKey = candidateKey;
+ segmentResults = results;
+ if (results.every((result) => "segment" in result)) {
+ break;
+ }
+ }
+
+ const unavailableSegment = segmentResults.find(
+ (result) => !("segment" in result),
+ );
+ if (unavailableSegment && !("segment" in unavailableSegment)) {
+ return createUnavailableState({
+ reason: unavailableSegment.reason,
+ message: unavailableSegment.message,
+ componentOptions,
+ activeComponentKey,
+ });
+ }
+
+ const segments = segmentResults.flatMap((result) =>
+ "segment" in result ? [result.segment] : [],
+ );
+ const primarySegment = segments[0];
+ if (!primarySegment) {
+ return createUnavailableState({
+ reason: "selected-keyframe-missing-on-channel",
+ message: "The selected keyframe is not editable as a graph segment.",
+ componentOptions,
+ activeComponentKey,
+ });
+ }
+
+ return {
+ status: "ready",
+ message:
+ segments.length === 1
+ ? "Edit graph"
+ : `Edit graph for ${segments.length} properties`,
+ componentOptions,
+ activeComponentKey,
+ trackId: selectedElement.trackId,
+ elementId: selectedElement.elementId,
+ element: selectedElement.element,
+ segments,
+ cubicBezier: primarySegment.cubicBezier,
+ };
+}
+
+export function buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ referenceSpanValue,
+}: {
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}): GraphEditorCurvePatch[] | null {
+ if (!context.nextKey) {
+ return null;
+ }
+
+ if (isLinearCurve({ cubicBezier })) {
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "linear",
+ rightHandle: null,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: null,
+ },
+ },
+ ];
+ }
+
+ const handles = getCurveHandlesForNormalizedCubicBezier({
+ leftKey: context.keyframe,
+ rightKey: context.nextKey,
+ cubicBezier,
+ referenceSpanValue,
+ });
+ if (!handles) {
+ return null;
+ }
+
+ return [
+ {
+ keyframeId: context.keyframe.id,
+ patch: {
+ segmentToNext: "bezier",
+ rightHandle: handles.rightHandle,
+ },
+ },
+ {
+ keyframeId: context.nextKey.id,
+ patch: {
+ leftHandle: handles.leftHandle,
+ },
+ },
+ ];
+}
+
+export function applyGraphEditorCurvePreview({
+ animations,
+ context,
+ cubicBezier,
+ referenceSpanValue,
+}: {
+ animations: ElementAnimations | undefined;
+ context: ScalarGraphKeyframeContext;
+ cubicBezier: NormalizedCubicBezier;
+ referenceSpanValue: number;
+}): ElementAnimations | undefined {
+ const patches = buildGraphEditorCurvePatches({
+ context,
+ cubicBezier,
+ referenceSpanValue,
+ });
+ if (!patches) {
+ return animations;
+ }
+
+ return patches.reduce<ElementAnimations | undefined>(
+ (nextAnimations, { keyframeId, patch }) =>
+ updateScalarKeyframeCurve({
+ animations: nextAnimations,
+ propertyPath: context.propertyPath,
+ componentKey: context.componentKey,
+ keyframeId,
+ patch,
+ }),
+ animations,
+ );
+}
diff --git a/apps/web/src/timeline/components/graph-editor/use-controller.ts b/apps/web/src/timeline/components/graph-editor/use-controller.ts
new file mode 100644
index 00000000..ea091485
--- /dev/null
+++ b/apps/web/src/timeline/components/graph-editor/use-controller.ts
@@ -0,0 +1,178 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEditor } from "@/editor/use-editor";
+import { registerCanceller } from "@/editor/cancel-interaction";
+import type { NormalizedCubicBezier } from "@/animation/types";
+import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
+import {
+ applyGraphEditorCurvePreview,
+ buildGraphEditorCurvePatches,
+ resolveGraphEditorSelectionState,
+ type GraphEditorSelectionState,
+} from "./session";
+
+export function useGraphEditorController() {
+ const editor = useEditor();
+ const renderTracks = useEditor(
+ (currentEditor) =>
+ currentEditor.timeline.getPreviewTracks() ??
+ currentEditor.scenes.getActiveScene().tracks,
+ );
+ const { selectedKeyframes } = useKeyframeSelection();
+ const [open, setOpen] = useState(false);
+ const [activeComponentKey, setActiveComponentKey] = useState<string | null>(
+ null,
+ );
+ const hasPreviewRef = useRef(false);
+
+ const state = useMemo<GraphEditorSelectionState>(
+ () =>
+ resolveGraphEditorSelectionState({
+ tracks: renderTracks,
+ selectedKeyframes,
+ preferredComponentKey: activeComponentKey,
+ }),
+ [activeComponentKey, renderTracks, selectedKeyframes],
+ );
+
+ const stateKey =
+ state.status === "ready"
+ ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments
+ .map(
+ (segment) =>
+ `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`,
+ )
+ .join("|")}`
+ : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
+ const previousStateKeyRef = useRef(stateKey);
+
+ const discardPreview = useCallback(() => {
+ if (!hasPreviewRef.current) {
+ return;
+ }
+
+ editor.timeline.discardPreview();
+ hasPreviewRef.current = false;
+ }, [editor]);
+
+ useEffect(() => {
+ if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) {
+ discardPreview();
+ }
+
+ previousStateKeyRef.current = stateKey;
+ }, [discardPreview, stateKey]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ return registerCanceller({
+ fn: () => {
+ discardPreview();
+ setOpen(false);
+ },
+ });
+ }, [discardPreview, open]);
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (!nextOpen) {
+ discardPreview();
+ }
+
+ setOpen(nextOpen);
+ },
+ [discardPreview],
+ );
+
+ const handleActiveComponentKeyChange = useCallback(
+ (nextComponentKey: string) => {
+ discardPreview();
+ setActiveComponentKey(nextComponentKey);
+ },
+ [discardPreview],
+ );
+
+ const handlePreviewValue = useCallback(
+ (nextValue: NormalizedCubicBezier) => {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ const nextAnimations = state.segments.reduce(
+ (animations, segment) =>
+ segment.allContexts.reduce(
+ (nextAnimationsForSegment, context) =>
+ applyGraphEditorCurvePreview({
+ animations: nextAnimationsForSegment,
+ context,
+ cubicBezier: nextValue,
+ referenceSpanValue: segment.referenceSpanValue,
+ }),
+ animations,
+ ),
+ state.element.animations,
+ );
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: state.trackId,
+ elementId: state.elementId,
+ updates: { animations: nextAnimations },
+ },
+ ],
+ });
+ hasPreviewRef.current = true;
+ },
+ [editor, state],
+ );
+
+ const handleCommitValue = useCallback(
+ (nextValue: NormalizedCubicBezier) => {
+ if (state.status !== "ready") {
+ return;
+ }
+
+ editor.timeline.updateKeyframeCurves({
+ keyframes: state.segments.flatMap((segment) => {
+ const patches = buildGraphEditorCurvePatches({
+ context: segment.context,
+ cubicBezier: nextValue,
+ referenceSpanValue: segment.referenceSpanValue,
+ });
+ if (!patches) {
+ return [];
+ }
+
+ return segment.allContexts.flatMap((context) =>
+ patches.map(({ keyframeId, patch }) => ({
+ trackId: state.trackId,
+ elementId: state.elementId,
+ propertyPath: segment.propertyPath,
+ componentKey: context.componentKey,
+ keyframeId,
+ patch,
+ })),
+ );
+ }),
+ });
+ hasPreviewRef.current = false;
+ },
+ [editor, state],
+ );
+
+ return {
+ open,
+ onOpenChange: handleOpenChange,
+ canOpen: state.status === "ready",
+ tooltip: state.status === "ready" ? "Open graph editor" : state.message,
+ state,
+ onActiveComponentKeyChange: handleActiveComponentKeyChange,
+ onPreviewValue: handlePreviewValue,
+ onCommitValue: handleCommitValue,
+ onCancelPreview: discardPreview,
+ };
+}
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx
new file mode 100644
index 00000000..e6a9df49
--- /dev/null
+++ b/apps/web/src/timeline/components/index.tsx
@@ -0,0 +1,946 @@
+"use client";
+
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Delete02Icon,
+ MagicWand05Icon,
+ MusicNote03Icon,
+ TaskAdd02Icon,
+ TextIcon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
+import { OcShapesIcon, OcVideoIcon } from "@/components/icons";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import { useTimelineZoom } from "@/timeline/hooks/use-timeline-zoom";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import type { ElementDragState, DropTarget } from "@/timeline";
+import { TimelineTrackContent } from "./timeline-track";
+import { TimelinePlayhead } from "./timeline-playhead";
+import { SelectionBox } from "@/selection/selection-box";
+import { useBoxSelect } from "@/selection/hooks/use-box-select";
+import { SnapIndicator } from "./snap-indicator";
+import type { SnapPoint } from "@/timeline/snapping";
+import type { TimelineTrack } from "@/timeline";
+import {
+ TIMELINE_SCROLLBAR_SIZE_PX,
+ TIMELINE_CONTENT_TOP_PADDING_PX,
+ TIMELINE_TRACK_GAP_PX,
+ TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX,
+ KEYFRAME_LANE_HEIGHT_PX,
+} from "./layout";
+import { useElementInteraction } from "@/timeline/hooks/element/use-element-interaction";
+import {
+ canTrackHaveAudio,
+ canTrackBeHidden,
+ getTimelineZoomMin,
+ getTimelinePaddingPx,
+} from "@/timeline";
+import { timelineTimeToPixels } from "@/timeline/pixel-utils";
+import {
+ getTrackHeight,
+ getCumulativeHeightBefore,
+ getTotalTracksHeight,
+} from "./track-layout";
+import { SELECTED_TRACK_ROW_CLASS } from "./theme";
+import {
+ computeTrackExpansionHeight,
+ getTrackExpandedRows,
+ getPropertyLabel,
+ type ExpandedRow,
+} from "./expanded-layout";
+import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction";
+import { TimelineToolbar } from "./timeline-toolbar";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek";
+import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop";
+import { TimelineRuler } from "./timeline-ruler";
+import {
+ TimelineBookmarksRow,
+ useBookmarkDrag,
+} from "@/timeline/bookmarks/index";
+import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
+import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom";
+import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize";
+import { useTimelineStore } from "@/timeline/timeline-store";
+import { useEditor } from "@/editor/use-editor";
+import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
+import { DragLine } from "./drag-line";
+import { invokeAction } from "@/actions";
+import { resolveTimelineElementIntersections } from "./selection-hit-testing";
+import { cn } from "@/utils/ui";
+
+const TRACKS_CONTAINER_MAX_HEIGHT = 800;
+const FALLBACK_CONTAINER_WIDTH = 1000;
+const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT };
+const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = {
+ video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />,
+ text: (
+ <HugeiconsIcon
+ icon={TextIcon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ audio: (
+ <HugeiconsIcon
+ icon={MusicNote03Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+ graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />,
+ effect: (
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="text-muted-foreground size-4 shrink-0"
+ />
+ ),
+};
+
+export function Timeline() {
+ const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
+ const {
+ selectedElements,
+ clearElementSelection,
+ setElementSelection,
+ mergeElementsIntoSelection,
+ } = useElementSelection();
+ const editor = useEditor();
+ const timeline = editor.timeline;
+ const scene = useEditor((currentEditor) =>
+ currentEditor.scenes.getActiveSceneOrNull(),
+ );
+ const tracks = useMemo<TimelineTrack[]>(
+ () =>
+ scene
+ ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
+ : [],
+ [scene],
+ );
+ const mainTrackId = scene?.tracks.main.id ?? null;
+ const seek = (time: number) => editor.playback.seek({ time });
+
+ const timelineRef = useRef<HTMLDivElement>(null);
+ const timelineHeaderRef = useRef<HTMLDivElement>(null);
+ const rulerRef = useRef<HTMLDivElement>(null);
+ const rulerScrollRef = useRef<HTMLDivElement>(null);
+ const tracksContainerRef = useRef<HTMLDivElement>(null);
+ const tracksScrollRef = useRef<HTMLDivElement>(null);
+ const trackLabelsRef = useRef<HTMLDivElement>(null);
+ const playheadRef = useRef<HTMLDivElement>(null);
+ const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
+
+ const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
+ null,
+ );
+
+ const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
+ setCurrentSnapPoint(snapPoint);
+ }, []);
+
+ const timelineDuration = timeline.getTotalDuration() || 0;
+ const minZoomLevel = getTimelineZoomMin({
+ duration: timelineDuration,
+ containerWidth: tracksContainerRef.current?.clientWidth,
+ });
+
+ const savedViewState = editor.project.getTimelineViewState();
+
+ const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } =
+ useTimelineZoom({
+ containerRef: timelineRef,
+ minZoom: minZoomLevel,
+ initialZoom: savedViewState?.zoomLevel,
+ initialScrollLeft: savedViewState?.scrollLeft,
+ initialPlayheadTime: savedViewState?.playheadTime,
+ tracksScrollRef,
+ rulerScrollRef,
+ });
+ const { isResizing, handleResizeStart } = useTimelineResize({
+ zoomLevel,
+ onSnapPointChange: handleSnapPointChange,
+ });
+
+ const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
+
+ const getTrackExpansionHeight = useCallback(
+ (trackIndex: number) => {
+ const track = tracks[trackIndex];
+ if (!track) return 0;
+ return computeTrackExpansionHeight({ track, expandedElementIds });
+ },
+ [tracks, expandedElementIds],
+ );
+
+ // Stable refs so the wheel listener never goes stale
+ const setZoomLevelRef = useRef(setZoomLevel);
+ useEffect(() => {
+ setZoomLevelRef.current = setZoomLevel;
+ }, [setZoomLevel]);
+
+ const saveScrollPositionRef = useRef(saveScrollPosition);
+ useEffect(() => {
+ saveScrollPositionRef.current = saveScrollPosition;
+ }, [saveScrollPosition]);
+
+ const minZoomLevelRef = useRef(minZoomLevel);
+ useEffect(() => {
+ minZoomLevelRef.current = minZoomLevel;
+ }, [minZoomLevel]);
+
+ // Pushes tracks scroll position to the two overflow:hidden followers
+ // (ruler and track labels). Called from the wheel handler (before paint,
+ // zero lag) and from onScroll on the tracks area (covers scrollbar drag).
+ const syncFollowers = useCallback(() => {
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = tracks.scrollLeft;
+ }
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = tracks.scrollTop;
+ }
+ }, []);
+
+ // Single non-passive capture listener owns all wheel input. Prevents any
+ // native scroll or browser zoom from firing inside the timeline.
+ useEffect(() => {
+ const container = timelineRef.current;
+ if (!container) return;
+
+ let pendingZoomDelta = 0;
+ let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null;
+
+ const onWheel = (e: WheelEvent) => {
+ const isZoom = e.ctrlKey || e.metaKey;
+
+ if (isZoom) {
+ e.preventDefault();
+ const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
+ pendingZoomDelta += normalizedDelta;
+
+ if (zoomRafId === null) {
+ zoomRafId = requestAnimationFrame(() => {
+ const frameRawDelta = pendingZoomDelta;
+ const cappedDelta =
+ Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevelRef.current((prev) => prev * zoomFactor);
+ pendingZoomDelta = 0;
+ zoomRafId = null;
+ });
+ }
+ return;
+ }
+
+ const tracks = tracksScrollRef.current;
+ if (!tracks) return;
+
+ const isHorizontal =
+ e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY);
+
+ e.preventDefault();
+
+ if (isHorizontal) {
+ const raw =
+ Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
+ const clamped =
+ Math.sign(raw) *
+ Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX);
+ tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped);
+ } else {
+ tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY);
+ }
+
+ syncFollowers();
+ saveScrollPositionRef.current();
+ };
+
+ container.addEventListener("wheel", onWheel, {
+ passive: false,
+ capture: true,
+ });
+ return () => {
+ container.removeEventListener("wheel", onWheel, { capture: true });
+ if (zoomRafId !== null) cancelAnimationFrame(zoomRafId);
+ };
+ }, [syncFollowers]);
+
+ useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll: () => saveScrollPositionRef.current(),
+ isReady: tracks.length > 0,
+ });
+
+ const {
+ dragState,
+ dragDropTarget,
+ handleElementMouseDown,
+ handleElementClick,
+ lastMouseXRef,
+ } = useElementInteraction({
+ zoomLevel,
+ timelineRef,
+ tracksContainerRef,
+ tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
+
+ const {
+ dragState: bookmarkDragState,
+ handleBookmarkMouseDown,
+ lastMouseXRef: bookmarkLastMouseXRef,
+ } = useBookmarkDrag({
+ zoomLevel,
+ scrollRef: tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
+
+ const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
+ useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({
+ containerRef: tracksContainerRef,
+ tracksScrollRef,
+ zoomLevel,
+ });
+
+ const {
+ selectionBox,
+ handleMouseDown: handleSelectionMouseDown,
+ isSelecting,
+ shouldIgnoreClick,
+ } = useBoxSelect({
+ containerRef: tracksContainerRef,
+ selectedIds: selectedElements,
+ anchorId: null,
+ getIsAdditiveSelection: (event) =>
+ event.shiftKey || event.ctrlKey || event.metaKey,
+ resolveIntersections: ({ startPos, currentPos }) => {
+ if (!tracksContainerRef.current) {
+ return [];
+ }
+
+ return resolveTimelineElementIntersections({
+ container: tracksContainerRef.current,
+ scrollContainer: tracksScrollRef.current,
+ tracks,
+ zoomLevel,
+ startPos,
+ currentPos,
+ });
+ },
+ onSelectionChange: ({ intersectedIds, isAdditive }) => {
+ if (isAdditive) {
+ mergeElementsIntoSelection({ elements: intersectedIds });
+ } else {
+ setElementSelection({ elements: intersectedIds });
+ }
+ },
+ });
+
+ const containerWidth =
+ tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH;
+ const contentWidth = timelineTimeToPixels({
+ time: timelineDuration,
+ zoomLevel,
+ });
+ const paddingPx = getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom: minZoomLevel,
+ });
+ const dynamicTimelineWidth = Math.max(
+ contentWidth + paddingPx,
+ containerWidth,
+ );
+ const tracksViewportWidth =
+ tracksScrollRef.current?.clientWidth ??
+ tracksContainerRef.current?.clientWidth ??
+ containerWidth;
+ const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth;
+
+ useEdgeAutoScroll({
+ isActive: bookmarkDragState.isDragging,
+ getMouseClientX: () => bookmarkLastMouseXRef.current,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: dynamicTimelineWidth,
+ });
+
+ const showSnapIndicator =
+ snappingEnabled &&
+ currentSnapPoint !== null &&
+ (dragState.isDragging || bookmarkDragState.isDragging || isResizing);
+
+ const {
+ handleTracksMouseDown,
+ handleTracksClick,
+ handleRulerMouseDown,
+ handleRulerClick,
+ } = useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration: timeline.getTotalDuration(),
+ isSelecting,
+ clearSelectedElements: clearElementSelection,
+ seek,
+ });
+
+ const timelineHeaderHeight =
+ (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) +
+ TIMELINE_CONTENT_TOP_PADDING_PX || 0;
+
+ return (
+ <section
+ className={
+ "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border"
+ }
+ {...dragProps}
+ aria-label="Timeline"
+ >
+ <TimelineToolbar
+ zoomLevel={zoomLevel}
+ minZoom={minZoomLevel}
+ setZoomLevel={({ zoom }) => setZoomLevel(zoom)}
+ />
+
+ <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}>
+ <TrackLabelsPanel
+ trackLabelsRef={trackLabelsRef}
+ trackLabelsScrollRef={trackLabelsScrollRef}
+ timelineHeaderHeight={timelineHeaderHeight}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
+ getTrackExpansionHeight={getTrackExpansionHeight}
+ />
+
+ <div
+ className="relative isolate flex flex-1 flex-col overflow-hidden"
+ ref={tracksContainerRef}
+ >
+ <SelectionBox
+ startPos={selectionBox?.startPos || null}
+ currentPos={selectionBox?.currentPos || null}
+ containerRef={tracksContainerRef}
+ isActive={selectionBox?.isActive || false}
+ />
+ <DragLine
+ dropTarget={dropTarget}
+ tracks={tracks}
+ isVisible={isDragOver && !dropTarget?.targetElement}
+ headerHeight={timelineHeaderHeight}
+ />
+ <DragLine
+ dropTarget={dragDropTarget}
+ tracks={tracks}
+ isVisible={dragState.isDragging}
+ headerHeight={timelineHeaderHeight}
+ />
+
+ <div ref={rulerScrollRef} className="shrink-0 overflow-hidden">
+ <div
+ ref={timelineHeaderRef}
+ className="flex flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
+ >
+ <TimelineRuler
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ rulerRef={rulerRef}
+ tracksScrollRef={rulerScrollRef}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ <TimelineBookmarksRow
+ zoomLevel={zoomLevel}
+ dynamicTimelineWidth={dynamicTimelineWidth}
+ dragState={bookmarkDragState}
+ onBookmarkMouseDown={handleBookmarkMouseDown}
+ handleWheel={handleWheel}
+ handleTimelineContentClick={handleRulerClick}
+ handleRulerTrackingMouseDown={handleRulerMouseDown}
+ handleRulerMouseDown={handlePlayheadRulerMouseDown}
+ />
+ </div>
+ </div>
+
+ <ScrollArea
+ className="flex-1"
+ ref={tracksScrollRef}
+ onScroll={() => {
+ syncFollowers();
+ saveScrollPosition();
+ }}
+ >
+ <div
+ className="flex min-h-full flex-col"
+ style={{ width: `${dynamicTimelineWidth}px` }}
+ >
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */}
+ <div
+ className="relative shrink-0"
+ style={{
+ height: `${
+ Math.max(
+ TRACKS_CONTAINER_HEIGHT.min,
+ Math.min(
+ TRACKS_CONTAINER_HEIGHT.max,
+ getTotalTracksHeight({
+ tracks,
+ getExtraHeight: getTrackExpansionHeight,
+ }),
+ ),
+ ) + TIMELINE_CONTENT_TOP_PADDING_PX
+ }px`,
+ }}
+ onMouseDown={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={(event) => {
+ const isDirectTarget = event.target === event.currentTarget;
+ if (!isDirectTarget) return;
+ event.stopPropagation();
+ handleTracksClick(event);
+ }}
+ >
+ {tracks.length > 0 && (
+ <TimelineTrackRows
+ mainTrackId={mainTrackId}
+ zoomLevel={zoomLevel}
+ dragState={dragState}
+ tracksScrollRef={tracksScrollRef}
+ lastMouseXRef={lastMouseXRef}
+ onResizeStart={handleResizeStart}
+ onElementMouseDown={handleElementMouseDown}
+ onElementClick={handleElementClick}
+ onTrackMouseDown={(event) => {
+ handleSelectionMouseDown(event);
+ handleTracksMouseDown(event);
+ }}
+ onTrackMouseUp={handleTracksClick}
+ shouldIgnoreClick={shouldIgnoreClick}
+ isDragOver={isDragOver}
+ dropTarget={dropTarget}
+ />
+ )}
+ </div>
+ <TimelineGutter
+ onMouseDown={(event) => {
+ handleTracksMouseDown(event);
+ handleSelectionMouseDown(event);
+ }}
+ onClick={handleTracksClick}
+ />
+ </div>
+ </ScrollArea>
+
+ <TimelinePlayhead
+ zoomLevel={zoomLevel}
+ hasHorizontalScrollbar={hasHorizontalScrollbar}
+ rulerRef={rulerRef}
+ rulerScrollRef={rulerScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ timelineRef={timelineRef}
+ playheadRef={playheadRef}
+ isSnappingToPlayhead={
+ showSnapIndicator && currentSnapPoint?.type === "playhead"
+ }
+ />
+ </div>
+ <SnapIndicator
+ snapPoint={currentSnapPoint}
+ zoomLevel={zoomLevel}
+ timelineRef={timelineRef}
+ tracksScrollRef={tracksScrollRef}
+ isVisible={showSnapIndicator}
+ />
+ </div>
+ </section>
+ );
+}
+
+function TrackLabelsPanel({
+ trackLabelsRef,
+ trackLabelsScrollRef,
+ timelineHeaderHeight,
+ hasHorizontalScrollbar,
+ getTrackExpansionHeight,
+}: {
+ trackLabelsRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>;
+ timelineHeaderHeight: number;
+ hasHorizontalScrollbar: boolean;
+ getTrackExpansionHeight: (trackIndex: number) => number;
+}) {
+ const editor = useEditor();
+ const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
+ const tracks = useMemo<TimelineTrack[]>(
+ () =>
+ scene
+ ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
+ : [],
+ [scene],
+ );
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
+ const trackExpandedRowsMap = useMemo(
+ () =>
+ tracks.map((track) =>
+ getTrackExpandedRows({ track, expandedElementIds }),
+ ),
+ [tracks, expandedElementIds],
+ );
+
+ return (
+ <div
+ className="flex shrink-0 flex-col border-r"
+ style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }}
+ >
+ <div
+ className="shrink-0"
+ style={{ height: timelineHeaderHeight || 48 }}
+ />
+ <div ref={trackLabelsRef} className="flex-1 overflow-hidden">
+ <div ref={trackLabelsScrollRef} className="size-full overflow-hidden">
+ {tracks.length > 0 && (
+ <div
+ className="flex flex-col"
+ style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }}
+ >
+ {tracks.map((track, index) => {
+ const expandedRows = trackExpandedRowsMap[index];
+ const baseHeight = getTrackHeight({ type: track.type });
+
+ return (
+ <div
+ key={track.id}
+ className={cn(
+ "group flex flex-col",
+ tracksWithSelection.has(track.id) &&
+ SELECTED_TRACK_ROW_CLASS,
+ )}
+ style={{
+ height: `${baseHeight + getTrackExpansionHeight(index)}px`,
+ }}
+ >
+ <div
+ className="flex shrink-0 items-center justify-end gap-2 px-3"
+ style={{ height: `${baseHeight}px` }}
+ >
+ {canTrackHaveAudio(track) && (
+ <TrackToggleIcon
+ isOff={track.muted}
+ icons={{
+ on: VolumeHighIcon,
+ off: VolumeOffIcon,
+ }}
+ onClick={() =>
+ editor.timeline.toggleTrackMute({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+ {canTrackBeHidden(track) && (
+ <TrackToggleIcon
+ isOff={track.hidden}
+ icons={{
+ on: ViewIcon,
+ off: ViewOffSlashIcon,
+ }}
+ onClick={() =>
+ editor.timeline.toggleTrackVisibility({
+ trackId: track.id,
+ })
+ }
+ />
+ )}
+ <TrackIcon track={track} />
+ </div>
+ {expandedRows.length > 0 && (
+ <PropertyTree rows={expandedRows} />
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ <div
+ className="bg-background shrink-0"
+ style={{
+ height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0,
+ }}
+ />
+ </div>
+ );
+}
+
+function TimelineTrackRows({
+ mainTrackId,
+ zoomLevel,
+ dragState,
+ tracksScrollRef,
+ lastMouseXRef,
+ onResizeStart,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ isDragOver,
+ dropTarget,
+}: {
+ mainTrackId: string | null;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onResizeStart: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onResizeStart"];
+ onElementMouseDown: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementMouseDown"];
+ onElementClick: React.ComponentProps<
+ typeof TimelineTrackContent
+ >["onElementClick"];
+ onTrackMouseDown: (event: React.MouseEvent) => void;
+ onTrackMouseUp: (event: React.MouseEvent) => void;
+ shouldIgnoreClick: () => boolean;
+ isDragOver: boolean;
+ dropTarget: DropTarget | null;
+}) {
+ const timeline = useEditor((e) => e.timeline);
+ const scene = useEditor((e) => e.scenes.getActiveSceneOrNull());
+ const tracks = useMemo<TimelineTrack[]>(
+ () =>
+ scene
+ ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio]
+ : [],
+ [scene],
+ );
+ const { selectedElements } = useElementSelection();
+ const tracksWithSelection = useMemo(
+ () => new Set(selectedElements.map((el) => el.trackId)),
+ [selectedElements],
+ );
+
+ const expandedElementIds = useTimelineStore((s) => s.expandedElementIds);
+
+ const getTrackExpansionHeight = useCallback(
+ (trackIndex: number) => {
+ const track = tracks[trackIndex];
+ if (!track) return 0;
+ return computeTrackExpansionHeight({ track, expandedElementIds });
+ },
+ [tracks, expandedElementIds],
+ );
+
+ const sortedTracks = useMemo(() => {
+ const draggingElementIds = new Set(dragState.dragElementIds);
+ return [...tracks]
+ .map((track, index) => ({ track, index }))
+ .sort((a, b) => {
+ const aHasDragged = a.track.elements.some((element) =>
+ draggingElementIds.has(element.id),
+ );
+ const bHasDragged = b.track.elements.some((element) =>
+ draggingElementIds.has(element.id),
+ );
+ if (aHasDragged) return 1;
+ if (bHasDragged) return -1;
+ return 0;
+ });
+ }, [tracks, dragState.dragElementIds]);
+
+ return (
+ <>
+ {sortedTracks.map(({ track, index }) => (
+ <ContextMenu key={track.id}>
+ <ContextMenuTrigger asChild>
+ <div
+ className={cn(
+ "absolute right-0 left-0 transition-colors",
+ tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS,
+ )}
+ style={{
+ top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`,
+ height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`,
+ }}
+ >
+ <TimelineTrackContent
+ track={track}
+ zoomLevel={zoomLevel}
+ dragState={dragState}
+ rulerScrollRef={tracksScrollRef}
+ tracksScrollRef={tracksScrollRef}
+ lastMouseXRef={lastMouseXRef}
+ onResizeStart={onResizeStart}
+ onElementMouseDown={onElementMouseDown}
+ onElementClick={onElementClick}
+ onTrackMouseDown={onTrackMouseDown}
+ onTrackMouseUp={onTrackMouseUp}
+ shouldIgnoreClick={shouldIgnoreClick}
+ targetElementId={
+ isDragOver
+ ? (dropTarget?.targetElement?.elementId ?? null)
+ : null
+ }
+ />
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-40">
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction("paste-copied");
+ }}
+ >
+ Paste elements
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackMute({ trackId: track.id });
+ }}
+ >
+ {canTrackHaveAudio(track) && track.muted
+ ? "Unmute track"
+ : "Mute track"}
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={ViewIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.toggleTrackVisibility({ trackId: track.id });
+ }}
+ >
+ {canTrackBeHidden(track) && track.hidden
+ ? "Show track"
+ : "Hide track"}
+ </ContextMenuItem>
+ {track.id !== mainTrackId && (
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ timeline.removeTrack({ trackId: track.id });
+ }}
+ variant="destructive"
+ >
+ Delete track
+ </ContextMenuItem>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ ))}
+ </>
+ );
+}
+
+function TimelineGutter({
+ onMouseDown,
+ onClick,
+}: {
+ onMouseDown: (event: React.MouseEvent) => void;
+ onClick: (event: React.MouseEvent) => void;
+}) {
+ // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system
+ return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />;
+}
+
+function TrackIcon({ track }: { track: TimelineTrack }) {
+ return <>{TRACK_ICONS[track.type]}</>;
+}
+
+function TrackToggleIcon({
+ isOff,
+ icons,
+ onClick,
+}: {
+ isOff: boolean;
+ icons: {
+ on: IconSvgElement;
+ off: IconSvgElement;
+ };
+ onClick: () => void;
+}) {
+ return (
+ <>
+ {isOff ? (
+ <HugeiconsIcon
+ icon={icons.off}
+ className="text-destructive size-4 cursor-pointer"
+ onClick={onClick}
+ />
+ ) : (
+ <HugeiconsIcon
+ icon={icons.on}
+ className="text-muted-foreground size-4 cursor-pointer"
+ onClick={onClick}
+ />
+ )}
+ </>
+ );
+}
+
+function PropertyTree({ rows }: { rows: ExpandedRow[] }) {
+ return (
+ <div className="flex flex-col overflow-hidden">
+ {rows.map((row) => (
+ <div
+ key={row.propertyPath}
+ className={cn("flex shrink-0 items-center px-3 bg-muted/50")}
+ style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
+ >
+ <span className="text-muted-foreground truncate text-xs leading-none">
+ {getPropertyLabel(row.propertyPath)}
+ </span>
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/timeline/components/interaction.ts b/apps/web/src/timeline/components/interaction.ts
new file mode 100644
index 00000000..fe0210a4
--- /dev/null
+++ b/apps/web/src/timeline/components/interaction.ts
@@ -0,0 +1,4 @@
+export const TIMELINE_DRAG_THRESHOLD_PX = 5;
+export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40;
+export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7;
+export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15;
diff --git a/apps/web/src/timeline/components/layers.ts b/apps/web/src/timeline/components/layers.ts
new file mode 100644
index 00000000..d59db412
--- /dev/null
+++ b/apps/web/src/timeline/components/layers.ts
@@ -0,0 +1,6 @@
+export const TIMELINE_LAYERS = {
+ trackContent: 10,
+ dragLine: 20,
+ playhead: 30,
+ snapIndicator: 40,
+} as const;
diff --git a/apps/web/src/timeline/components/layout.ts b/apps/web/src/timeline/components/layout.ts
new file mode 100644
index 00000000..57a7f5df
--- /dev/null
+++ b/apps/web/src/timeline/components/layout.ts
@@ -0,0 +1,20 @@
+import type { TrackType } from "@/timeline";
+
+export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = {
+ video: 65,
+ text: 25,
+ audio: 50,
+ graphic: 25,
+ effect: 25,
+} as const;
+
+export const KEYFRAME_LANE_HEIGHT_PX = 20;
+export const KEYFRAME_DIAMOND_SIZE_PX = 14;
+export const EXPANDED_GROUP_HEADER_HEIGHT_PX = 18;
+
+export const TIMELINE_TRACK_GAP_PX = 6;
+export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112;
+export const TIMELINE_RULER_HEIGHT_PX = 22;
+export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16;
+export const TIMELINE_SCROLLBAR_SIZE_PX = 12;
+export const TIMELINE_CONTENT_TOP_PADDING_PX = 2;
diff --git a/apps/web/src/timeline/components/selection-hit-testing.ts b/apps/web/src/timeline/components/selection-hit-testing.ts
new file mode 100644
index 00000000..da5617c6
--- /dev/null
+++ b/apps/web/src/timeline/components/selection-hit-testing.ts
@@ -0,0 +1,135 @@
+import type { TimelineTrack } from "@/timeline";
+import { timelineTimeToPixels } from "@/timeline/pixel-utils";
+import {
+ TIMELINE_CONTENT_TOP_PADDING_PX,
+} from "./layout";
+import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout";
+
+type TimelineElementRef = { trackId: string; elementId: string };
+
+interface SelectionRectangle {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+
+function getNormalizedRectangle({
+ startPos,
+ endPos,
+}: {
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ return {
+ left: Math.min(startPos.x, endPos.x),
+ top: Math.min(startPos.y, endPos.y),
+ right: Math.max(startPos.x, endPos.x),
+ bottom: Math.max(startPos.y, endPos.y),
+ };
+}
+
+function getSelectionRectangleInContent({
+ container,
+ scrollContainer,
+ startPos,
+ endPos,
+}: {
+ container: HTMLElement;
+ scrollContainer: HTMLDivElement | null;
+ startPos: { x: number; y: number };
+ endPos: { x: number; y: number };
+}): SelectionRectangle {
+ const containerRect = container.getBoundingClientRect();
+ const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+
+ const adjustedStart = {
+ x: startPos.x - containerRect.left + scrollLeft,
+ y: startPos.y - scrollRect.top + scrollTop,
+ };
+ const adjustedEnd = {
+ x: endPos.x - containerRect.left + scrollLeft,
+ y: endPos.y - scrollRect.top + scrollTop,
+ };
+
+ return getNormalizedRectangle({
+ startPos: adjustedStart,
+ endPos: adjustedEnd,
+ });
+}
+
+function isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+}: {
+ elementRectangle: SelectionRectangle;
+ selectionRectangle: SelectionRectangle;
+}): boolean {
+ return !(
+ elementRectangle.right < selectionRectangle.left ||
+ elementRectangle.left > selectionRectangle.right ||
+ elementRectangle.bottom < selectionRectangle.top ||
+ elementRectangle.top > selectionRectangle.bottom
+ );
+}
+
+export function resolveTimelineElementIntersections({
+ container,
+ scrollContainer,
+ tracks,
+ zoomLevel,
+ startPos,
+ currentPos,
+}: {
+ container: HTMLElement;
+ scrollContainer: HTMLDivElement | null;
+ tracks: TimelineTrack[];
+ zoomLevel: number;
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+}): TimelineElementRef[] {
+ const selectionRectangle = getSelectionRectangleInContent({
+ container,
+ scrollContainer,
+ startPos,
+ endPos: currentPos,
+ });
+ const selectedElements: TimelineElementRef[] = [];
+
+ for (const [trackIndex, track] of tracks.entries()) {
+ const trackTop = getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+ });
+ const trackHeight = getTrackHeight({ type: track.type });
+ const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop;
+ const elementBottom = elementTop + trackHeight;
+
+ for (const element of track.elements) {
+ const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel });
+ const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel });
+ const elementRectangle = {
+ left: elementLeft,
+ top: elementTop,
+ right: elementRight,
+ bottom: elementBottom,
+ };
+
+ if (
+ isRectangleIntersecting({
+ elementRectangle,
+ selectionRectangle,
+ })
+ ) {
+ selectedElements.push({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ }
+ }
+ }
+
+ return selectedElements;
+}
diff --git a/apps/web/src/timeline/components/snap-indicator.tsx b/apps/web/src/timeline/components/snap-indicator.tsx
new file mode 100644
index 00000000..6c1dbf1a
--- /dev/null
+++ b/apps/web/src/timeline/components/snap-indicator.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useSnapIndicatorPosition } from "@/timeline/hooks/use-snap-indicator-position";
+import type { SnapPoint } from "@/timeline/snapping";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+} from "@/timeline";
+import { TIMELINE_LAYERS } from "./layers";
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ timelineRef,
+ tracksScrollRef,
+}: SnapIndicatorProps) {
+ const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ timelineRef,
+ tracksScrollRef,
+ });
+
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
+ top: topPosition,
+ height: `${height}px`,
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.snapIndicator,
+ }}
+ >
+ <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
+ </div>
+ );
+}
diff --git a/apps/web/src/timeline/components/theme.ts b/apps/web/src/timeline/components/theme.ts
new file mode 100644
index 00000000..9f93dc16
--- /dev/null
+++ b/apps/web/src/timeline/components/theme.ts
@@ -0,0 +1,31 @@
+import type { TrackType } from "@/timeline";
+
+export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)";
+
+export const TIMELINE_TRACK_THEME: Record<
+ TrackType,
+ {
+ elementClassName: string;
+ waveformColor?: string;
+ }
+> = {
+ video: { elementClassName: "transparent" },
+ text: { elementClassName: "bg-[#5DBAA0]" },
+ audio: {
+ elementClassName: "bg-[#8F5DBA]",
+ waveformColor: TIMELINE_AUDIO_WAVEFORM_COLOR,
+ },
+ graphic: { elementClassName: "bg-[#BA5D7A]" },
+ effect: { elementClassName: "bg-[#5d93ba]" },
+} as const;
+
+export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50";
+export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff";
+
+export function getTimelineElementClassName({
+ type,
+}: {
+ type: TrackType;
+}): string {
+ return TIMELINE_TRACK_THEME[type].elementClassName.trim();
+}
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
new file mode 100644
index 00000000..74c34607
--- /dev/null
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -0,0 +1,1302 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import { useEditor } from "@/editor/use-editor";
+import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store";
+import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform";
+import { AudioVolumeLine } from "./audio-volume-line";
+import { useElementPreview } from "@/timeline/hooks/use-element-preview";
+import {
+ useKeyframeDrag,
+ type KeyframeDragState,
+} from "@/timeline/hooks/element/use-keyframe-drag";
+import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection";
+import { useKeyframeBoxSelect } from "@/timeline/hooks/element/use-keyframe-box-select";
+import { SelectionBox } from "@/selection/selection-box";
+import { getElementKeyframes } from "@/animation";
+import {
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasElementEffects,
+ hasMediaId,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/timeline";
+import { getTrackHeight } from "./track-layout";
+import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+ VideoElement,
+ ImageElement,
+ AudioElement,
+} from "@/timeline";
+import type { MediaAsset } from "@/media/types";
+import { mediaSupportsAudio } from "@/media/media-utils";
+import {
+ canToggleSourceAudio,
+ getSourceAudioActionLabel,
+ isSourceAudioSeparated,
+} from "@/timeline/audio-separation";
+import { buildWaveformGainSamples } from "@/timeline/audio-state";
+import { getTimelinePixelsPerSecond } from "@/timeline";
+import { buildWaveformSourceKey } from "@/media/waveform-summary";
+import { TICKS_PER_SECOND } from "@/wasm/ticks";
+import {
+ getActionDefinition,
+ type TAction,
+ type TActionWithOptionalArgs,
+ invokeAction,
+} from "@/actions";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import { resolveStickerId } from "@/stickers";
+import { buildGraphicPreviewUrl } from "@/graphics";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+ KeyframeIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import { useMemo, type ComponentProps, type ReactNode } from "react";
+import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types";
+import { cn } from "@/utils/ui";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
+import { getTrackTypeForElementType } from "@/timeline/placement/compatibility";
+import { useTimelineStore } from "@/timeline/timeline-store";
+import { KEYFRAME_LANE_HEIGHT_PX } from "./layout";
+import {
+ getExpandedRows,
+ getExpansionHeight,
+ type ExpandedRow,
+} from "./expanded-layout";
+
+const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
+const ELEMENT_RING_WIDTH_PX = 1.5;
+
+const PixelsPerSecondContext = createContext<number | null>(null);
+const THUMBNAIL_ASPECT_RATIO = 16 / 9;
+
+interface KeyframeIndicator {
+ time: number;
+ offsetPx: number;
+ keyframes: SelectedKeyframeRef[];
+}
+
+export function buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+}: {
+ keyframe: ElementKeyframe;
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+}): {
+ time: number;
+ offsetPx: number;
+ keyframeRef: SelectedKeyframeRef;
+} {
+ const keyframeRef = {
+ trackId,
+ elementId,
+ propertyPath: keyframe.propertyPath,
+ keyframeId: keyframe.id,
+ };
+ const keyframeLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime + keyframe.time,
+ zoomLevel,
+ });
+ return {
+ time: keyframe.time,
+ offsetPx: keyframeLeft - elementLeft,
+ keyframeRef,
+ };
+}
+
+export function getKeyframeIndicators({
+ keyframes,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+}: {
+ keyframes: ElementKeyframe[];
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+ elementWidth: number;
+}): KeyframeIndicator[] {
+ if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
+ return [];
+ }
+
+ const keyframesByTime = new Map<number, KeyframeIndicator>();
+ for (const keyframe of keyframes) {
+ const indicator = buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ });
+ const existingIndicator = keyframesByTime.get(indicator.time);
+ if (!existingIndicator) {
+ keyframesByTime.set(indicator.time, {
+ time: indicator.time,
+ offsetPx: indicator.offsetPx,
+ keyframes: [indicator.keyframeRef],
+ });
+ continue;
+ }
+
+ existingIndicator.keyframes.push(indicator.keyframeRef);
+ }
+
+ return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
+}
+
+export function getDisplayShortcut({ action }: { action: TAction }) {
+ const { defaultShortcuts } = getActionDefinition({ action });
+ if (!defaultShortcuts?.length) {
+ return "";
+ }
+
+ return uppercase({
+ string: defaultShortcuts[0].replace("+", " "),
+ });
+}
+
+interface TimelineElementProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onResizeStart: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ side: "left" | "right";
+ }) => void;
+ onElementMouseDown: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ dragState: ElementDragState;
+ isDropTarget?: boolean;
+}
+
+export function TimelineElement({
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onResizeStart,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
+ isDropTarget = false,
+}: TimelineElementProps) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const { selectedElements } = useElementSelection();
+ const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
+ const { renderElement } = useElementPreview({
+ trackId: track.id,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ let mediaAsset: MediaAsset | null = null;
+
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
+
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
+
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
+
+ const isBeingDragged = dragState.dragElementIds.includes(element.id);
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime + dragTimeOffset
+ : renderElement.startTime;
+ const displayedStartTime = elementStartTime;
+ const displayedDuration = renderElement.duration;
+ const elementWidth = timelineTimeToPixels({
+ time: displayedDuration,
+ zoomLevel,
+ });
+ const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel });
+ const elementLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime,
+ zoomLevel,
+ });
+ const keyframeIndicators = isSelected
+ ? getKeyframeIndicators({
+ keyframes: getElementKeyframes({ animations: element.animations }),
+ trackId: track.id,
+ elementId: element.id,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+ })
+ : [];
+
+ const {
+ keyframeDragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
+
+ const elementKeyframes = getElementKeyframes({
+ animations: element.animations,
+ });
+
+ const isExpanded = useTimelineStore((s) =>
+ s.expandedElementIds.has(element.id),
+ );
+ const toggleElementExpanded = useTimelineStore(
+ (s) => s.toggleElementExpanded,
+ );
+ const expandedRows = useMemo(
+ () =>
+ isExpanded ? getExpandedRows({ animations: element.animations }) : [],
+ [isExpanded, element.animations],
+ );
+
+ const {
+ containerRef: expandedLanesRef,
+ selectionBox: keyframeSelectionBox,
+ isBoxSelecting: isKeyframeBoxSelecting,
+ handleExpandedAreaMouseDown,
+ handleExpandedAreaClick,
+ } = useKeyframeBoxSelect({
+ trackId: track.id,
+ elementId: element.id,
+ rows: expandedRows,
+ keyframes: elementKeyframes,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ });
+
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
+
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
+ const canToggleCurrentSourceAudio =
+ selectedElements.length === 1 &&
+ isCurrentElementSelected &&
+ canToggleSourceAudio(element, mediaAsset);
+ const sourceAudioLabel =
+ element.type === "video"
+ ? getSourceAudioActionLabel({ element })
+ : "Extract audio";
+ const isElementSourceAudioSeparated =
+ element.type === "video" && isSourceAudioSeparated({ element });
+ const hasKeyframes = elementKeyframes.length > 0;
+ const expansionHeight = getExpansionHeight({ rows: expandedRows });
+ const baseTrackHeight = getTrackHeight({ type: track.type });
+
+ const expandedContent =
+ isExpanded && expandedRows.length > 0 ? (
+ <ExpandedKeyframeLanes
+ rows={expandedRows}
+ keyframes={elementKeyframes}
+ trackId={track.id}
+ elementId={element.id}
+ displayedStartTime={displayedStartTime}
+ zoomLevel={zoomLevel}
+ elementLeft={elementLeft}
+ keyframeDragState={keyframeDragState}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ containerRef={expandedLanesRef}
+ onLaneMouseDown={handleExpandedAreaMouseDown}
+ onLaneClick={handleExpandedAreaClick}
+ selectionBox={keyframeSelectionBox}
+ isBoxSelecting={isKeyframeBoxSelecting}
+ />
+ ) : null;
+
+ return (
+ <PixelsPerSecondContext.Provider value={timelinePixelsPerSecond}>
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute top-0 select-none"
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ height:
+ expandedRows.length > 0
+ ? `${baseTrackHeight + expansionHeight}px`
+ : "100%",
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
+ }}
+ >
+ <ElementInner
+ element={element}
+ displayElement={renderElement}
+ track={track}
+ isSelected={isSelected}
+ isExpanded={expandedRows.length > 0}
+ baseTrackHeight={baseTrackHeight}
+ expandedContent={expandedContent}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ onResizeStart={onResizeStart}
+ isDropTarget={isDropTarget}
+ />
+ {isSelected && (
+ <div
+ className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden"
+ style={{ height: `${baseTrackHeight}px` }}
+ >
+ <KeyframeIndicators
+ indicators={keyframeIndicators}
+ dragState={keyframeDragState}
+ displayedStartTime={displayedStartTime}
+ elementLeft={elementLeft}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ />
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-64">
+ <ActionMenuItem
+ action="split"
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ >
+ Split
+ </ActionMenuItem>
+ <CopyMenuItem />
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canToggleCurrentSourceAudio && (
+ <ContextMenuItem
+ icon={
+ <HugeiconsIcon
+ icon={
+ isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon
+ }
+ />
+ }
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction("toggle-source-audio");
+ }}
+ >
+ {sourceAudioLabel}
+ </ContextMenuItem>
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {hasKeyframes && (
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={KeyframeIcon} />}
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ toggleElementExpanded(element.id);
+ }}
+ >
+ {isExpanded ? "Collapse keyframes" : "Expand keyframes"}
+ </ContextMenuItem>
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Search01Icon} />}
+ onClick={(event: React.MouseEvent) =>
+ handleRevealInMedia({ event })
+ }
+ >
+ Reveal media
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Exchange01Icon} />}
+ disabled
+ >
+ Replace media
+ </ContextMenuItem>
+ </>
+ )}
+ <ContextMenuSeparator />
+ <DeleteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+ </ContextMenuContent>
+ </ContextMenu>
+ </PixelsPerSecondContext.Provider>
+ );
+}
+
+function ElementInner({
+ element,
+ displayElement,
+ track,
+ isSelected,
+ isExpanded,
+ baseTrackHeight,
+ expandedContent,
+ onElementClick,
+ onElementMouseDown,
+ onResizeStart,
+ isDropTarget = false,
+}: {
+ element: TimelineElementType;
+ displayElement?: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ isExpanded: boolean;
+ baseTrackHeight: number;
+ expandedContent: React.ReactNode;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementMouseDown: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onResizeStart: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ side: "left" | "right";
+ }) => void;
+ isDropTarget?: boolean;
+}) {
+ const visibleElement = displayElement ?? element;
+ const isReducedOpacity =
+ (canElementBeHidden(visibleElement) && visibleElement.hidden) ||
+ isDropTarget;
+ return (
+ <div
+ className="absolute top-0 bottom-0"
+ style={{
+ left: `${ELEMENT_RING_WIDTH_PX}px`,
+ right: `${ELEMENT_RING_WIDTH_PX}px`,
+ }}
+ >
+ <div
+ className="absolute inset-0 rounded-sm"
+ style={
+ isSelected
+ ? {
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
+ }
+ : undefined
+ }
+ >
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ isExpanded && "bg-background",
+ )}
+ >
+ <button
+ type="button"
+ tabIndex={-1}
+ className="absolute inset-0 size-full flex flex-col"
+ onClick={(event) => onElementClick(event, element)}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div
+ className={cn(
+ "flex shrink-0 items-center overflow-hidden",
+ getTimelineElementClassName({
+ type: getTrackTypeForElementType({
+ elementType: element.type,
+ }),
+ }),
+ isReducedOpacity && "opacity-50",
+ )}
+ style={{ height: `${baseTrackHeight}px` }}
+ >
+ <div className="flex flex-1 min-h-0 h-full items-center overflow-hidden">
+ <ElementContent element={visibleElement} track={track} />
+ </div>
+ </div>
+ {expandedContent}
+ </button>
+ </div>
+ </div>
+
+ {isSelected && (
+ <>
+ <ResizeHandle
+ side="left"
+ element={element}
+ track={track}
+ onResizeStart={onResizeStart}
+ />
+ <ResizeHandle
+ side="right"
+ element={element}
+ track={track}
+ onResizeStart={onResizeStart}
+ />
+ </>
+ )}
+ </div>
+ );
+}
+
+function ResizeHandle({
+ side,
+ element,
+ track,
+ onResizeStart,
+}: {
+ side: "left" | "right";
+ element: TimelineElementType;
+ track: TimelineTrack;
+ onResizeStart: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute top-0 bottom-0 w-2",
+ isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
+ )}
+ onMouseDown={(event) => onResizeStart({ event, element, track, side })}
+ onClick={(event) => event.stopPropagation()}
+ aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
+ ></button>
+ );
+}
+
+function KeyframeIndicators({
+ indicators,
+ dragState,
+ displayedStartTime,
+ elementLeft,
+ onKeyframeMouseDown,
+ onKeyframeClick,
+ getVisualOffsetPx,
+}: {
+ indicators: KeyframeIndicator[];
+ dragState: KeyframeDragState;
+ displayedStartTime: number;
+ elementLeft: number;
+ onKeyframeMouseDown: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => void;
+ onKeyframeClick: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => void;
+ getVisualOffsetPx: (params: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }) => number;
+}) {
+ const { isKeyframeSelected } = useKeyframeSelection();
+ const orderedKeyframes = indicators.flatMap(
+ (indicator) => indicator.keyframes,
+ );
+
+ return indicators.map((indicator) => {
+ const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ const isBeingDragged = indicator.keyframes.some((keyframe) =>
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
+ );
+ const visualOffsetPx = getVisualOffsetPx({
+ indicatorTime: indicator.time,
+ indicatorOffsetPx: indicator.offsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ });
+
+ return (
+ <button
+ key={indicator.time}
+ type="button"
+ className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5"
+ style={{ left: visualOffsetPx }}
+ onMouseDown={(event) =>
+ onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
+ }
+ onClick={(event) =>
+ onKeyframeClick({
+ event,
+ keyframes: indicator.keyframes,
+ orderedKeyframes,
+ indicatorTime: indicator.time,
+ })
+ }
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 text-black",
+ isIndicatorSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ });
+}
+
+function ExpandedKeyframeLanes({
+ rows,
+ keyframes,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ keyframeDragState,
+ onKeyframeMouseDown,
+ onKeyframeClick,
+ getVisualOffsetPx,
+ containerRef,
+ onLaneMouseDown,
+ onLaneClick,
+ selectionBox,
+ isBoxSelecting,
+}: {
+ rows: ExpandedRow[];
+ keyframes: ElementKeyframe[];
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+ keyframeDragState: KeyframeDragState;
+ onKeyframeMouseDown: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => void;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+ onLaneMouseDown: (event: React.MouseEvent) => void;
+ onLaneClick: (event: React.MouseEvent) => void;
+ selectionBox: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ isActive: boolean;
+ } | null;
+ isBoxSelecting: boolean;
+ onKeyframeClick: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => void;
+ getVisualOffsetPx: (params: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }) => number;
+}) {
+ const { isKeyframeSelected } = useKeyframeSelection();
+
+ const orderedKeyframes = useMemo(
+ () =>
+ [...keyframes]
+ .sort(
+ (a, b) =>
+ a.time - b.time || a.propertyPath.localeCompare(b.propertyPath),
+ )
+ .map((kf) => ({
+ trackId,
+ elementId,
+ propertyPath: kf.propertyPath,
+ keyframeId: kf.id,
+ })),
+ [keyframes, trackId, elementId],
+ );
+
+ return (
+ // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface
+ // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface
+ <div
+ ref={containerRef}
+ className="relative flex flex-col"
+ onMouseDown={onLaneMouseDown}
+ onClick={onLaneClick}
+ >
+ {rows.map((row) => {
+ const laneKeyframes = keyframes.filter(
+ (kf) => kf.propertyPath === row.propertyPath,
+ );
+ return (
+ <div
+ key={row.propertyPath}
+ className={cn("relative flex items-center bg-muted/50")}
+ style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }}
+ >
+ {laneKeyframes.map((kf) => {
+ const keyframeRef: SelectedKeyframeRef = {
+ trackId,
+ elementId,
+ propertyPath: row.propertyPath,
+ keyframeId: kf.id,
+ };
+ const isBeingDragged = keyframeDragState.draggingKeyframeIds.has(
+ kf.id,
+ );
+ const kfLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime + kf.time,
+ zoomLevel,
+ });
+ const offsetPx = kfLeft - elementLeft;
+ const visualOffset = getVisualOffsetPx({
+ indicatorTime: kf.time,
+ indicatorOffsetPx: offsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ });
+ const isSelected = isKeyframeSelected({
+ keyframe: keyframeRef,
+ });
+
+ return (
+ <button
+ key={kf.id}
+ type="button"
+ className={cn(
+ "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab",
+ isBoxSelecting && "pointer-events-none",
+ )}
+ style={{ left: visualOffset }}
+ onMouseDown={(event) => {
+ event.stopPropagation();
+ onKeyframeMouseDown({
+ event,
+ keyframes: [keyframeRef],
+ });
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ onKeyframeClick({
+ event,
+ keyframes: [keyframeRef],
+ orderedKeyframes,
+ indicatorTime: kf.time,
+ });
+ }}
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 text-black mr-1",
+ isSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ })}
+ </div>
+ );
+ })}
+ {selectionBox && (
+ <SelectionBox
+ startPos={selectionBox.startPos}
+ currentPos={selectionBox.currentPos}
+ containerRef={containerRef}
+ isActive={selectionBox.isActive}
+ />
+ )}
+ </div>
+ );
+}
+
+interface ElementContentProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+}
+
+function TextElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "text" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+}
+
+function EffectElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "effect" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function StickerElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "sticker" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function GraphicElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "graphic" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={buildGraphicPreviewUrl({
+ definitionId: element.definitionId,
+ params: element.params,
+ size: 20,
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function AudioElementContent({
+ element,
+ trackId,
+}: {
+ element: AudioElement;
+ trackId: string;
+}) {
+ const pixelsPerSecond = useContext(PixelsPerSecondContext);
+ if (pixelsPerSecond === null) {
+ throw new Error(
+ "AudioElementContent must be rendered inside PixelsPerSecondContext.Provider",
+ );
+ }
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const mediaAsset =
+ element.sourceType === "upload"
+ ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
+ : null;
+
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+ const audioUrl =
+ element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const sourceFile =
+ element.sourceType === "upload" ? mediaAsset?.file : undefined;
+ const sourceKey =
+ element.sourceType === "upload"
+ ? buildWaveformSourceKey({ kind: "media", id: element.mediaId })
+ : buildWaveformSourceKey({ kind: "library", id: element.sourceUrl });
+ const mediaLabel = mediaAsset?.name ?? element.name;
+ const gainSamples = useMemo(
+ () =>
+ buildWaveformGainSamples({
+ element,
+ count: WAVEFORM_GAIN_SAMPLE_COUNT,
+ }),
+ [element],
+ );
+ if (audioBuffer || audioUrl || sourceFile) {
+ return (
+ <div className="group/audio relative size-full">
+ <MediaElementHeader name={mediaLabel} hasFade={false} />
+ <div className="absolute inset-x-0 top-5 bottom-0 overflow-hidden">
+ <AudioWaveform
+ sourceKey={sourceKey}
+ sourceFile={sourceFile}
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ gainSamples={gainSamples}
+ pixelsPerSecond={pixelsPerSecond}
+ clipDurationSec={element.duration / TICKS_PER_SECOND}
+ retime={element.retime}
+ sourceStartSec={element.trimStart / TICKS_PER_SECOND}
+ color={TIMELINE_TRACK_THEME.audio.waveformColor}
+ />
+ <AudioVolumeLine element={element} trackId={trackId} />
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="group/audio relative size-full">
+ <div className="flex size-full items-center pl-2">
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ </div>
+ <AudioVolumeLine element={element} trackId={trackId} />
+ </div>
+ );
+}
+
+function EffectsButton({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const editor = useEditor();
+ const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ });
+ setActiveTab(element.type, "effects");
+ };
+
+ return (
+ <button
+ type="button"
+ className="flex shrink-0 justify-center text-white cursor-pointer"
+ onMouseDown={(event) => event.stopPropagation()}
+ onClick={handleClick}
+ >
+ <HugeiconsIcon icon={MagicWand05Icon} size={12} />
+ </button>
+ );
+}
+
+function TiledMediaContent({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ const imageUrl =
+ element.type === "video"
+ ? mediaAsset?.thumbnailUrl
+ : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
+
+ if (!imageUrl) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
+
+ return (
+ <>
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundColor: "var(--muted)",
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ <MediaElementHeader
+ name={mediaAsset?.name}
+ leading={
+ hasElementEffects({ element }) ? (
+ <EffectsButton element={element} track={track} />
+ ) : null
+ }
+ hasFade={true}
+ />
+ </>
+ );
+}
+
+function MediaElementHeader({
+ name,
+ leading,
+ hasFade,
+}: {
+ name?: string | null;
+ leading?: ReactNode;
+ hasFade?: boolean;
+}) {
+ if (!name && !leading) {
+ return null;
+ }
+
+ return (
+ <div
+ className={cn(
+ "absolute top-0 left-0 flex h-5 w-full bg-linear-to-b pt-1",
+ hasFade && "from-black/30 to-transparent",
+ )}
+ >
+ {leading && <div className="pl-1">{leading}</div>}
+ {name && (
+ <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
+ {name}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function ElementContent({ element, track }: ElementContentProps) {
+ switch (element.type) {
+ case "text":
+ return <TextElementContent element={element} />;
+ case "effect":
+ return <EffectElementContent element={element} />;
+ case "sticker":
+ return <StickerElementContent element={element} />;
+ case "graphic":
+ return <GraphicElementContent element={element} />;
+ case "audio":
+ return <AudioElementContent element={element} trackId={track.id} />;
+ case "video":
+ case "image":
+ return <TiledMediaContent element={element} track={track} />;
+ }
+}
+
+function CopyMenuItem() {
+ return (
+ <ActionMenuItem
+ action="copy-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Copy
+ </ActionMenuItem>
+ );
+}
+
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={VolumeMute02Icon} />;
+ }
+ return isMuted ? (
+ <HugeiconsIcon icon={VolumeOffIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
+ {isMuted ? "Unmute" : "Mute"}
+ </ActionMenuItem>
+ );
+}
+
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
+
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={ViewOffSlashIcon} />;
+ }
+ return isHidden ? (
+ <HugeiconsIcon icon={ViewIcon} />
+ ) : (
+ <HugeiconsIcon icon={ViewOffSlashIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem
+ action="toggle-elements-visibility-selected"
+ icon={getIcon()}
+ >
+ {isHidden ? "Show" : "Hide"}
+ </ActionMenuItem>
+ );
+}
+
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ <ActionMenuItem
+ action="delete-selected"
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+ </ActionMenuItem>
+ );
+}
+
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
+ action: TActionWithOptionalArgs;
+ children: ReactNode;
+}) {
+ return (
+ <ContextMenuItem
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut({ action })}
+ {...props}
+ >
+ {children}
+ </ContextMenuItem>
+ );
+}
diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx
new file mode 100644
index 00000000..4dd3329f
--- /dev/null
+++ b/apps/web/src/timeline/components/timeline-playhead.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { useRef } from "react";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+ timelineTimeToSnappedPixels,
+} from "@/timeline";
+import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { useEditor } from "@/editor/use-editor";
+import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout";
+import { TIMELINE_LAYERS } from "./layers";
+
+interface TimelinePlayheadProps {
+ zoomLevel: number;
+ hasHorizontalScrollbar: boolean;
+ rulerRef: React.RefObject<HTMLDivElement | null>;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ playheadRef?: React.RefObject<HTMLDivElement | null>;
+ isSnappingToPlayhead?: boolean;
+}
+
+export function TimelinePlayhead({
+ zoomLevel,
+ hasHorizontalScrollbar,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ timelineRef,
+ playheadRef: externalPlayheadRef,
+ isSnappingToPlayhead = false,
+}: TimelinePlayheadProps) {
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const internalPlayheadRef = useRef<HTMLDivElement>(null);
+ const playheadRef = externalPlayheadRef || internalPlayheadRef;
+
+ const { handlePlayheadMouseDown } = useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+ });
+
+ const timelineContainerHeight =
+ timelineRef.current?.clientHeight ??
+ tracksScrollRef.current?.clientHeight ??
+ 400;
+ const totalHeight = Math.max(
+ 0,
+ timelineContainerHeight -
+ (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0),
+ );
+
+ const currentTime = editor.playback.getCurrentTime();
+ const centerPosition = timelineTimeToSnappedPixels({
+ time: currentTime,
+ zoomLevel,
+ });
+ const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0;
+ const leftPosition =
+ getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft;
+
+ const handlePlayheadKeyDown = (
+ event: React.KeyboardEvent<HTMLDivElement>,
+ ) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
+
+ event.preventDefault();
+ const fps = editor.project.getActive().settings.fps;
+ const ticksPerFrame = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ const direction = event.key === "ArrowRight" ? 1 : -1;
+ const now = editor.playback.getCurrentTime();
+ const nextTime = Math.max(
+ 0,
+ Math.min(duration, now + direction * ticksPerFrame),
+ );
+
+ editor.playback.seek({ time: nextTime });
+ };
+
+ return (
+ <div
+ ref={playheadRef}
+ role="slider"
+ aria-label="Timeline playhead"
+ aria-valuemin={0}
+ aria-valuemax={duration}
+ aria-valuenow={currentTime}
+ tabIndex={0}
+ className="pointer-events-none absolute"
+ style={{
+ left: `${leftPosition}px`,
+ top: 0,
+ height: `${totalHeight}px`,
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.playhead,
+ }}
+ onKeyDown={handlePlayheadKeyDown}
+ >
+ <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" />
+
+ <button
+ type="button"
+ aria-label="Drag playhead"
+ className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`}
+ onMouseDown={handlePlayheadMouseDown}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx
new file mode 100644
index 00000000..e2a379ee
--- /dev/null
+++ b/apps/web/src/timeline/components/timeline-ruler.tsx
@@ -0,0 +1,146 @@
+import { type JSX, useLayoutEffect, useRef } from "react";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { mediaTimeToSeconds } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { TIMELINE_RULER_HEIGHT_PX } from "./layout";
+import { DEFAULT_FPS } from "@/fps/defaults";
+import { useEditor } from "@/editor/use-editor";
+import { getRulerConfig, shouldShowLabel } from "@/timeline/ruler-utils";
+import { useScrollPosition } from "@/timeline/hooks/use-scroll-position";
+import { TimelineTick } from "./timeline-tick";
+
+interface TimelineRulerProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ rulerRef: React.Ref<HTMLDivElement>;
+ tracksScrollRef: React.RefObject<HTMLElement | null>;
+ handleWheel: (e: React.WheelEvent) => void;
+ handleTimelineContentClick: (e: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
+ handleRulerMouseDown: (e: React.MouseEvent) => void;
+}
+
+export function TimelineRuler({
+ zoomLevel,
+ dynamicTimelineWidth,
+ rulerRef,
+ tracksScrollRef,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineRulerProps) {
+ const durationTicks = useEditor((e) => e.timeline.getTotalDuration());
+ const durationSeconds = mediaTimeToSeconds({ time: durationTicks });
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+ const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond;
+ const effectiveDurationSeconds = Math.max(
+ durationSeconds,
+ visibleDurationSeconds,
+ );
+ const fps =
+ useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS;
+ const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({
+ zoomLevel,
+ fps,
+ });
+ const tickCount =
+ Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1;
+
+ const { scrollLeft, viewportWidth } = useScrollPosition({
+ scrollRef: tracksScrollRef,
+ });
+
+ /**
+ * widens the virtualization buffer during zoom transitions.
+ * useScrollPosition lags one frame behind the scroll adjustment
+ * that useLayoutEffect applies after a zoom change.
+ */
+ const prevZoomRef = useRef(zoomLevel);
+ const isZoomTransition = zoomLevel !== prevZoomRef.current;
+ const bufferPx = isZoomTransition
+ ? Math.max(200, (scrollLeft + viewportWidth) * 0.15)
+ : 200;
+
+ useLayoutEffect(() => {
+ prevZoomRef.current = zoomLevel;
+ }, [zoomLevel]);
+
+ const visibleStartTimeSeconds = Math.max(
+ 0,
+ (scrollLeft - bufferPx) / pixelsPerSecond,
+ );
+ const visibleEndTimeSeconds =
+ (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond;
+
+ const startTickIndex = Math.max(
+ 0,
+ Math.floor(visibleStartTimeSeconds / tickIntervalSeconds),
+ );
+ const endTickIndex = Math.min(
+ tickCount - 1,
+ Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds),
+ );
+
+ const timelineTicks: Array<JSX.Element> = [];
+ for (
+ let tickIndex = startTickIndex;
+ tickIndex <= endTickIndex;
+ tickIndex += 1
+ ) {
+ const timeSeconds = tickIndex * tickIntervalSeconds;
+ if (timeSeconds > effectiveDurationSeconds) break;
+
+ const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND);
+ const showLabel = shouldShowLabel({
+ time: timeSeconds,
+ labelIntervalSeconds,
+ });
+ timelineTicks.push(
+ <TimelineTick
+ key={tickIndex}
+ time={timeTicks}
+ timeInSeconds={timeSeconds}
+ zoomLevel={zoomLevel}
+ fps={fps}
+ showLabel={showLabel}
+ />,
+ );
+ }
+
+ return (
+ <div
+ role="slider"
+ tabIndex={0}
+ aria-label="Timeline ruler"
+ aria-valuemin={0}
+ aria-valuemax={effectiveDurationSeconds}
+ aria-valuenow={0}
+ className="relative flex-1 overflow-x-visible"
+ style={{ height: TIMELINE_RULER_HEIGHT_PX }}
+ onWheel={handleWheel}
+ onClick={(event) => {
+ // Ruler seek already happens on mousedown via playhead scrubbing.
+ // Forwarding the follow-up click re-enters the selection-clearing path.
+ if (event.target === event.currentTarget) {
+ handleTimelineContentClick(event);
+ }
+ }}
+ onMouseDown={handleRulerTrackingMouseDown}
+ onKeyDown={() => {}}
+ >
+ <div
+ role="none"
+ ref={rulerRef}
+ className="relative cursor-default select-none"
+ style={{
+ height: TIMELINE_RULER_HEIGHT_PX,
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ onMouseDown={handleRulerMouseDown}
+ >
+ {timelineTicks}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/timeline/components/timeline-tick.tsx b/apps/web/src/timeline/components/timeline-tick.tsx
new file mode 100644
index 00000000..1b1e46d3
--- /dev/null
+++ b/apps/web/src/timeline/components/timeline-tick.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import type { FrameRate } from "opencut-wasm";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import { formatRulerLabel } from "@/timeline/ruler-utils";
+
+interface TimelineTickProps {
+ time: number;
+ timeInSeconds: number;
+ zoomLevel: number;
+ fps: FrameRate;
+ showLabel: boolean;
+}
+
+export function TimelineTick({
+ time,
+ timeInSeconds,
+ zoomLevel,
+ fps,
+ showLabel,
+}: TimelineTickProps) {
+ const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
+
+ if (showLabel) {
+ const label = formatRulerLabel({ timeInSeconds, fps });
+ return (
+ <span
+ className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
+ style={{ left: `${leftPosition}px` }}
+ >
+ {label}
+ </span>
+ );
+ }
+
+ return (
+ <div
+ className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
+ style={{ left: `${leftPosition}px` }}
+ />
+ );
+}
diff --git a/apps/web/src/timeline/components/timeline-toolbar.tsx b/apps/web/src/timeline/components/timeline-toolbar.tsx
new file mode 100644
index 00000000..22acd447
--- /dev/null
+++ b/apps/web/src/timeline/components/timeline-toolbar.tsx
@@ -0,0 +1,376 @@
+import { useEditor } from "@/editor/use-editor";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import {
+ TooltipProvider,
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import {
+ SplitButton,
+ SplitButtonLeft,
+ SplitButtonRight,
+ SplitButtonSeparator,
+} from "@/components/ui/split-button";
+import { Slider } from "@/components/ui/slider";
+import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction";
+import { TIMELINE_ZOOM_MAX } from "@/timeline/scale";
+import { sliderToZoom, zoomToSlider } from "@/timeline/zoom-utils";
+import { ScenesView } from "@/components/editor/scenes-view";
+import { type TActionWithOptionalArgs, invokeAction } from "@/actions";
+import {
+ canToggleSourceAudio,
+ getSourceAudioActionLabel,
+ isSourceAudioSeparated,
+} from "@/timeline/audio-separation";
+import { hasMediaId } from "@/timeline";
+import { cn } from "@/utils/ui";
+import { useTimelineStore } from "@/timeline/timeline-store";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Bookmark02Icon,
+ Delete02Icon,
+ SnowIcon,
+ ScissorIcon,
+ MagnetIcon,
+ SearchAddIcon,
+ SearchMinusIcon,
+ Copy01Icon,
+ AlignLeftIcon,
+ AlignRightIcon,
+ Link02Icon,
+ Layers01Icon,
+ Chart03Icon,
+ Unlink02Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { OcRippleIcon } from "@/components/icons";
+import { GraphEditorPopover } from "./graph-editor/popover";
+import { PopoverTrigger } from "@/components/ui/popover";
+import { useGraphEditorController } from "./graph-editor/use-controller";
+
+export function TimelineToolbar({
+ zoomLevel,
+ minZoom,
+ setZoomLevel,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+ setZoomLevel: ({ zoom }: { zoom: number }) => void;
+}) {
+ const handleZoom = ({ direction }: { direction: "in" | "out" }) => {
+ const newZoomLevel =
+ direction === "in"
+ ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR)
+ : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR);
+ setZoomLevel({ zoom: newZoomLevel });
+ };
+
+ return (
+ <ScrollArea className="scrollbar-hidden">
+ <div className="flex h-10 items-center justify-between border-b px-2 py-1">
+ <ToolbarLeftSection />
+
+ <SceneSelector />
+
+ <ToolbarRightSection
+ zoomLevel={zoomLevel}
+ minZoom={minZoom}
+ onZoomChange={(zoom) => setZoomLevel({ zoom })}
+ onZoom={handleZoom}
+ />
+ </div>
+ </ScrollArea>
+ );
+}
+
+function ToolbarLeftSection() {
+ const editor = useEditor();
+ const mediaAssets = useEditor((currentEditor) =>
+ currentEditor.media.getAssets(),
+ );
+ const { selectedElements } = useElementSelection();
+ const graphEditor = useGraphEditorController();
+ const isCurrentlyBookmarked = useEditor((e) =>
+ e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }),
+ );
+ const selectedElement =
+ selectedElements.length === 1
+ ? (editor.timeline.getElementsWithTracks({
+ elements: selectedElements,
+ })[0] ?? null)
+ : null;
+ const selectedMediaAsset = (() => {
+ if (!selectedElement) {
+ return null;
+ }
+
+ const { element } = selectedElement;
+ if (!hasMediaId(element)) {
+ return null;
+ }
+
+ return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ })();
+ const canToggleSelectedSourceAudio =
+ !!selectedElement &&
+ canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
+ const sourceAudioLabel =
+ selectedElement?.element.type === "video"
+ ? getSourceAudioActionLabel({
+ element: selectedElement.element,
+ })
+ : "Extract audio";
+ const isSelectedSourceAudioSeparated =
+ selectedElement?.element.type === "video" &&
+ isSourceAudioSeparated({
+ element: selectedElement.element,
+ });
+
+ const handleAction = ({
+ action,
+ event,
+ }: {
+ action: TActionWithOptionalArgs;
+ event: React.MouseEvent;
+ }) => {
+ event.stopPropagation();
+ invokeAction(action);
+ };
+
+ return (
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ tooltip="Split element"
+ onClick={({ event }) => handleAction({ action: "split", event })}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={AlignLeftIcon} />}
+ tooltip="Split left"
+ onClick={({ event }) => handleAction({ action: "split-left", event })}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={AlignRightIcon} />}
+ tooltip="Split right"
+ onClick={({ event }) =>
+ handleAction({ action: "split-right", event })
+ }
+ />
+
+ <ToolbarButton
+ icon={
+ <HugeiconsIcon
+ icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon}
+ />
+ }
+ tooltip={sourceAudioLabel}
+ disabled={!canToggleSelectedSourceAudio}
+ onClick={({ event }) =>
+ handleAction({ action: "toggle-source-audio", event })
+ }
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ tooltip="Duplicate element"
+ onClick={({ event }) =>
+ handleAction({ action: "duplicate-selected", event })
+ }
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={SnowIcon} />}
+ tooltip="Freeze frame (coming soon)"
+ disabled={true}
+ onClick={({ event: _event }) => {}}
+ />
+
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ tooltip="Delete element"
+ onClick={({ event }) =>
+ handleAction({ action: "delete-selected", event })
+ }
+ />
+
+ <div className="bg-border mx-1 h-6 w-px" />
+
+ <Tooltip>
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Bookmark02Icon} />}
+ isActive={isCurrentlyBookmarked}
+ tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"}
+ onClick={({ event }) =>
+ handleAction({ action: "toggle-bookmark", event })
+ }
+ />
+ </Tooltip>
+
+ <GraphEditorPopover
+ open={graphEditor.open}
+ onOpenChange={graphEditor.onOpenChange}
+ value={
+ graphEditor.state.status === "ready"
+ ? graphEditor.state.cubicBezier
+ : null
+ }
+ message={graphEditor.state.message}
+ componentOptions={graphEditor.state.componentOptions}
+ activeComponentKey={graphEditor.state.activeComponentKey}
+ onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange}
+ onPreviewValue={graphEditor.onPreviewValue}
+ onCommitValue={graphEditor.onCommitValue}
+ onCancelPreview={graphEditor.onCancelPreview}
+ >
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={Chart03Icon} />}
+ tooltip={graphEditor.tooltip}
+ disabled={!graphEditor.canOpen}
+ buttonWrapper={(button) =>
+ graphEditor.canOpen ? (
+ <PopoverTrigger asChild>{button}</PopoverTrigger>
+ ) : (
+ button
+ )
+ }
+ />
+ </GraphEditorPopover>
+ </TooltipProvider>
+ </div>
+ );
+}
+
+function SceneSelector() {
+ const editor = useEditor();
+ const currentScene = editor.scenes.getActiveScene();
+
+ return (
+ <div>
+ <SplitButton className="border-foreground/10 border">
+ <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
+ <SplitButtonSeparator />
+ <ScenesView>
+ <SplitButtonRight onClick={() => {}}>
+ <HugeiconsIcon icon={Layers01Icon} className="size-4" />
+ </SplitButtonRight>
+ </ScenesView>
+ </SplitButton>
+ </div>
+ );
+}
+
+function ToolbarRightSection({
+ zoomLevel,
+ minZoom,
+ onZoomChange,
+ onZoom,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+ onZoomChange: (zoom: number) => void;
+ onZoom: (options: { direction: "in" | "out" }) => void;
+}) {
+ const snappingEnabled = useTimelineStore((s) => s.snappingEnabled);
+ const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
+ const toggleSnapping = useTimelineStore((s) => s.toggleSnapping);
+ const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing);
+
+ return (
+ <div className="flex items-center gap-1">
+ <TooltipProvider delayDuration={500}>
+ <ToolbarButton
+ icon={<HugeiconsIcon icon={MagnetIcon} />}
+ isActive={snappingEnabled}
+ tooltip="Auto snapping"
+ onClick={() => toggleSnapping()}
+ />
+
+ <ToolbarButton
+ icon={<OcRippleIcon size={24} className="scale-110" />}
+ isActive={rippleEditingEnabled}
+ tooltip="Ripple editing"
+ onClick={() => toggleRippleEditing()}
+ />
+ </TooltipProvider>
+
+ <div className="bg-border mx-1 h-6 w-px" />
+
+ <div className="flex items-center gap-1">
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => onZoom({ direction: "out" })}
+ >
+ <HugeiconsIcon icon={SearchMinusIcon} />
+ </Button>
+ <Slider
+ className="w-28"
+ value={[zoomToSlider({ zoomLevel, minZoom })]}
+ onValueChange={(values) =>
+ onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom }))
+ }
+ min={0}
+ max={1}
+ step={0.005}
+ />
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => onZoom({ direction: "in" })}
+ >
+ <HugeiconsIcon icon={SearchAddIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function ToolbarButton({
+ icon,
+ tooltip,
+ onClick,
+ disabled,
+ isActive,
+ buttonWrapper,
+}: {
+ icon: React.ReactNode;
+ tooltip: string;
+ onClick?: ({ event }: { event: React.MouseEvent }) => void;
+ disabled?: boolean;
+ isActive?: boolean;
+ buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
+}) {
+ const button = (
+ <Button
+ variant={isActive ? "secondary" : "text"}
+ size="icon"
+ disabled={disabled}
+ onClick={onClick ? (event) => onClick({ event }) : undefined}
+ className={cn(
+ "rounded-sm",
+ disabled ? "cursor-not-allowed opacity-50" : "",
+ )}
+ >
+ {icon}
+ </Button>
+ );
+ const trigger = disabled ? (
+ <span className="inline-flex">{button}</span>
+ ) : buttonWrapper ? (
+ buttonWrapper(button)
+ ) : (
+ button
+ );
+
+ return (
+ <Tooltip delayDuration={200}>
+ <TooltipTrigger asChild>{trigger}</TooltipTrigger>
+ <TooltipContent>{tooltip}</TooltipContent>
+ </Tooltip>
+ );
+}
diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx
new file mode 100644
index 00000000..6c40637e
--- /dev/null
+++ b/apps/web/src/timeline/components/timeline-track.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import { TimelineElement } from "./timeline-element";
+import type { TimelineTrack } from "@/timeline";
+import type { TimelineElement as TimelineElementType } from "@/timeline";
+import { TIMELINE_LAYERS } from "./layers";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
+import type { ElementDragState } from "@/timeline";
+import { useEditor } from "@/editor/use-editor";
+
+interface TimelineTrackContentProps {
+ track: TimelineTrack;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onResizeStart: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ side: "left" | "right";
+ }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onTrackMouseDown?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
+ shouldIgnoreClick?: () => boolean;
+ targetElementId?: string | null;
+}
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ dragState,
+ rulerScrollRef,
+ tracksScrollRef,
+ lastMouseXRef,
+ onResizeStart,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ targetElementId = null,
+}: TimelineTrackContentProps) {
+ const { isElementSelected } = useElementSelection();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
+
+ useEdgeAutoScroll({
+ isActive: dragState.isDragging,
+ getMouseClientX: () => lastMouseXRef.current ?? 0,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
+ });
+
+ return (
+ <div className="relative size-full">
+ <button
+ type="button"
+ className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
+ aria-label={`Select ${track.name} track`}
+ onMouseUp={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ <div
+ className="relative h-full min-w-full"
+ style={{ zIndex: TIMELINE_LAYERS.trackContent }}
+ onMouseUp={(event) => {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
+ {track.elements.length === 0 ? (
+ <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ ) : (
+ track.elements.map((element) => {
+ const isSelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onResizeStart={({ event, element, side }) =>
+ onResizeStart({ event, element, track, side })
+ }
+ onElementMouseDown={(event, element) =>
+ onElementMouseDown({ event, element, track })
+ }
+ onElementClick={(event, element) =>
+ onElementClick({ event, element, track })
+ }
+ dragState={dragState}
+ isDropTarget={element.id === targetElementId}
+ />
+ );
+ })
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/timeline/components/track-layout.ts b/apps/web/src/timeline/components/track-layout.ts
new file mode 100644
index 00000000..59f4a5f9
--- /dev/null
+++ b/apps/web/src/timeline/components/track-layout.ts
@@ -0,0 +1,60 @@
+import type { TrackType } from "@/timeline";
+import {
+ KEYFRAME_LANE_HEIGHT_PX,
+ TIMELINE_TRACK_GAP_PX,
+ TIMELINE_TRACK_HEIGHTS_PX,
+} from "./layout";
+
+export function getTrackHeight({ type }: { type: TrackType }): number {
+ return TIMELINE_TRACK_HEIGHTS_PX[type];
+}
+
+export function getExpandedTrackHeight({
+ type,
+ expandedLaneCount,
+}: {
+ type: TrackType;
+ expandedLaneCount: number;
+}): number {
+ return (
+ TIMELINE_TRACK_HEIGHTS_PX[type] +
+ expandedLaneCount * KEYFRAME_LANE_HEIGHT_PX
+ );
+}
+
+export function getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+ getExtraHeight,
+}: {
+ tracks: Array<{ type: TrackType }>;
+ trackIndex: number;
+ getExtraHeight?: (trackIndex: number) => number;
+}): number {
+ return tracks
+ .slice(0, trackIndex)
+ .reduce(
+ (sum, track, i) =>
+ sum +
+ getTrackHeight({ type: track.type }) +
+ (getExtraHeight?.(i) ?? 0) +
+ TIMELINE_TRACK_GAP_PX,
+ 0,
+ );
+}
+
+export function getTotalTracksHeight({
+ tracks,
+ getExtraHeight,
+}: {
+ tracks: Array<{ type: TrackType }>;
+ getExtraHeight?: (trackIndex: number) => number;
+}): number {
+ const tracksHeight = tracks.reduce(
+ (sum, track, i) =>
+ sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0),
+ 0,
+ );
+ const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX;
+ return tracksHeight + gapsHeight;
+}