summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline
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
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')
-rw-r--r--apps/web/src/timeline/audio-constants.ts2
-rw-r--r--apps/web/src/timeline/audio-display.ts64
-rw-r--r--apps/web/src/timeline/audio-separation/index.ts143
-rw-r--r--apps/web/src/timeline/audio-state.ts116
-rw-r--r--apps/web/src/timeline/bookmarks/components/bookmarks.tsx425
-rw-r--r--apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts298
-rw-r--r--apps/web/src/timeline/bookmarks/index.ts19
-rw-r--r--apps/web/src/timeline/bookmarks/preview-overlay-source.tsx85
-rw-r--r--apps/web/src/timeline/bookmarks/snap-source.ts20
-rw-r--r--apps/web/src/timeline/bookmarks/utils.ts145
-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
-rw-r--r--apps/web/src/timeline/creation.ts3
-rw-r--r--apps/web/src/timeline/defaults.ts77
-rw-r--r--apps/web/src/timeline/drag-data.ts48
-rw-r--r--apps/web/src/timeline/drag-utils.ts21
-rw-r--r--apps/web/src/timeline/drag.ts42
-rw-r--r--apps/web/src/timeline/element-snap-source.ts38
-rw-r--r--apps/web/src/timeline/element-utils.ts426
-rw-r--r--apps/web/src/timeline/group-move/build-group.ts87
-rw-r--r--apps/web/src/timeline/group-move/index.ts12
-rw-r--r--apps/web/src/timeline/group-move/resolve-move.ts470
-rw-r--r--apps/web/src/timeline/group-move/snap.ts85
-rw-r--r--apps/web/src/timeline/group-move/track-placement.ts83
-rw-r--r--apps/web/src/timeline/group-move/types.ts36
-rw-r--r--apps/web/src/timeline/group-resize/compute-resize.ts249
-rw-r--r--apps/web/src/timeline/group-resize/index.ts9
-rw-r--r--apps/web/src/timeline/group-resize/types.ts36
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts760
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-selection.ts137
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts217
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts328
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-selection.ts229
-rw-r--r--apps/web/src/timeline/hooks/use-edge-auto-scroll.ts97
-rw-r--r--apps/web/src/timeline/hooks/use-element-preview.ts39
-rw-r--r--apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts42
-rw-r--r--apps/web/src/timeline/hooks/use-scroll-position.ts52
-rw-r--r--apps/web/src/timeline/hooks/use-scroll-sync.ts173
-rw-r--r--apps/web/src/timeline/hooks/use-snap-indicator-position.ts55
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts648
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts301
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts332
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts197
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts291
-rw-r--r--apps/web/src/timeline/index.ts29
-rw-r--r--apps/web/src/timeline/pixel-utils.ts80
-rw-r--r--apps/web/src/timeline/placement/__tests__/resolve.test.ts661
-rw-r--r--apps/web/src/timeline/placement/apply.ts158
-rw-r--r--apps/web/src/timeline/placement/compatibility.ts51
-rw-r--r--apps/web/src/timeline/placement/index.ts12
-rw-r--r--apps/web/src/timeline/placement/insert-index.ts88
-rw-r--r--apps/web/src/timeline/placement/main-track.ts54
-rw-r--r--apps/web/src/timeline/placement/overlap.ts44
-rw-r--r--apps/web/src/timeline/placement/resolve.ts277
-rw-r--r--apps/web/src/timeline/placement/track-factory.ts123
-rw-r--r--apps/web/src/timeline/placement/types.ts39
-rw-r--r--apps/web/src/timeline/playhead-snap-source.ts9
-rw-r--r--apps/web/src/timeline/ruler-utils.ts257
-rw-r--r--apps/web/src/timeline/scale.ts3
-rw-r--r--apps/web/src/timeline/scenes.ts113
-rw-r--r--apps/web/src/timeline/snapping/build.ts17
-rw-r--r--apps/web/src/timeline/snapping/index.ts9
-rw-r--r--apps/web/src/timeline/snapping/resolve.ts28
-rw-r--r--apps/web/src/timeline/snapping/threshold.ts15
-rw-r--r--apps/web/src/timeline/snapping/types.ts21
-rw-r--r--apps/web/src/timeline/timeline-store.ts57
-rw-r--r--apps/web/src/timeline/track-capabilities.ts20
-rw-r--r--apps/web/src/timeline/track-element-update.ts147
-rw-r--r--apps/web/src/timeline/tracks.ts9
-rw-r--r--apps/web/src/timeline/types.ts314
-rw-r--r--apps/web/src/timeline/update-pipeline.ts211
-rw-r--r--apps/web/src/timeline/zoom-utils.ts90
96 files changed, 15990 insertions, 0 deletions
diff --git a/apps/web/src/timeline/audio-constants.ts b/apps/web/src/timeline/audio-constants.ts
new file mode 100644
index 00000000..f5bb8a3e
--- /dev/null
+++ b/apps/web/src/timeline/audio-constants.ts
@@ -0,0 +1,2 @@
+export const VOLUME_DB_MIN = -60;
+export const VOLUME_DB_MAX = 20;
diff --git a/apps/web/src/timeline/audio-display.ts b/apps/web/src/timeline/audio-display.ts
new file mode 100644
index 00000000..9958c472
--- /dev/null
+++ b/apps/web/src/timeline/audio-display.ts
@@ -0,0 +1,64 @@
+import { clampDb } from "./audio-state";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
+
+const SLIDER_CURVE_EXPONENT = 2;
+const MIN_DISPLAY_DB = -40;
+const WAVEFORM_BAR_EXPONENT = 1.5;
+const MIN_LINEAR_GAIN = 10 ** (VOLUME_DB_MIN / 20);
+const MAX_LINEAR_GAIN = 10 ** (VOLUME_DB_MAX / 20);
+const LINEAR_GAIN_RANGE = MAX_LINEAR_GAIN - MIN_LINEAR_GAIN;
+
+function getNormalizedGainFromDb({ db }: { db: number }): number {
+ const clampedDb = clampDb(db);
+ const linearGain = 10 ** (clampedDb / 20);
+ return (linearGain - MIN_LINEAR_GAIN) / LINEAR_GAIN_RANGE;
+}
+
+/**
+ * Maps the clip's volume setting to the line position. The curve is defined in
+ * linear gain space so dragging near 0 dB is precise while mute compresses into
+ * the bottom of the clip.
+ */
+export function getLinePosFromDb({ db }: { db: number }): number {
+ const normalizedGain = Math.max(
+ 0,
+ Math.min(1, getNormalizedGainFromDb({ db })),
+ );
+ const progress = normalizedGain ** (1 / SLIDER_CURVE_EXPONENT);
+ return (1 - progress) * 100;
+}
+
+/**
+ * Inverse of getLinePosFromDb. Converts a drag position back into the clip's
+ * volume setting without depending on the underlying audio content.
+ */
+export function getDbFromLinePos({ percent }: { percent: number }): number {
+ const clampedPercent = Math.max(0, Math.min(100, percent));
+ const progress = 1 - clampedPercent / 100;
+ const normalizedGain = progress ** SLIDER_CURVE_EXPONENT;
+ const linearGain = MIN_LINEAR_GAIN + normalizedGain * LINEAR_GAIN_RANGE;
+ return clampDb(20 * Math.log10(linearGain));
+}
+
+/**
+ * Maps an output amplitude (raw sample amplitude × gain) to a visible waveform
+ * height fraction using a dB scale. Agnostic to whether the input is peak or
+ * RMS — the caller decides which measure feeds this function; the mapping
+ * curve is the same either way.
+ *
+ * The log scale keeps quiet content visible while reserving the top of the
+ * element for amplitudes that approach 0 dBFS.
+ */
+export function getBarFractionFromOutputAmplitude({
+ outputAmplitude,
+}: {
+ outputAmplitude: number;
+}): number {
+ if (outputAmplitude <= 0) return 0;
+ const db = 20 * Math.log10(outputAmplitude);
+ if (db <= MIN_DISPLAY_DB) return 0;
+ return Math.min(
+ 1,
+ ((db - MIN_DISPLAY_DB) / -MIN_DISPLAY_DB) ** WAVEFORM_BAR_EXPONENT,
+ );
+}
diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts
new file mode 100644
index 00000000..beb488de
--- /dev/null
+++ b/apps/web/src/timeline/audio-separation/index.ts
@@ -0,0 +1,143 @@
+import { cloneAnimations } from "@/animation";
+import type { ElementAnimations } from "@/animation/types";
+import type { MediaAsset } from "@/media/types";
+import { DEFAULTS } from "@/timeline/defaults";
+import type {
+ CreateUploadAudioElement,
+ TimelineElement,
+ AudioElement,
+ VideoElement,
+} from "../types";
+
+type MediaAudioState = Pick<MediaAsset, "hasAudio">;
+
+export function isSourceAudioEnabled({
+ element,
+}: {
+ element: VideoElement;
+}): boolean {
+ return element.isSourceAudioEnabled !== false;
+}
+
+export function isSourceAudioSeparated({
+ element,
+}: {
+ element: VideoElement;
+}): boolean {
+ return !isSourceAudioEnabled({ element });
+}
+
+export function canExtractSourceAudio(
+ element: TimelineElement,
+ mediaAsset: MediaAudioState | null | undefined,
+): element is VideoElement {
+ return (
+ element.type === "video" &&
+ isSourceAudioEnabled({ element }) &&
+ !!mediaAsset &&
+ mediaAsset.hasAudio !== false
+ );
+}
+
+export function canRecoverSourceAudio(
+ element: TimelineElement,
+): element is VideoElement {
+ return element.type === "video" && isSourceAudioSeparated({ element });
+}
+
+export function canToggleSourceAudio(
+ element: TimelineElement,
+ mediaAsset: MediaAudioState | null | undefined,
+): element is VideoElement {
+ return (
+ canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset)
+ );
+}
+
+export function doesElementHaveEnabledAudio({
+ element,
+ mediaAsset,
+}: {
+ element: AudioElement | VideoElement;
+ mediaAsset?: MediaAudioState | null;
+}): boolean {
+ if (element.type === "audio") {
+ return true;
+ }
+
+ return (
+ !!mediaAsset &&
+ mediaAsset.hasAudio !== false &&
+ isSourceAudioEnabled({ element })
+ );
+}
+
+export function buildSeparatedAudioElement({
+ sourceElement,
+}: {
+ sourceElement: VideoElement;
+}): CreateUploadAudioElement {
+ return {
+ type: "audio",
+ sourceType: "upload",
+ mediaId: sourceElement.mediaId,
+ name: sourceElement.name,
+ duration: sourceElement.duration,
+ startTime: sourceElement.startTime,
+ trimStart: sourceElement.trimStart,
+ trimEnd: sourceElement.trimEnd,
+ sourceDuration: sourceElement.sourceDuration,
+ volume: sourceElement.volume ?? DEFAULTS.element.volume,
+ muted: sourceElement.muted ?? false,
+ retime: sourceElement.retime
+ ? {
+ rate: sourceElement.retime.rate,
+ maintainPitch: sourceElement.retime.maintainPitch,
+ }
+ : undefined,
+ animations: cloneVolumeAnimations({
+ animations: sourceElement.animations,
+ }),
+ };
+}
+
+export function getSourceAudioActionLabel({
+ element,
+}: {
+ element: VideoElement;
+}): "Extract audio" | "Recover audio" {
+ return isSourceAudioSeparated({ element })
+ ? "Recover audio"
+ : "Extract audio";
+}
+
+function cloneVolumeAnimations({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementAnimations | undefined {
+ const volumeBinding = animations?.bindings.volume;
+ if (!volumeBinding) {
+ return undefined;
+ }
+
+ const subsetChannels = Object.fromEntries(
+ volumeBinding.components.flatMap((component) => {
+ const channel = animations?.channels[component.channelId];
+ return channel ? [[component.channelId, channel] as const] : [];
+ }),
+ );
+ if (Object.keys(subsetChannels).length === 0) {
+ return undefined;
+ }
+
+ return cloneAnimations({
+ animations: {
+ bindings: {
+ volume: volumeBinding,
+ },
+ channels: subsetChannels,
+ },
+ shouldRegenerateKeyframeIds: true,
+ });
+}
diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts
new file mode 100644
index 00000000..4ef559ea
--- /dev/null
+++ b/apps/web/src/timeline/audio-state.ts
@@ -0,0 +1,116 @@
+import { hasKeyframesForPath } from "@/animation/keyframe-query";
+import { resolveNumberAtTime } from "@/animation/resolve";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
+import type { TimelineElement } from "./types";
+const DEFAULT_STEP_SECONDS = 1 / 60;
+
+export type AudioCapableElement = Extract<
+ TimelineElement,
+ { type: "audio" | "video" }
+>;
+
+export function clampDb(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
+}
+
+export function dBToLinear(db: number): number {
+ return 10 ** (clampDb(db) / 20);
+}
+
+export function hasAnimatedVolume({
+ element,
+}: {
+ element: AudioCapableElement;
+}): boolean {
+ return hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath: "volume",
+ });
+}
+
+import { TICKS_PER_SECOND } from "@/wasm";
+
+export function resolveEffectiveAudioGain({
+ element,
+ trackMuted = false,
+ localTime,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ localTime: number;
+}): number {
+ if (trackMuted || element.muted === true) {
+ return 0;
+ }
+
+ const resolvedDb = resolveNumberAtTime({
+ baseValue: element.volume ?? 0,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime: Math.round(localTime * TICKS_PER_SECOND),
+ });
+
+ return dBToLinear(resolvedDb);
+}
+
+export function buildWaveformGainSamples({
+ element,
+ count,
+}: {
+ element: AudioCapableElement;
+ count: number;
+}): number[] {
+ const durationSeconds = element.duration / TICKS_PER_SECOND;
+ return Array.from({ length: count }, (_, i) => {
+ const localTime = ((i + 0.5) / count) * durationSeconds;
+ return resolveEffectiveAudioGain({ element, localTime });
+ });
+}
+
+export function buildAudioGainAutomation({
+ element,
+ trackMuted = false,
+ fromLocalTime,
+ toLocalTime,
+ stepSeconds = DEFAULT_STEP_SECONDS,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ fromLocalTime: number;
+ toLocalTime: number;
+ stepSeconds?: number;
+}): Array<{ localTime: number; gain: number }> {
+ const startTime = Math.max(0, fromLocalTime);
+ const endTime = Math.max(startTime, toLocalTime);
+ const safeStep =
+ Number.isFinite(stepSeconds) && stepSeconds > 0
+ ? stepSeconds
+ : DEFAULT_STEP_SECONDS;
+ const points: Array<{ localTime: number; gain: number }> = [];
+
+ for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
+ points.push({
+ localTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime,
+ }),
+ });
+ }
+
+ points.push({
+ localTime: endTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime: endTime,
+ }),
+ });
+
+ return points;
+}
diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
new file mode 100644
index 00000000..20e22c57
--- /dev/null
+++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx
@@ -0,0 +1,425 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { EditorCore } from "@/core";
+import { useEditor } from "@/editor/use-editor";
+import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
+import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
+import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
+import { DEFAULT_FPS } from "@/fps/defaults";
+import { snappedSeekTime } from "opencut-wasm";
+import {
+ ArrowTurnBackwardIcon,
+ Delete02Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { Bookmark } from "@/timeline";
+import {
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+} from "@/components/ui/popover";
+import { Input } from "@/components/ui/input";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { uppercase } from "@/utils/string";
+import { clamp, formatNumberForDisplay } from "@/utils/math";
+import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline";
+
+const MIN_BOOKMARK_WIDTH_PX = 2;
+const BOOKMARK_MARKER_WIDTH_PX = 12;
+const BOOKMARK_MARKER_HEIGHT_PX = 15;
+const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
+const BOOKMARK_MARKER_CLIP_PATH =
+ "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
+
+function seekToBookmarkTime({
+ editor,
+ time,
+}: {
+ editor: EditorCore;
+ time: number;
+}) {
+ const activeProject = editor.project.getActive();
+ const duration = editor.timeline.getTotalDuration();
+ const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
+ const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time;
+ editor.playback.seek({ time: snappedTime });
+}
+
+interface TimelineBookmarksRowProps {
+ zoomLevel: number;
+ dynamicTimelineWidth: number;
+ dragState: BookmarkDragState;
+ onBookmarkMouseDown: (params: {
+ event: React.MouseEvent;
+ bookmark: Bookmark;
+ }) => void;
+ handleWheel: (event: React.WheelEvent) => void;
+ handleTimelineContentClick: (event: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
+ handleRulerMouseDown: (event: React.MouseEvent) => void;
+}
+
+export function TimelineBookmarksRow({
+ zoomLevel,
+ dynamicTimelineWidth,
+ dragState,
+ onBookmarkMouseDown,
+ handleWheel,
+ handleTimelineContentClick,
+ handleRulerTrackingMouseDown,
+ handleRulerMouseDown,
+}: TimelineBookmarksRowProps) {
+ const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks);
+
+ return (
+ <div
+ className="relative flex-1 overflow-hidden"
+ style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }}
+ >
+ <button
+ className="relative w-full cursor-default select-none border-0 bg-transparent p-0"
+ style={{
+ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX,
+ width: `${dynamicTimelineWidth}px`,
+ }}
+ aria-label="Timeline ruler"
+ type="button"
+ onWheel={handleWheel}
+ onClick={(event) => {
+ if (!event.currentTarget.contains(event.target as Node)) return;
+ handleTimelineContentClick(event);
+ }}
+ onMouseDown={(event) => {
+ if (!event.currentTarget.contains(event.target as Node)) return;
+ handleRulerMouseDown(event);
+ handleRulerTrackingMouseDown(event);
+ }}
+ >
+ {bookmarks.map((bookmark) => (
+ <TimelineBookmark
+ key={`bookmark-${bookmark.time}`}
+ bookmark={bookmark}
+ zoomLevel={zoomLevel}
+ dragState={dragState}
+ onBookmarkMouseDown={onBookmarkMouseDown}
+ />
+ ))}
+ </button>
+ </div>
+ );
+}
+
+function TimelineBookmark({
+ bookmark,
+ zoomLevel,
+ dragState,
+ onBookmarkMouseDown,
+}: {
+ bookmark: Bookmark;
+ zoomLevel: number;
+ dragState: BookmarkDragState;
+ onBookmarkMouseDown: (params: {
+ event: React.MouseEvent;
+ bookmark: Bookmark;
+ }) => void;
+}) {
+ const editor = useEditor();
+ const duration = editor.timeline.getTotalDuration();
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+ const isDragging =
+ dragState.isDragging &&
+ dragState.bookmarkTime !== null &&
+ dragState.bookmarkTime === bookmark.time;
+
+ const displayTime = isDragging ? dragState.currentTime : bookmark.time;
+ const time = bookmark.time;
+ const bookmarkDuration = bookmark.duration ?? 0;
+ const durationWidth =
+ bookmarkDuration > 0
+ ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel })
+ : 0;
+ const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
+ const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
+ const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel });
+ const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
+ const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
+ const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR;
+
+ const handleSeek = () => seekToBookmarkTime({ editor, time });
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.detail === 2) {
+ setIsPopoverOpen(true);
+ } else {
+ handleSeek();
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ handleSeek();
+ };
+
+ const handleMouseDown = (event: React.MouseEvent) => {
+ onBookmarkMouseDown({ event, bookmark });
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ return (
+ <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
+ <PopoverAnchor asChild>
+ <button
+ className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0"
+ style={{
+ left: `${bookmarkLeft}px`,
+ width: `${bookmarkWidth}px`,
+ }}
+ aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`}
+ type="button"
+ onMouseDown={handleMouseDown}
+ onClick={handleClick}
+ onKeyDown={handleKeyDown}
+ >
+ {hasDurationRange ? (
+ <div
+ className="absolute opacity-30"
+ style={{
+ top: 1.5,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
+ left: BOOKMARK_HALF_WIDTH_PX,
+ width: durationWidth,
+ backgroundColor: iconColor,
+ }}
+ />
+ ) : null}
+ <div
+ className="absolute top-0 overflow-hidden"
+ style={{
+ left: 0,
+ width: BOOKMARK_HALF_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ }}
+ >
+ <div
+ className="absolute inset-0"
+ style={{
+ width: BOOKMARK_MARKER_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: "hsl(var(--background))",
+ }}
+ />
+ <div
+ className="absolute"
+ style={{
+ top: 1,
+ left: 1,
+ width: BOOKMARK_MARKER_WIDTH_PX - 2,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: iconColor,
+ }}
+ />
+ </div>
+ <div
+ className="absolute top-0 overflow-hidden"
+ style={{
+ left: rightHalfLeft,
+ width: BOOKMARK_HALF_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ }}
+ >
+ <div
+ className="absolute top-0"
+ style={{
+ left: -BOOKMARK_HALF_WIDTH_PX,
+ width: BOOKMARK_MARKER_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: "hsl(var(--background))",
+ }}
+ />
+ <div
+ className="absolute"
+ style={{
+ top: 1,
+ left: 1 - BOOKMARK_HALF_WIDTH_PX,
+ width: BOOKMARK_MARKER_WIDTH_PX - 2,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: iconColor,
+ }}
+ />
+ </div>
+ </button>
+ </PopoverAnchor>
+ <PopoverContent
+ className="w-64 flex flex-col gap-3 p-3"
+ align="start"
+ side="bottom"
+ sideOffset={8}
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+ <BookmarkPopoverContent
+ bookmark={bookmark}
+ time={time}
+ timelineDuration={duration}
+ onPopoverClose={() => setIsPopoverOpen(false)}
+ />
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function BookmarkPopoverContent({
+ bookmark,
+ time,
+ timelineDuration,
+ onPopoverClose,
+}: {
+ bookmark: Bookmark;
+ time: number;
+ timelineDuration: number;
+ onPopoverClose: () => void;
+}) {
+ const editor = useEditor();
+ const [draftColorHex, setDraftColorHex] = useState(
+ (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
+ .replace("#", "")
+ .toUpperCase(),
+ );
+
+ useEffect(() => {
+ setDraftColorHex(
+ (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR)
+ .replace("#", "")
+ .toUpperCase(),
+ );
+ }, [bookmark.color]);
+
+ const handleRemove = () => {
+ editor.scenes.removeBookmark({ time });
+ onPopoverClose();
+ };
+
+ const handleUpdate = ({
+ note,
+ color,
+ duration,
+ }: Partial<{ note: string; color: string; duration: number }>) => {
+ const updates: Partial<{ note: string; color: string; duration: number }> =
+ {};
+ if (note !== undefined && note !== bookmark.note) updates.note = note;
+ if (
+ color !== undefined &&
+ color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
+ ) {
+ updates.color = color;
+ }
+ if (duration !== undefined && duration !== bookmark.duration) {
+ updates.duration = duration;
+ }
+ if (Object.keys(updates).length === 0) return;
+ editor.scenes.updateBookmark({ time, updates });
+ };
+
+ return (
+ <>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Note</Label>
+ <Input
+ placeholder="Add a note..."
+ value={bookmark.note ?? ""}
+ onChange={(event) => handleUpdate({ note: event.target.value })}
+ className="h-8 text-sm"
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Color</Label>
+ <div className="relative">
+ <ColorPicker
+ value={uppercase({ string: draftColorHex })}
+ onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
+ onChangeEnd={(color) =>
+ handleUpdate({ color: `#${uppercase({ string: color })}` })
+ }
+ className="bg-background border"
+ />
+ {bookmark.color &&
+ bookmark.color.replace(/^#/, "").toUpperCase() !==
+ DEFAULT_TIMELINE_BOOKMARK_COLOR.replace(
+ /^#/,
+ "",
+ ).toUpperCase() && (
+ <Button
+ type="button"
+ variant="text"
+ size="text"
+ aria-label="Reset to default color"
+ className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
+ onClick={() =>
+ editor.scenes.updateBookmark({
+ time,
+ updates: { color: undefined },
+ })
+ }
+ >
+ <HugeiconsIcon
+ icon={ArrowTurnBackwardIcon}
+ className="!size-3.5"
+ />
+ </Button>
+ )}
+ </div>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Duration</Label>
+ <div className="flex items-center gap-1.5">
+ <Input
+ type="number"
+ min={0}
+ step={0.1}
+ value={bookmark.duration ?? 0}
+ onChange={(event) => {
+ const parsed = parseFloat(event.target.value);
+ const value = Number.isNaN(parsed)
+ ? 0
+ : clamp({
+ value: parsed,
+ min: 0,
+ max: Math.max(0, timelineDuration - time),
+ });
+ handleUpdate({ duration: value });
+ }}
+ className="h-8 text-sm"
+ containerClassName="w-full"
+ />
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="text-destructive hover:bg-destructive/10"
+ onClick={handleRemove}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ handleRemove();
+ }
+ }}
+ aria-label="delete bookmark"
+ >
+ <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
+ Delete
+ </Button>
+ </>
+ );
+}
diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
new file mode 100644
index 00000000..1071c6dd
--- /dev/null
+++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts
@@ -0,0 +1,298 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type RefObject,
+} from "react";
+import { useEditor } from "@/editor/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
+import { roundToFrame } from "opencut-wasm";
+import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+ type SnapPoint,
+} from "@/timeline/snapping";
+import { getBookmarkSnapPoints } from "../snap-source";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import type { Bookmark } from "@/timeline";
+
+export interface BookmarkDragState {
+ isDragging: boolean;
+ bookmarkTime: number | null;
+ currentTime: number;
+}
+
+interface PendingBookmarkDrag {
+ bookmarkTime: number;
+ startMouseX: number;
+ startMouseY: number;
+}
+
+interface UseBookmarkDragProps {
+ zoomLevel: number;
+ scrollRef: RefObject<HTMLElement | null>;
+ snappingEnabled: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+export function useBookmarkDrag({
+ zoomLevel,
+ scrollRef,
+ snappingEnabled,
+ onSnapPointChange,
+}: UseBookmarkDragProps) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = activeScene?.bookmarks ?? [];
+ const playheadTime = editor.playback.getCurrentTime();
+ const duration = editor.timeline.getTotalDuration();
+
+ const [dragState, setDragState] = useState<BookmarkDragState>({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+ const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
+ const lastMouseXRef = useRef(0);
+
+ const startDrag = useCallback(
+ ({
+ bookmarkTime,
+ initialCurrentTime,
+ }: {
+ bookmarkTime: number;
+ initialCurrentTime: number;
+ }) => {
+ setDragState({
+ isDragging: true,
+ bookmarkTime,
+ currentTime: initialCurrentTime,
+ });
+ },
+ [],
+ );
+
+ const endDrag = useCallback(() => {
+ setDragState({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ }, []);
+
+ const getSnapResult = useCallback(
+ ({
+ rawTime,
+ excludeBookmarkTime,
+ }: {
+ rawTime: number;
+ excludeBookmarkTime: number;
+ }): { snappedTime: number; snapPoint: SnapPoint | null } => {
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (!shouldSnap) {
+ return { snappedTime: rawTime, snapPoint: null };
+ }
+
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () => getElementEdgeSnapPoints({ tracks }),
+ () => getPlayheadSnapPoints({ playheadTime }),
+ () => getBookmarkSnapPoints({ bookmarks, excludeBookmarkTime }),
+ () => getAnimationKeyframeSnapPointsForTimeline({ tracks }),
+ ],
+ });
+ const result = resolveTimelineSnap({
+ targetTime: rawTime,
+ snapPoints,
+ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
+ });
+ return {
+ snappedTime: result.snappedTime,
+ snapPoint: result.snapPoint,
+ };
+ },
+ [
+ snappingEnabled,
+ tracks,
+ playheadTime,
+ bookmarks,
+ zoomLevel,
+ isShiftHeldRef,
+ ],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = (event: MouseEvent) => {
+ lastMouseXRef.current = event.clientX;
+
+ const scrollContainer = scrollRef.current;
+ if (!scrollContainer) return;
+
+ if (isPendingDrag && pendingDragRef.current) {
+ const { startMouseX, startMouseY, bookmarkTime } =
+ pendingDragRef.current;
+ const deltaX = Math.abs(event.clientX - startMouseX);
+ const deltaY = Math.abs(event.clientY - startMouseY);
+
+ if (
+ deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
+ deltaY <= TIMELINE_DRAG_THRESHOLD_PX
+ ) {
+ return;
+ }
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const frameSnappedTime =
+ roundToFrame({
+ time: Math.max(0, Math.min(mouseTime, duration)),
+ rate: activeProject.settings.fps,
+ }) ?? Math.max(0, Math.min(mouseTime, duration));
+ const { snappedTime: initialTime } = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: bookmarkTime,
+ });
+
+ startDrag({
+ bookmarkTime,
+ initialCurrentTime: initialTime,
+ });
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ return;
+ }
+
+ if (!dragState.isDragging || dragState.bookmarkTime === null) return;
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const clampedTime = Math.max(0, Math.min(mouseTime, duration));
+ const frameSnappedTime =
+ roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
+ clampedTime;
+ const snapResult = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: dragState.bookmarkTime,
+ });
+
+ setDragState((previousDragState) => ({
+ ...previousDragState,
+ currentTime: snapResult.snappedTime,
+ }));
+ onSnapPointChange?.(snapResult.snapPoint);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ zoomLevel,
+ duration,
+ editor.project,
+ scrollRef,
+ isPendingDrag,
+ startDrag,
+ getSnapResult,
+ onSnapPointChange,
+ ]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = () => {
+ if (dragState.bookmarkTime === null) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ const clampedTime = Math.max(
+ 0,
+ Math.min(dragState.currentTime, duration),
+ );
+
+ editor.scenes.moveBookmark({
+ fromTime: dragState.bookmarkTime,
+ toTime: clampedTime,
+ });
+
+ endDrag();
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ dragState.currentTime,
+ duration,
+ endDrag,
+ onSnapPointChange,
+ editor.scenes,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag, onSnapPointChange]);
+
+ const handleBookmarkMouseDown = useCallback(
+ ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
+ if (event.button !== 0) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ pendingDragRef.current = {
+ bookmarkTime: bookmark.time,
+ startMouseX: event.clientX,
+ startMouseY: event.clientY,
+ };
+ setIsPendingDrag(true);
+ },
+ [],
+ );
+
+ return {
+ dragState,
+ handleBookmarkMouseDown,
+ lastMouseXRef,
+ };
+}
diff --git a/apps/web/src/timeline/bookmarks/index.ts b/apps/web/src/timeline/bookmarks/index.ts
new file mode 100644
index 00000000..667979b4
--- /dev/null
+++ b/apps/web/src/timeline/bookmarks/index.ts
@@ -0,0 +1,19 @@
+export {
+ findBookmarkIndex,
+ isBookmarkAtTime,
+ toggleBookmarkInArray,
+ removeBookmarkFromArray,
+ updateBookmarkInArray,
+ moveBookmarkInArray,
+ getFrameTime,
+ getBookmarkAtTime,
+ getBookmarksActiveAtTime,
+} from "./utils";
+export { getBookmarkSnapPoints } from "./snap-source";
+export {
+ bookmarkNotesPreviewOverlay,
+ getBookmarkPreviewOverlaySource,
+} from "./preview-overlay-source";
+export { useBookmarkDrag } from "./hooks/use-bookmark-drag";
+export type { BookmarkDragState } from "./hooks/use-bookmark-drag";
+export { TimelineBookmarksRow } from "./components/bookmarks";
diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
new file mode 100644
index 00000000..8e2b4bcf
--- /dev/null
+++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx
@@ -0,0 +1,85 @@
+import type { Bookmark } from "@/timeline";
+import {
+ EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT,
+ type PreviewOverlayDefinition,
+ type PreviewOverlaySourceResult,
+} from "@/preview/overlays";
+import { getBookmarksActiveAtTime } from "./utils";
+
+export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = {
+ id: "bookmark-notes",
+ label: "Show bookmark notes",
+ defaultVisible: true,
+};
+
+function BookmarkNotesOverlay({
+ bookmarks,
+}: {
+ bookmarks: Array<{ time: number; note: string; color?: string }>;
+}) {
+ return (
+ <div className="flex flex-col gap-1.5" aria-live="polite">
+ {bookmarks.map((bookmark) => (
+ <div
+ key={bookmark.time}
+ className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
+ style={{
+ backgroundColor: "rgb(0 0 0 / 0.5)",
+ borderLeft: bookmark.color
+ ? `3px solid ${bookmark.color}`
+ : "3px solid var(--primary)",
+ }}
+ >
+ {bookmark.note}
+ </div>
+ ))}
+ </div>
+ );
+}
+
+export function getBookmarkPreviewOverlaySource({
+ bookmarks,
+ time,
+ isVisible,
+}: {
+ bookmarks: Bookmark[];
+ time: number;
+ isVisible: boolean;
+}): PreviewOverlaySourceResult {
+ const bookmarksWithNotes = getBookmarksActiveAtTime({
+ bookmarks,
+ time,
+ }).flatMap((bookmark) => {
+ if (bookmark.note == null || bookmark.note.trim() === "") {
+ return [];
+ }
+
+ return [
+ {
+ time: bookmark.time,
+ note: bookmark.note,
+ color: bookmark.color,
+ },
+ ];
+ });
+
+ if (!isVisible || bookmarksWithNotes.length === 0) {
+ return {
+ ...EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT,
+ definitions: [bookmarkNotesPreviewOverlay],
+ };
+ }
+
+ return {
+ definitions: [bookmarkNotesPreviewOverlay],
+ instances: [
+ {
+ id: bookmarkNotesPreviewOverlay.id,
+ mount: { kind: "hud", anchor: "top-left", order: 0 },
+ plane: "over-interaction",
+ pointerEvents: "none",
+ render: () => <BookmarkNotesOverlay bookmarks={bookmarksWithNotes} />,
+ },
+ ],
+ };
+}
diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts
new file mode 100644
index 00000000..32a84baa
--- /dev/null
+++ b/apps/web/src/timeline/bookmarks/snap-source.ts
@@ -0,0 +1,20 @@
+import type { Bookmark } from "@/timeline";
+import type { SnapPoint } from "@/timeline/snapping";
+
+export function getBookmarkSnapPoints({
+ bookmarks,
+ excludeBookmarkTime,
+}: {
+ bookmarks: Bookmark[];
+ excludeBookmarkTime?: number;
+}): SnapPoint[] {
+ return bookmarks.flatMap((bookmark) => {
+ if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) {
+ return [];
+ }
+
+ return [
+ { time: bookmark.time, type: "bookmark" satisfies SnapPoint["type"] },
+ ];
+ });
+}
diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts
new file mode 100644
index 00000000..d05fe3a9
--- /dev/null
+++ b/apps/web/src/timeline/bookmarks/utils.ts
@@ -0,0 +1,145 @@
+import type { Bookmark } from "@/timeline";
+import type { FrameRate } from "opencut-wasm";
+import { roundToFrame } from "opencut-wasm";
+
+function bookmarkTimeEqual({
+ bookmarkTime,
+ frameTime,
+}: {
+ bookmarkTime: number;
+ frameTime: number;
+}): boolean {
+ return bookmarkTime === frameTime;
+}
+
+export function findBookmarkIndex({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): number {
+ return bookmarks.findIndex((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function isBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): boolean {
+ return bookmarks.some((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function toggleBookmarkInArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
+
+ if (bookmarkIndex !== -1) {
+ return bookmarks.filter((_, index) => index !== bookmarkIndex);
+ }
+
+ const newBookmarks = [...bookmarks, { time: frameTime }];
+ return newBookmarks.slice().sort((a, b) => a.time - b.time);
+}
+
+export function removeBookmarkFromArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark[] {
+ return bookmarks.filter(
+ (bookmark) =>
+ !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
+}
+
+export function updateBookmarkInArray({
+ bookmarks,
+ frameTime,
+ updates,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ updates: Partial<Omit<Bookmark, "time">>;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], ...updates };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result;
+}
+
+export function moveBookmarkInArray({
+ bookmarks,
+ fromTime,
+ toTime,
+}: {
+ bookmarks: Bookmark[];
+ fromTime: number;
+ toTime: number;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], time: toTime };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result.slice().sort((a, b) => a.time - b.time);
+}
+
+export function getFrameTime({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: FrameRate;
+}): number {
+ return roundToFrame({ time, rate: fps }) ?? time;
+}
+
+export function getBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark | null {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ return index === -1 ? null : bookmarks[index];
+}
+
+export function getBookmarksActiveAtTime({
+ bookmarks,
+ time,
+}: {
+ bookmarks: Bookmark[];
+ time: number;
+}): Bookmark[] {
+ return bookmarks.filter((bookmark) => {
+ const start = bookmark.time;
+ const end =
+ bookmark.duration != null && bookmark.duration > 0
+ ? start + bookmark.duration
+ : start;
+ return time >= start && time <= end;
+ });
+}
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;
+}
diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts
new file mode 100644
index 00000000..2ac7f246
--- /dev/null
+++ b/apps/web/src/timeline/creation.ts
@@ -0,0 +1,3 @@
+import { TICKS_PER_SECOND } from "@/wasm/ticks";
+
+export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts
new file mode 100644
index 00000000..06639e01
--- /dev/null
+++ b/apps/web/src/timeline/defaults.ts
@@ -0,0 +1,77 @@
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
+import type { TTimelineViewState } from "@/project/types";
+import type { BlendMode, Transform } from "@/rendering";
+import type { TextElement } from "./types";
+
+const defaultTransform: Transform = {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+const defaultOpacity = 1;
+const defaultBlendMode: BlendMode = "normal";
+const defaultVolume = 0;
+
+const defaultTextLetterSpacing = 0;
+const defaultTextLineHeight = 1.2;
+
+const defaultTextBackground = {
+ enabled: false,
+ color: "#000000",
+ cornerRadius: 0,
+ paddingX: 30,
+ paddingY: 42,
+ offsetX: 0,
+ offsetY: 0,
+};
+
+const defaultTextElement: Omit<TextElement, "id"> = {
+ type: "text",
+ name: "Text",
+ content: "Default text",
+ fontSize: 15,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ background: { ...defaultTextBackground },
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ duration: DEFAULT_NEW_ELEMENT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...defaultTransform,
+ position: { ...defaultTransform.position },
+ },
+ opacity: defaultOpacity,
+};
+
+const defaultTimelineViewState: TTimelineViewState = {
+ zoomLevel: 1,
+ scrollLeft: 0,
+ playheadTime: 0,
+};
+
+export const DEFAULTS = {
+ element: {
+ transform: defaultTransform,
+ opacity: defaultOpacity,
+ blendMode: defaultBlendMode,
+ volume: defaultVolume,
+ },
+ text: {
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ background: defaultTextBackground,
+ element: defaultTextElement,
+ },
+ timeline: {
+ viewState: defaultTimelineViewState,
+ },
+};
diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts
new file mode 100644
index 00000000..1fc9ce9a
--- /dev/null
+++ b/apps/web/src/timeline/drag-data.ts
@@ -0,0 +1,48 @@
+import type { TimelineDragData } from "@/timeline/drag";
+
+const MIME_TYPE = "application/x-timeline-drag";
+let lastDragData: TimelineDragData | null = null;
+
+export function setDragData({
+ dataTransfer,
+ dragData,
+}: {
+ dataTransfer: DataTransfer;
+ dragData: TimelineDragData;
+}): void {
+ dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
+ dataTransfer.setData("text/plain", JSON.stringify(dragData));
+ lastDragData = dragData;
+}
+
+export function getDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): TimelineDragData | null {
+ const data = dataTransfer.getData(MIME_TYPE);
+ if (data) return JSON.parse(data) as TimelineDragData;
+
+ const textData = dataTransfer.getData("text/plain");
+ if (textData) {
+ try {
+ return JSON.parse(textData) as TimelineDragData;
+ } catch {
+ return lastDragData;
+ }
+ }
+
+ return lastDragData;
+}
+
+export function hasDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): boolean {
+ return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
+}
+
+export function clearDragData(): void {
+ lastDragData = null;
+}
diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts
new file mode 100644
index 00000000..a731d7a0
--- /dev/null
+++ b/apps/web/src/timeline/drag-utils.ts
@@ -0,0 +1,21 @@
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+
+export function getMouseTimeFromClientX({
+ clientX,
+ containerRect,
+ zoomLevel,
+ scrollLeft,
+}: {
+ clientX: number;
+ containerRect: DOMRect;
+ zoomLevel: number;
+ scrollLeft: number;
+}): number {
+ const mouseX = clientX - containerRect.left + scrollLeft;
+ const seconds = Math.max(
+ 0,
+ mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ );
+ return Math.round(seconds * TICKS_PER_SECOND);
+}
diff --git a/apps/web/src/timeline/drag.ts b/apps/web/src/timeline/drag.ts
new file mode 100644
index 00000000..7bf99a6d
--- /dev/null
+++ b/apps/web/src/timeline/drag.ts
@@ -0,0 +1,42 @@
+import type { MaskableElement, VisualElement } from "./types";
+import type { ParamValues } from "@/params";
+
+interface BaseDragData {
+ id: string;
+ name: string;
+}
+
+export interface MediaDragData extends BaseDragData {
+ type: "media";
+ mediaType: "image" | "video" | "audio";
+ targetElementTypes?: MaskableElement["type"][];
+}
+
+export interface TextDragData extends BaseDragData {
+ type: "text";
+ content: string;
+}
+
+export interface StickerDragData extends BaseDragData {
+ type: "sticker";
+ stickerId: string;
+}
+
+export interface GraphicDragData extends BaseDragData {
+ type: "graphic";
+ definitionId: string;
+ params: Partial<ParamValues>;
+}
+
+export interface EffectDragData extends BaseDragData {
+ type: "effect";
+ effectType: string;
+ targetElementTypes: VisualElement["type"][];
+}
+
+export type TimelineDragData =
+ | MediaDragData
+ | TextDragData
+ | StickerDragData
+ | GraphicDragData
+ | EffectDragData;
diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts
new file mode 100644
index 00000000..ec1e5f65
--- /dev/null
+++ b/apps/web/src/timeline/element-snap-source.ts
@@ -0,0 +1,38 @@
+import type { SceneTracks } from "@/timeline";
+import type { SnapPoint } from "@/timeline/snapping";
+
+export function getElementEdgeSnapPoints({
+ tracks,
+ excludeElementIds,
+}: {
+ tracks: SceneTracks;
+ excludeElementIds?: Set<string>;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+
+ for (const track of orderedTracks) {
+ for (const element of track.elements) {
+ if (excludeElementIds?.has(element.id)) {
+ continue;
+ }
+
+ snapPoints.push(
+ {
+ time: element.startTime,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: element.startTime + element.duration,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ }
+ }
+
+ return snapPoints;
+}
diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts
new file mode 100644
index 00000000..676bc823
--- /dev/null
+++ b/apps/web/src/timeline/element-utils.ts
@@ -0,0 +1,426 @@
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
+import {
+ MASKABLE_ELEMENT_TYPES,
+ RETIMABLE_ELEMENT_TYPES,
+ VISUAL_ELEMENT_TYPES,
+ type CreateEffectElement,
+ type CreateGraphicElement,
+ type CreateTimelineElement,
+ type CreateVideoElement,
+ type CreateImageElement,
+ type CreateStickerElement,
+ type CreateUploadAudioElement,
+ type CreateLibraryAudioElement,
+ type TextBackground,
+ type TextElement,
+ type SceneTracks,
+ type TimelineElement,
+ type AudioElement,
+ type VideoElement,
+ type ImageElement,
+ type MaskableElement,
+ type RetimableElement,
+ type VisualElement,
+ type UploadAudioElement,
+} from "@/timeline";
+import { DEFAULTS } from "@/timeline/defaults";
+import type { MediaType } from "@/media/types";
+import { buildDefaultEffectInstance } from "@/effects";
+import { buildDefaultGraphicInstance } from "@/graphics";
+import type { ParamValues } from "@/params";
+import { capitalizeFirstLetter } from "@/utils/string";
+
+export function canElementHaveAudio(
+ element: TimelineElement,
+): element is AudioElement | VideoElement {
+ return element.type === "audio" || element.type === "video";
+}
+
+export function isVisualElement(
+ element: TimelineElement,
+): element is VisualElement {
+ return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isMaskableElement(
+ element: TimelineElement,
+): element is MaskableElement {
+ return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isRetimableElement(
+ element: TimelineElement,
+): element is RetimableElement {
+ return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function canElementBeHidden(
+ element: TimelineElement,
+): element is VisualElement {
+ return isVisualElement(element);
+}
+
+export function hasElementEffects({
+ element,
+}: {
+ element: TimelineElement;
+}): boolean {
+ return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
+}
+
+export function hasMediaId(
+ element: TimelineElement,
+): element is UploadAudioElement | VideoElement | ImageElement {
+ return "mediaId" in element;
+}
+
+export function requiresMediaId({
+ element,
+}: {
+ element: CreateTimelineElement;
+}): boolean {
+ return (
+ element.type === "video" ||
+ element.type === "image" ||
+ (element.type === "audio" && element.sourceType === "upload")
+ );
+}
+
+function buildTextBackground(
+ raw: Partial<TextBackground> | undefined,
+): TextBackground {
+ const color = raw?.color ?? DEFAULTS.text.element.background.color;
+ const enabled = raw?.enabled ?? color !== "transparent";
+ return {
+ enabled,
+ color,
+ cornerRadius: raw?.cornerRadius,
+ paddingX: raw?.paddingX,
+ paddingY: raw?.paddingY,
+ offsetX: raw?.offsetX,
+ offsetY: raw?.offsetY,
+ };
+}
+
+export function buildTextElement({
+ raw,
+ startTime,
+}: {
+ raw: Partial<Omit<TextElement, "type" | "id">>;
+ startTime: number;
+}): CreateTimelineElement {
+ const t = raw as Partial<TextElement>;
+
+ return {
+ type: "text",
+ name: t.name ?? DEFAULTS.text.element.name,
+ content: t.content ?? DEFAULTS.text.element.content,
+ duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
+ fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
+ color: t.color ?? DEFAULTS.text.element.color,
+ background: buildTextBackground(t.background),
+ textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
+ fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
+ fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
+ textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
+ letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
+ lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
+ transform: t.transform ?? DEFAULTS.text.element.transform,
+ opacity: t.opacity ?? DEFAULTS.text.element.opacity,
+ blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
+ };
+}
+
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
+export function buildStickerElement({
+ stickerId,
+ name,
+ startTime,
+ intrinsicWidth,
+ intrinsicHeight,
+}: {
+ stickerId: string;
+ name?: string;
+ startTime: number;
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+}): CreateStickerElement {
+ const stickerNameFromId =
+ stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
+ return {
+ type: "sticker",
+ name: name ?? stickerNameFromId,
+ stickerId,
+ intrinsicWidth,
+ intrinsicHeight,
+ duration: DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+export function buildGraphicElement({
+ definitionId,
+ name,
+ startTime,
+ params,
+}: {
+ definitionId: string;
+ name?: string;
+ startTime: number;
+ params?: Partial<ParamValues>;
+}): CreateGraphicElement {
+ const instance = buildDefaultGraphicInstance({ definitionId });
+ return {
+ type: "graphic",
+ name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
+ definitionId: instance.definitionId,
+ params: { ...instance.params, ...(params ?? {}) } as ParamValues,
+ duration: DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildVideoElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateVideoElement {
+ return {
+ type: "video",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ muted: false,
+ isSourceAudioEnabled: true,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ volume: DEFAULTS.element.volume,
+ };
+}
+
+function buildImageElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateImageElement {
+ return {
+ type: "image",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateUploadAudioElement {
+ const element: CreateUploadAudioElement = {
+ type: "audio",
+ sourceType: "upload",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function buildElementFromMedia({
+ mediaId,
+ mediaType,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ mediaType: MediaType;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateTimelineElement {
+ switch (mediaType) {
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+ });
+ case "video":
+ return buildVideoElement({ mediaId, name, duration, startTime });
+ case "image":
+ return buildImageElement({ mediaId, name, duration, startTime });
+ }
+}
+
+export function buildLibraryAudioElement({
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ sourceUrl: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateLibraryAudioElement {
+ const element: CreateLibraryAudioElement = {
+ type: "audio",
+ sourceType: "library",
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function getElementsAtTime({
+ tracks,
+ time,
+}: {
+ tracks: SceneTracks;
+ time: number;
+}): { trackId: string; elementId: string }[] {
+ const result: { trackId: string; elementId: string }[] = [];
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+
+ for (const track of orderedTracks) {
+ for (const element of track.elements) {
+ const elementStart = element.startTime;
+ const elementEnd = element.startTime + element.duration;
+
+ if (time > elementStart && time < elementEnd) {
+ result.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ return result;
+}
+
+export function getElementFontFamilies({
+ tracks,
+}: {
+ tracks: SceneTracks;
+}): string[] {
+ const families = new Set<string>();
+ for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
+ for (const element of track.elements) {
+ if (element.type === "text" && element.fontFamily) {
+ families.add(element.fontFamily);
+ }
+ if ("masks" in element) {
+ for (const mask of element.masks ?? []) {
+ if (mask.type === "text" && mask.params.fontFamily) {
+ families.add(mask.params.fontFamily);
+ }
+ }
+ }
+ }
+ }
+ return [...families];
+}
diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts
new file mode 100644
index 00000000..00f39d55
--- /dev/null
+++ b/apps/web/src/timeline/group-move/build-group.ts
@@ -0,0 +1,87 @@
+import type { ElementRef, SceneTracks } from "@/timeline";
+import { findTrackInSceneTracks } from "@/timeline/track-element-update";
+import type { GroupMember, MoveGroup } from "./types";
+import { getTrackPlacementById } from "./track-placement";
+
+export function buildMoveGroup({
+ anchorRef,
+ selectedElements,
+ tracks,
+}: {
+ anchorRef: ElementRef;
+ selectedElements: ElementRef[];
+ tracks: SceneTracks;
+}): MoveGroup | null {
+ const anchorTrack = findTrackInSceneTracks({
+ tracks,
+ trackId: anchorRef.trackId,
+ });
+ const anchorElement = anchorTrack?.elements.find(
+ (element) => element.id === anchorRef.elementId,
+ );
+ const anchorPlacement = getTrackPlacementById({
+ tracks,
+ trackId: anchorRef.trackId,
+ });
+ if (!anchorTrack || !anchorElement || !anchorPlacement) {
+ return null;
+ }
+
+ const seen = new Set<string>();
+ const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => {
+ if (seen.has(elementRef.elementId)) {
+ return false;
+ }
+
+ seen.add(elementRef.elementId);
+ return true;
+ });
+
+ const members = orderedRefs.flatMap((elementRef): GroupMember[] => {
+ const track = findTrackInSceneTracks({
+ tracks,
+ trackId: elementRef.trackId,
+ });
+ const element = track?.elements.find(
+ (trackElement) => trackElement.id === elementRef.elementId,
+ );
+ const placement = getTrackPlacementById({
+ tracks,
+ trackId: elementRef.trackId,
+ });
+ if (!track || !element || !placement) {
+ return [];
+ }
+
+ return [
+ {
+ trackId: track.id,
+ elementId: element.id,
+ elementType: element.type,
+ duration: element.duration,
+ timeOffset: element.startTime - anchorElement.startTime,
+ trackSection: placement.section,
+ sectionIndex: placement.sectionIndex,
+ displayIndex: placement.displayIndex,
+ },
+ ];
+ });
+
+ if (members.length === 0) {
+ return null;
+ }
+
+ const anchor = members.find(
+ (member) =>
+ member.trackId === anchorRef.trackId &&
+ member.elementId === anchorRef.elementId,
+ );
+ if (!anchor) {
+ return null;
+ }
+
+ return {
+ anchor,
+ members,
+ };
+}
diff --git a/apps/web/src/timeline/group-move/index.ts b/apps/web/src/timeline/group-move/index.ts
new file mode 100644
index 00000000..82bd3895
--- /dev/null
+++ b/apps/web/src/timeline/group-move/index.ts
@@ -0,0 +1,12 @@
+export { buildMoveGroup } from "./build-group";
+export { resolveGroupMove } from "./resolve-move";
+export { snapGroupEdges } from "./snap";
+
+export type {
+ GroupMember,
+ GroupMoveResult,
+ GroupTrackSection,
+ MoveGroup,
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "./types";
diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts
new file mode 100644
index 00000000..60940315
--- /dev/null
+++ b/apps/web/src/timeline/group-move/resolve-move.ts
@@ -0,0 +1,470 @@
+import type { SceneTracks } from "@/timeline";
+import { getTrackTypeForElementType } from "@/timeline/placement/compatibility";
+import { canPlaceTimeSpansOnTrack } from "@/timeline/placement/overlap";
+import type {
+ GroupMoveResult,
+ MoveGroup,
+ PlannedElementMove,
+ PlannedTrackCreation,
+} from "./types";
+import {
+ getDisplayTracks,
+ getTrackPlacementByDisplayIndex,
+ getTrackPlacementById,
+} from "./track-placement";
+
+type GroupMoveTarget =
+ | {
+ kind: "existingTrack";
+ anchorTargetTrackId: string;
+ }
+ | {
+ kind: "newTracks";
+ anchorInsertIndex: number;
+ newTrackIds: string[];
+ };
+
+export function resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime,
+ target,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ target: GroupMoveTarget;
+}): GroupMoveResult | null {
+ if (target.kind === "newTracks") {
+ return resolveNewTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorInsertIndex: target.anchorInsertIndex,
+ newTrackIds: target.newTrackIds,
+ });
+ }
+
+ return resolveExistingTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorTargetTrackId: target.anchorTargetTrackId,
+ });
+}
+
+function resolveExistingTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorTargetTrackId,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ anchorTargetTrackId: string;
+}): GroupMoveResult | null {
+ const anchorTargetPlacement = getTrackPlacementById({
+ tracks,
+ trackId: anchorTargetTrackId,
+ });
+ if (!anchorTargetPlacement) {
+ return null;
+ }
+
+ const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({
+ group,
+ tracks,
+ anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex,
+ });
+ if (!targetTrackIdsByElementId) {
+ return null;
+ }
+
+ const clampedAnchorStartTime = clampAnchorStartTime({
+ group,
+ tracks,
+ anchorStartTime,
+ targetTrackIdsByElementId,
+ });
+
+ const moves = group.members.map((member) => ({
+ sourceTrackId: member.trackId,
+ targetTrackId:
+ targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
+ elementId: member.elementId,
+ newStartTime: clampedAnchorStartTime + member.timeOffset,
+ }));
+
+ if (!canApplyMovesToExistingTracks({ tracks, moves })) {
+ return null;
+ }
+
+ return {
+ moves,
+ createTracks: [],
+ targetSelection: moves.map(({ elementId, targetTrackId }) => ({
+ trackId: targetTrackId,
+ elementId,
+ })),
+ };
+}
+
+function resolveNewTrackMove({
+ group,
+ tracks,
+ anchorStartTime,
+ anchorInsertIndex,
+ newTrackIds,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ anchorInsertIndex: number;
+ newTrackIds: string[];
+}): GroupMoveResult | null {
+ const sortedMembers = [...group.members].sort(
+ (leftMember, rightMember) =>
+ leftMember.displayIndex - rightMember.displayIndex,
+ );
+ const anchorMemberIndex = sortedMembers.findIndex(
+ (member) => member.elementId === group.anchor.elementId,
+ );
+ if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) {
+ return null;
+ }
+
+ const hasAudioMember = sortedMembers.some(
+ (member) => member.trackSection === "audio",
+ );
+ const hasNonAudioMember = sortedMembers.some(
+ (member) => member.trackSection !== "audio",
+ );
+ if (hasAudioMember && hasNonAudioMember) {
+ return null;
+ }
+
+ const clampedAnchorStartTime = clampAnchorStartTime({
+ group,
+ tracks,
+ anchorStartTime,
+ targetTrackIdsByElementId: new Map(),
+ });
+ const blockStartIndex = hasAudioMember
+ ? clampAudioInsertIndex({
+ tracks,
+ insertIndex: anchorInsertIndex - anchorMemberIndex,
+ })
+ : Math.max(
+ 0,
+ Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length),
+ );
+
+ const createTracks: PlannedTrackCreation[] = sortedMembers.map(
+ (member, memberIndex) => ({
+ id: newTrackIds[memberIndex],
+ type: getTrackTypeForElementType({
+ elementType: member.elementType,
+ }),
+ index: blockStartIndex + memberIndex,
+ }),
+ );
+ const moves = sortedMembers.map((member, memberIndex) => ({
+ sourceTrackId: member.trackId,
+ targetTrackId: newTrackIds[memberIndex],
+ elementId: member.elementId,
+ newStartTime: clampedAnchorStartTime + member.timeOffset,
+ }));
+
+ return {
+ moves,
+ createTracks,
+ targetSelection: moves.map(({ elementId, targetTrackId }) => ({
+ trackId: targetTrackId,
+ elementId,
+ })),
+ };
+}
+
+function clampAudioInsertIndex({
+ tracks,
+ insertIndex,
+}: {
+ tracks: SceneTracks;
+ insertIndex: number;
+}): number {
+ const minimumAudioInsertIndex = tracks.overlay.length + 1;
+ return Math.max(
+ minimumAudioInsertIndex,
+ Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length),
+ );
+}
+
+function resolveExistingTrackIdsByElementId({
+ group,
+ tracks,
+ anchorTargetDisplayIndex,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorTargetDisplayIndex: number;
+}): Map<string, string> | null {
+ const sortedMembers = [...group.members].sort(
+ (leftMember, rightMember) =>
+ leftMember.displayIndex - rightMember.displayIndex,
+ );
+ const anchorMemberIndex = sortedMembers.findIndex(
+ (member) => member.elementId === group.anchor.elementId,
+ );
+ if (anchorMemberIndex < 0) {
+ return null;
+ }
+
+ const targetTrackIdsByElementId = new Map<string, string>();
+ const usedTrackIds = new Set<string>();
+ const anchorPlacement = getTrackPlacementByDisplayIndex({
+ tracks,
+ displayIndex: anchorTargetDisplayIndex,
+ });
+ if (!anchorPlacement) {
+ return null;
+ }
+
+ targetTrackIdsByElementId.set(
+ group.anchor.elementId,
+ anchorPlacement.trackId,
+ );
+ usedTrackIds.add(anchorPlacement.trackId);
+
+ let upperBoundaryIndex = anchorTargetDisplayIndex;
+ for (
+ let memberIndex = anchorMemberIndex - 1;
+ memberIndex >= 0;
+ memberIndex -= 1
+ ) {
+ const member = sortedMembers[memberIndex];
+ const targetPlacement = findCompatibleTrackPlacement({
+ tracks,
+ requiredTrackType: getTrackTypeForElementType({
+ elementType: member.elementType,
+ }),
+ startDisplayIndex: upperBoundaryIndex - 1,
+ step: -1,
+ usedTrackIds,
+ });
+ if (!targetPlacement) {
+ return null;
+ }
+
+ targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
+ usedTrackIds.add(targetPlacement.trackId);
+ upperBoundaryIndex = targetPlacement.displayIndex;
+ }
+
+ let lowerBoundaryIndex = anchorTargetDisplayIndex;
+ for (
+ let memberIndex = anchorMemberIndex + 1;
+ memberIndex < sortedMembers.length;
+ memberIndex += 1
+ ) {
+ const member = sortedMembers[memberIndex];
+ const targetPlacement = findCompatibleTrackPlacement({
+ tracks,
+ requiredTrackType: getTrackTypeForElementType({
+ elementType: member.elementType,
+ }),
+ startDisplayIndex: lowerBoundaryIndex + 1,
+ step: 1,
+ usedTrackIds,
+ });
+ if (!targetPlacement) {
+ return null;
+ }
+
+ targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
+ usedTrackIds.add(targetPlacement.trackId);
+ lowerBoundaryIndex = targetPlacement.displayIndex;
+ }
+
+ return targetTrackIdsByElementId;
+}
+
+function findCompatibleTrackPlacement({
+ tracks,
+ requiredTrackType,
+ startDisplayIndex,
+ step,
+ usedTrackIds,
+}: {
+ tracks: SceneTracks;
+ requiredTrackType: ReturnType<typeof getTrackTypeForElementType>;
+ startDisplayIndex: number;
+ step: -1 | 1;
+ usedTrackIds: Set<string>;
+}) {
+ for (
+ let displayIndex = startDisplayIndex;
+ displayIndex >= 0 &&
+ displayIndex < tracks.overlay.length + 1 + tracks.audio.length;
+ displayIndex += step
+ ) {
+ const placement = getTrackPlacementByDisplayIndex({
+ tracks,
+ displayIndex,
+ });
+ if (!placement) {
+ continue;
+ }
+
+ if (
+ placement.trackType === requiredTrackType &&
+ !usedTrackIds.has(placement.trackId)
+ ) {
+ return placement;
+ }
+ }
+
+ return null;
+}
+
+function clampAnchorStartTime({
+ group,
+ tracks,
+ anchorStartTime,
+ targetTrackIdsByElementId,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: number;
+ targetTrackIdsByElementId: Map<string, string>;
+}): number {
+ const minimumAnchorStartTime = Math.max(
+ 0,
+ ...group.members.map((member) => -member.timeOffset),
+ );
+ let clampedAnchorStartTime = Math.max(
+ minimumAnchorStartTime,
+ anchorStartTime,
+ );
+
+ const memberOnMainTrack = group.members.find(
+ (member) =>
+ targetTrackIdsByElementId.get(member.elementId) === tracks.main.id,
+ );
+ if (!memberOnMainTrack) {
+ return clampedAnchorStartTime;
+ }
+
+ const movingElementIds = new Set(
+ group.members.map((member) => member.elementId),
+ );
+ const requestedMainStartTime =
+ clampedAnchorStartTime + memberOnMainTrack.timeOffset;
+ const earliestStationaryMainStartTime = tracks.main.elements
+ .filter((element) => !movingElementIds.has(element.id))
+ .reduce<number | null>((earliestStartTime, element) => {
+ if (earliestStartTime == null || element.startTime < earliestStartTime) {
+ return element.startTime;
+ }
+
+ return earliestStartTime;
+ }, null);
+ if (
+ earliestStationaryMainStartTime == null ||
+ requestedMainStartTime <= earliestStationaryMainStartTime
+ ) {
+ clampedAnchorStartTime = Math.max(
+ minimumAnchorStartTime,
+ -memberOnMainTrack.timeOffset,
+ );
+ }
+
+ return clampedAnchorStartTime;
+}
+
+function canApplyMovesToExistingTracks({
+ tracks,
+ moves,
+}: {
+ tracks: SceneTracks;
+ moves: PlannedElementMove[];
+}): boolean {
+ const movingElementIds = new Set(moves.map((move) => move.elementId));
+ const sourceElements = new Map(
+ getDisplayTracks({ tracks }).flatMap((track) =>
+ track.elements.map((element) => [element.id, element] as const),
+ ),
+ );
+ const movesByTargetTrackId = new Map<string, PlannedElementMove[]>();
+ for (const move of moves) {
+ const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? [];
+ targetMoves.push(move);
+ movesByTargetTrackId.set(move.targetTrackId, targetMoves);
+ }
+
+ for (const [targetTrackId, targetMoves] of movesByTargetTrackId) {
+ const targetPlacement = getTrackPlacementById({
+ tracks,
+ trackId: targetTrackId,
+ });
+ if (!targetPlacement) {
+ return false;
+ }
+
+ const targetTrack = getDisplayTracks({ tracks })[
+ targetPlacement.displayIndex
+ ];
+ if (!targetTrack) {
+ return false;
+ }
+
+ const timeSpans = targetMoves.map((move) => {
+ const sourceElement = sourceElements.get(move.elementId);
+ return {
+ startTime: move.newStartTime,
+ duration: sourceElement?.duration ?? 0,
+ };
+ });
+ if (hasOverlappingTimeSpans({ timeSpans })) {
+ return false;
+ }
+
+ if (
+ !canPlaceTimeSpansOnTrack({
+ track: {
+ elements: targetTrack.elements.filter(
+ (element) => !movingElementIds.has(element.id),
+ ),
+ },
+ timeSpans,
+ })
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function hasOverlappingTimeSpans({
+ timeSpans,
+}: {
+ timeSpans: Array<{ startTime: number; duration: number }>;
+}): boolean {
+ const sortedSpans = [...timeSpans].sort(
+ (leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime,
+ );
+
+ for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) {
+ const previousSpan = sortedSpans[spanIndex - 1];
+ const currentSpan = sortedSpans[spanIndex];
+ if (
+ previousSpan.startTime + previousSpan.duration >
+ currentSpan.startTime
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts
new file mode 100644
index 00000000..32c0a2f1
--- /dev/null
+++ b/apps/web/src/timeline/group-move/snap.ts
@@ -0,0 +1,85 @@
+import type { SceneTracks } from "@/timeline";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+ type SnapPoint,
+} from "@/timeline/snapping";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import type { MoveGroup } from "./types";
+
+export function snapGroupEdges({
+ group,
+ anchorStartTime,
+ tracks,
+ playheadTime,
+ zoomLevel,
+}: {
+ group: MoveGroup;
+ anchorStartTime: number;
+ tracks: SceneTracks;
+ playheadTime: number;
+ zoomLevel: number;
+}): {
+ snappedAnchorStartTime: number;
+ snapPoint: SnapPoint | null;
+} {
+ const excludeElementIds = new Set(
+ group.members.map((member) => member.elementId),
+ );
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
+ () => getPlayheadSnapPoints({ playheadTime }),
+ () =>
+ getAnimationKeyframeSnapPointsForTimeline({
+ tracks,
+ excludeElementIds,
+ }),
+ ],
+ });
+ const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
+
+ let closestSnapDistance = Infinity;
+ let snappedAnchorStartTime = anchorStartTime;
+ let snapPoint: SnapPoint | null = null;
+
+ for (const member of group.members) {
+ const memberStartTime = anchorStartTime + member.timeOffset;
+ const memberStartSnap = resolveTimelineSnap({
+ targetTime: memberStartTime,
+ snapPoints,
+ maxSnapDistance,
+ });
+ if (
+ memberStartSnap.snapPoint &&
+ memberStartSnap.snapDistance < closestSnapDistance
+ ) {
+ closestSnapDistance = memberStartSnap.snapDistance;
+ snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
+ snapPoint = memberStartSnap.snapPoint;
+ }
+
+ const memberEndSnap = resolveTimelineSnap({
+ targetTime: memberStartTime + member.duration,
+ snapPoints,
+ maxSnapDistance,
+ });
+ if (
+ memberEndSnap.snapPoint &&
+ memberEndSnap.snapDistance < closestSnapDistance
+ ) {
+ closestSnapDistance = memberEndSnap.snapDistance;
+ snappedAnchorStartTime =
+ memberEndSnap.snappedTime - member.duration - member.timeOffset;
+ snapPoint = memberEndSnap.snapPoint;
+ }
+ }
+
+ return {
+ snappedAnchorStartTime,
+ snapPoint,
+ };
+}
diff --git a/apps/web/src/timeline/group-move/track-placement.ts b/apps/web/src/timeline/group-move/track-placement.ts
new file mode 100644
index 00000000..c4c5efb5
--- /dev/null
+++ b/apps/web/src/timeline/group-move/track-placement.ts
@@ -0,0 +1,83 @@
+import type { SceneTracks, TimelineTrack } from "@/timeline";
+import type { GroupTrackSection } from "./types";
+
+export interface TrackPlacement {
+ trackId: string;
+ trackType: TimelineTrack["type"];
+ section: GroupTrackSection;
+ sectionIndex: number;
+ displayIndex: number;
+}
+
+export function getDisplayTracks({
+ tracks,
+}: {
+ tracks: SceneTracks;
+}): TimelineTrack[] {
+ return [...tracks.overlay, tracks.main, ...tracks.audio];
+}
+
+export function getTrackPlacementById({
+ tracks,
+ trackId,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+}): TrackPlacement | null {
+ if (tracks.main.id === trackId) {
+ return {
+ trackId,
+ trackType: tracks.main.type,
+ section: "main",
+ sectionIndex: -1,
+ displayIndex: tracks.overlay.length,
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex(
+ (track) => track.id === trackId,
+ );
+ if (overlayTrackIndex >= 0) {
+ return {
+ trackId,
+ trackType: tracks.overlay[overlayTrackIndex].type,
+ section: "overlay",
+ sectionIndex: overlayTrackIndex,
+ displayIndex: overlayTrackIndex,
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex(
+ (track) => track.id === trackId,
+ );
+ if (audioTrackIndex >= 0) {
+ return {
+ trackId,
+ trackType: tracks.audio[audioTrackIndex].type,
+ section: "audio",
+ sectionIndex: audioTrackIndex,
+ displayIndex: tracks.overlay.length + 1 + audioTrackIndex,
+ };
+ }
+
+ return null;
+}
+
+export function getTrackPlacementByDisplayIndex({
+ tracks,
+ displayIndex,
+}: {
+ tracks: SceneTracks;
+ displayIndex: number;
+}): TrackPlacement | null {
+ const displayTracks = getDisplayTracks({ tracks });
+ const track = displayTracks[displayIndex];
+ if (!track) {
+ return null;
+ }
+
+ return getTrackPlacementById({
+ tracks,
+ trackId: track.id,
+ });
+}
diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts
new file mode 100644
index 00000000..daf9c6e9
--- /dev/null
+++ b/apps/web/src/timeline/group-move/types.ts
@@ -0,0 +1,36 @@
+import type { ElementRef, ElementType, TrackType } from "@/timeline";
+
+export type GroupTrackSection = "overlay" | "main" | "audio";
+
+export interface GroupMember extends ElementRef {
+ elementType: ElementType;
+ duration: number;
+ timeOffset: number;
+ trackSection: GroupTrackSection;
+ sectionIndex: number;
+ displayIndex: number;
+}
+
+export interface MoveGroup {
+ anchor: GroupMember;
+ members: GroupMember[];
+}
+
+export interface PlannedTrackCreation {
+ id: string;
+ type: TrackType;
+ index: number;
+}
+
+export interface PlannedElementMove {
+ sourceTrackId: string;
+ targetTrackId: string;
+ elementId: string;
+ newStartTime: number;
+}
+
+export interface GroupMoveResult {
+ moves: PlannedElementMove[];
+ createTracks: PlannedTrackCreation[];
+ targetSelection: ElementRef[];
+}
diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts
new file mode 100644
index 00000000..dd542967
--- /dev/null
+++ b/apps/web/src/timeline/group-resize/compute-resize.ts
@@ -0,0 +1,249 @@
+import { roundToFrame } from "opencut-wasm";
+import {
+ getSourceSpanAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/retime";
+import { TICKS_PER_SECOND } from "@/wasm";
+import type {
+ ComputeGroupResizeArgs,
+ GroupResizeMember,
+ GroupResizeResult,
+ GroupResizeUpdate,
+ ResizeSide,
+} from "./types";
+
+export function computeGroupResize({
+ members,
+ side,
+ deltaTime,
+ fps,
+}: ComputeGroupResizeArgs): GroupResizeResult {
+ const minDuration = Math.round(
+ (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
+ );
+ const minimumDeltaTime = Math.max(
+ ...members.map((member) =>
+ getMinimumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+ }),
+ ),
+ );
+ const maximumDeltaTime = Math.min(
+ ...members.map((member) =>
+ getMaximumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+ }),
+ ),
+ );
+ const clampedDeltaTime =
+ minimumDeltaTime > maximumDeltaTime
+ ? minimumDeltaTime
+ : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
+
+ // Snap the drag delta to a frame exactly once, then derive every patch
+ // field from that single snapped value. This keeps the invariant
+ // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
+ // delta is added on one side of the element and removed from the other,
+ // so the rounding cancels by construction. Per-field rounding (the old
+ // approach) couldn't preserve this because the individual rounds don't
+ // compose when `sourceDuration` isn't frame-aligned.
+ const snappedDeltaTime =
+ roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
+ // Re-clamp after rounding. Bounds derived from other elements are
+ // frame-aligned, so this is normally a no-op; at the source-extent limit
+ // the bound may not be frame-aligned, and honouring the bound takes
+ // precedence over frame alignment (you can't extend past real content).
+ const finalDeltaTime =
+ minimumDeltaTime > maximumDeltaTime
+ ? minimumDeltaTime
+ : Math.min(
+ maximumDeltaTime,
+ Math.max(minimumDeltaTime, snappedDeltaTime),
+ );
+
+ return {
+ deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
+ updates: members.map((member) =>
+ buildResizeUpdate({
+ member,
+ side,
+ deltaTime: finalDeltaTime,
+ }),
+ ),
+ };
+}
+
+function buildResizeUpdate({
+ member,
+ side,
+ deltaTime,
+}: {
+ member: GroupResizeMember;
+ side: ResizeSide;
+ deltaTime: number;
+}): GroupResizeUpdate {
+ const sourceDelta = getSourceDeltaForClipDelta({
+ member,
+ clipDelta: deltaTime,
+ });
+
+ if (side === "left") {
+ return {
+ trackId: member.trackId,
+ elementId: member.elementId,
+ patch: {
+ trimStart: Math.max(0, member.trimStart + sourceDelta),
+ trimEnd: member.trimEnd,
+ startTime: member.startTime + deltaTime,
+ duration: member.duration - deltaTime,
+ },
+ };
+ }
+
+ return {
+ trackId: member.trackId,
+ elementId: member.elementId,
+ patch: {
+ trimStart: member.trimStart,
+ trimEnd: Math.max(0, member.trimEnd - sourceDelta),
+ startTime: member.startTime,
+ duration: member.duration + deltaTime,
+ },
+ };
+}
+
+function getMinimumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+}: {
+ member: GroupResizeMember;
+ side: ResizeSide;
+ minDuration: number;
+}): number {
+ if (side === "right") {
+ return minDuration - member.duration;
+ }
+
+ const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
+ ? member.leftNeighborBound - member.startTime
+ : -member.startTime;
+ if (member.sourceDuration == null) {
+ return leftNeighborFloor;
+ }
+
+ const maximumSourceExtension =
+ getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan:
+ getVisibleSourceSpanForDuration({
+ member,
+ duration: member.duration,
+ }) + member.trimStart,
+ }) - member.duration;
+ return Math.max(leftNeighborFloor, -maximumSourceExtension);
+}
+
+function getMaximumAllowedDeltaTime({
+ member,
+ side,
+ minDuration,
+}: {
+ member: GroupResizeMember;
+ side: ResizeSide;
+ minDuration: number;
+}): number {
+ if (side === "left") {
+ return member.duration - minDuration;
+ }
+
+ const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
+ ? member.rightNeighborBound - (member.startTime + member.duration)
+ : Infinity;
+ if (member.sourceDuration == null) {
+ return rightNeighborCeiling;
+ }
+
+ const maximumVisibleSourceSpan =
+ getSourceDuration({ member }) - member.trimStart;
+ const maximumDuration = getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan: maximumVisibleSourceSpan,
+ });
+ return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
+}
+
+function getSourceDeltaForClipDelta({
+ member,
+ clipDelta,
+}: {
+ member: GroupResizeMember;
+ clipDelta: number;
+}): number {
+ if (!member.retime) {
+ return clipDelta;
+ }
+
+ return clipDelta >= 0
+ ? getSourceSpanAtClipTime({
+ clipTime: clipDelta,
+ retime: member.retime,
+ })
+ : -getSourceSpanAtClipTime({
+ clipTime: Math.abs(clipDelta),
+ retime: member.retime,
+ });
+}
+
+function getVisibleSourceSpanForDuration({
+ member,
+ duration,
+}: {
+ member: GroupResizeMember;
+ duration: number;
+}): number {
+ if (!member.retime) {
+ return duration;
+ }
+
+ return getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime: member.retime,
+ });
+}
+
+function getDurationForVisibleSourceSpan({
+ member,
+ sourceSpan,
+}: {
+ member: GroupResizeMember;
+ sourceSpan: number;
+}): number {
+ if (!member.retime) {
+ return sourceSpan;
+ }
+
+ return getTimelineDurationForSourceSpan({
+ sourceSpan,
+ retime: member.retime,
+ });
+}
+
+function getSourceDuration({ member }: { member: GroupResizeMember }): number {
+ if (typeof member.sourceDuration === "number") {
+ return member.sourceDuration;
+ }
+
+ return (
+ member.trimStart +
+ getVisibleSourceSpanForDuration({
+ member,
+ duration: member.duration,
+ }) +
+ member.trimEnd
+ );
+}
diff --git a/apps/web/src/timeline/group-resize/index.ts b/apps/web/src/timeline/group-resize/index.ts
new file mode 100644
index 00000000..bc0a7b35
--- /dev/null
+++ b/apps/web/src/timeline/group-resize/index.ts
@@ -0,0 +1,9 @@
+export { computeGroupResize } from "./compute-resize";
+
+export type {
+ ComputeGroupResizeArgs,
+ GroupResizeMember,
+ GroupResizeResult,
+ GroupResizeUpdate,
+ ResizeSide,
+} from "./types";
diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts
new file mode 100644
index 00000000..b07e6fe6
--- /dev/null
+++ b/apps/web/src/timeline/group-resize/types.ts
@@ -0,0 +1,36 @@
+import type { FrameRate } from "opencut-wasm";
+import type { ElementRef, RetimeConfig } from "@/timeline/types";
+
+export type ResizeSide = "left" | "right";
+
+export interface GroupResizeMember extends ElementRef {
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ sourceDuration?: number;
+ retime?: RetimeConfig;
+ leftNeighborBound: number;
+ rightNeighborBound: number;
+}
+
+export interface GroupResizeUpdate extends ElementRef {
+ patch: {
+ trimStart: number;
+ trimEnd: number;
+ startTime: number;
+ duration: number;
+ };
+}
+
+export interface GroupResizeResult {
+ deltaTime: number;
+ updates: GroupResizeUpdate[];
+}
+
+export interface ComputeGroupResizeArgs {
+ members: GroupResizeMember[];
+ side: ResizeSide;
+ deltaTime: number;
+ fps: FrameRate;
+}
diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
new file mode 100644
index 00000000..621259b9
--- /dev/null
+++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
@@ -0,0 +1,760 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type MouseEvent as ReactMouseEvent,
+ type RefObject,
+} from "react";
+import { useEditor } from "@/editor/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import {
+ buildMoveGroup,
+ resolveGroupMove,
+ snapGroupEdges,
+ type GroupMoveResult,
+ type MoveGroup,
+} from "@/timeline/group-move";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
+import { roundToFrame } from "opencut-wasm";
+import { computeDropTarget } from "@/timeline/components/drop-target";
+import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
+import { generateUUID } from "@/utils/id";
+import type { SnapPoint } from "@/timeline/snapping";
+import { registerCanceller } from "@/editor/cancel-interaction";
+import type {
+ DropTarget,
+ ElementRef,
+ ElementDragState,
+ SceneTracks,
+ TimelineElement,
+ TimelineTrack,
+} from "@/timeline";
+
+interface UseElementInteractionProps {
+ zoomLevel: number;
+ timelineRef: RefObject<HTMLDivElement | null>;
+ tracksContainerRef: RefObject<HTMLDivElement | null>;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ snappingEnabled: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+const MOUSE_BUTTON_RIGHT = 2;
+
+const initialDragState: ElementDragState = {
+ isDragging: false,
+ elementId: null,
+ dragElementIds: [],
+ dragTimeOffsets: {},
+ trackId: null,
+ startMouseX: 0,
+ startMouseY: 0,
+ startElementTime: 0,
+ clickOffsetTime: 0,
+ currentTime: 0,
+ currentMouseY: 0,
+};
+
+interface PendingDragState {
+ elementId: string;
+ trackId: string;
+ selectedElements: ElementRef[];
+ startMouseX: number;
+ startMouseY: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+}
+
+function getClickOffsetTime({
+ clientX,
+ elementRect,
+ zoomLevel,
+}: {
+ clientX: number;
+ elementRect: DOMRect;
+ zoomLevel: number;
+}): number {
+ const clickOffsetX = clientX - elementRect.left;
+ const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
+ return Math.round(seconds * TICKS_PER_SECOND);
+}
+
+function getVerticalDragDirection({
+ startMouseY,
+ currentMouseY,
+}: {
+ startMouseY: number;
+ currentMouseY: number;
+}): "up" | "down" | null {
+ if (currentMouseY < startMouseY) return "up";
+ if (currentMouseY > startMouseY) return "down";
+ return null;
+}
+
+function getDragDropTarget({
+ clientX,
+ clientY,
+ elementId,
+ trackId,
+ tracks,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ zoomLevel,
+ snappedTime,
+ verticalDragDirection,
+}: {
+ clientX: number;
+ clientY: number;
+ elementId: string;
+ trackId: string;
+ tracks: SceneTracks;
+ tracksContainerRef: RefObject<HTMLDivElement | null>;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ zoomLevel: number;
+ snappedTime: number;
+ verticalDragDirection?: "up" | "down" | null;
+}): DropTarget | null {
+ const containerRect = tracksContainerRef.current?.getBoundingClientRect();
+ const scrollContainer = tracksScrollRef.current;
+ if (!containerRect || !scrollContainer) return null;
+
+ const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
+ ({ id }) => id === trackId,
+ );
+ const movingElement = sourceTrack?.elements.find(
+ ({ id }) => id === elementId,
+ );
+ if (!movingElement) return null;
+
+ const elementDuration = movingElement.duration;
+ const scrollLeft = scrollContainer.scrollLeft;
+ const scrollTop = scrollContainer.scrollTop;
+ const scrollContainerRect = scrollContainer.getBoundingClientRect();
+ const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const mouseX = clientX - scrollContainerRect.left + scrollLeft;
+ const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
+
+ return computeDropTarget({
+ elementType: movingElement.type,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime: snappedTime,
+ isExternalDrop: false,
+ elementDuration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel,
+ startTimeOverride: snappedTime,
+ excludeElementId: movingElement.id,
+ verticalDragDirection,
+ });
+}
+
+interface StartDragParams
+ extends Omit<
+ ElementDragState,
+ "isDragging" | "currentTime" | "currentMouseY"
+ > {
+ initialCurrentTime: number;
+ initialCurrentMouseY: number;
+}
+
+export function useElementInteraction({
+ zoomLevel,
+ timelineRef,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ snappingEnabled,
+ onSnapPointChange,
+}: UseElementInteractionProps) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const sceneTracks = editor.scenes.getActiveScene().tracks;
+ const {
+ selectedElements,
+ isElementSelected,
+ selectElement,
+ handleElementClick: handleSelectionClick,
+ } = useElementSelection();
+
+ const [dragState, setDragState] =
+ useState<ElementDragState>(initialDragState);
+ const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+ const pendingDragRef = useRef<PendingDragState | null>(null);
+ const moveGroupRef = useRef<MoveGroup | null>(null);
+ const newTrackIdsRef = useRef<string[]>([]);
+ const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
+ const lastMouseXRef = useRef(0);
+ const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
+
+ const startDrag = useCallback(
+ ({
+ elementId,
+ dragElementIds,
+ dragTimeOffsets,
+ trackId,
+ startMouseX,
+ startMouseY,
+ startElementTime,
+ clickOffsetTime,
+ initialCurrentTime,
+ initialCurrentMouseY,
+ }: StartDragParams) => {
+ setDragState({
+ isDragging: true,
+ elementId,
+ dragElementIds,
+ dragTimeOffsets,
+ trackId,
+ startMouseX,
+ startMouseY,
+ startElementTime,
+ clickOffsetTime,
+ currentTime: initialCurrentTime,
+ currentMouseY: initialCurrentMouseY,
+ });
+ },
+ [],
+ );
+
+ const endDrag = useCallback(() => {
+ moveGroupRef.current = null;
+ newTrackIdsRef.current = [];
+ groupMoveResultRef.current = null;
+ setDragState(initialDragState);
+ setDragDropTarget(null);
+ }, []);
+
+ const cancelCurrentDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownLocationRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ onSnapPointChange?.(null);
+ }, [endDrag, onSnapPointChange]);
+
+ const resolveGroupDragMove = useCallback(
+ ({
+ group,
+ snappedTime,
+ dropTarget,
+ }: {
+ group: MoveGroup;
+ snappedTime: number;
+ dropTarget: DropTarget | null;
+ }): GroupMoveResult | null => {
+ if (!dropTarget) {
+ return null;
+ }
+
+ if (dropTarget.isNewTrack) {
+ return resolveGroupMove({
+ group,
+ tracks: sceneTracks,
+ anchorStartTime: snappedTime,
+ target: {
+ kind: "newTracks",
+ anchorInsertIndex: dropTarget.trackIndex,
+ newTrackIds: newTrackIdsRef.current,
+ },
+ });
+ }
+
+ const orderedTracks = [
+ ...sceneTracks.overlay,
+ sceneTracks.main,
+ ...sceneTracks.audio,
+ ];
+ const targetTrack = orderedTracks[dropTarget.trackIndex];
+ if (!targetTrack) {
+ return null;
+ }
+
+ const existingTrackResult = resolveGroupMove({
+ group,
+ tracks: sceneTracks,
+ anchorStartTime: snappedTime,
+ target: {
+ kind: "existingTrack",
+ anchorTargetTrackId: targetTrack.id,
+ },
+ });
+ if (existingTrackResult) {
+ return existingTrackResult;
+ }
+
+ return resolveGroupMove({
+ group,
+ tracks: sceneTracks,
+ anchorStartTime: snappedTime,
+ target: {
+ kind: "newTracks",
+ anchorInsertIndex: dropTarget.trackIndex,
+ newTrackIds: newTrackIdsRef.current,
+ },
+ });
+ },
+ [sceneTracks],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ return registerCanceller({ fn: cancelCurrentDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
+
+ const getDragSnapResult = useCallback(
+ ({
+ frameSnappedTime,
+ group,
+ }: {
+ frameSnappedTime: number;
+ group: MoveGroup | null;
+ }) => {
+ if (!group || !snappingEnabled || isShiftHeldRef.current) {
+ return { snappedTime: frameSnappedTime, snapPoint: null };
+ }
+
+ const groupSnap = snapGroupEdges({
+ group,
+ anchorStartTime: frameSnappedTime,
+ tracks: sceneTracks,
+ playheadTime: editor.playback.getCurrentTime(),
+ zoomLevel,
+ });
+
+ return {
+ snappedTime: groupSnap.snappedAnchorStartTime,
+ snapPoint: groupSnap.snapPoint,
+ };
+ },
+ [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
+ let startedDragThisEvent = false;
+ const timeline = timelineRef.current;
+ const scrollContainer = tracksScrollRef.current;
+ if (!timeline || !scrollContainer) return;
+ lastMouseXRef.current = clientX;
+
+ if (isPendingDrag && pendingDragRef.current) {
+ const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
+ const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
+ if (
+ deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
+ deltaY > TIMELINE_DRAG_THRESHOLD_PX
+ ) {
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const adjustedTime = Math.max(
+ 0,
+ mouseTime - pendingDragRef.current.clickOffsetTime,
+ );
+ const snappedTime =
+ roundToFrame({
+ time: adjustedTime,
+ rate: activeProject.settings.fps,
+ }) ?? adjustedTime;
+ const moveGroup = buildMoveGroup({
+ anchorRef: {
+ trackId: pendingDragRef.current.trackId,
+ elementId: pendingDragRef.current.elementId,
+ },
+ selectedElements: pendingDragRef.current.selectedElements,
+ tracks: sceneTracks,
+ });
+ if (!moveGroup) {
+ return;
+ }
+
+ moveGroupRef.current = moveGroup;
+ newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
+ const dragTimeOffsets: Record<string, number> = {};
+ for (const member of moveGroup.members) {
+ dragTimeOffsets[member.elementId] = member.timeOffset;
+ }
+ const {
+ snappedTime: initialSnappedTime,
+ snapPoint: initialSnapPoint,
+ } = getDragSnapResult({
+ frameSnappedTime: snappedTime,
+ group: moveGroup,
+ });
+ const verticalDragDirection = getVerticalDragDirection({
+ startMouseY: pendingDragRef.current.startMouseY,
+ currentMouseY: clientY,
+ });
+ const anchorDropTarget = getDragDropTarget({
+ clientX,
+ clientY,
+ elementId: pendingDragRef.current.elementId,
+ trackId: pendingDragRef.current.trackId,
+ tracks: sceneTracks,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ zoomLevel,
+ snappedTime: initialSnappedTime,
+ verticalDragDirection,
+ });
+ const nextGroupMoveResult =
+ anchorDropTarget != null
+ ? resolveGroupDragMove({
+ group: moveGroup,
+ snappedTime: initialSnappedTime,
+ dropTarget: anchorDropTarget,
+ })
+ : null;
+ groupMoveResultRef.current = nextGroupMoveResult;
+ setDragDropTarget(
+ anchorDropTarget &&
+ (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
+ ? {
+ ...anchorDropTarget,
+ isNewTrack: true,
+ }
+ : null,
+ );
+ startDrag({
+ ...pendingDragRef.current,
+ dragElementIds: moveGroup.members.map((member) => member.elementId),
+ dragTimeOffsets,
+ initialCurrentTime: initialSnappedTime,
+ initialCurrentMouseY: clientY,
+ });
+ onSnapPointChange?.(initialSnapPoint);
+ startedDragThisEvent = true;
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ } else {
+ return;
+ }
+ }
+
+ if (startedDragThisEvent) {
+ return;
+ }
+
+ if (dragState.elementId && dragState.trackId) {
+ const alreadySelected = isElementSelected({
+ trackId: dragState.trackId,
+ elementId: dragState.elementId,
+ });
+ if (!alreadySelected) {
+ selectElement({
+ trackId: dragState.trackId,
+ elementId: dragState.elementId,
+ });
+ }
+ }
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
+ const fps = activeProject.settings.fps;
+ const frameSnappedTime =
+ roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
+
+ const moveGroup = moveGroupRef.current;
+ const { snappedTime, snapPoint } = getDragSnapResult({
+ frameSnappedTime,
+ group: moveGroup,
+ });
+ setDragState((previousDragState) => ({
+ ...previousDragState,
+ currentTime: snappedTime,
+ currentMouseY: clientY,
+ }));
+ onSnapPointChange?.(snapPoint);
+
+ if (dragState.elementId && dragState.trackId) {
+ const verticalDragDirection = getVerticalDragDirection({
+ startMouseY: dragState.startMouseY,
+ currentMouseY: clientY,
+ });
+ const anchorDropTarget = getDragDropTarget({
+ clientX,
+ clientY,
+ elementId: dragState.elementId,
+ trackId: dragState.trackId,
+ tracks: sceneTracks,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ zoomLevel,
+ snappedTime,
+ verticalDragDirection,
+ });
+ const nextGroupMoveResult =
+ moveGroup && anchorDropTarget
+ ? resolveGroupDragMove({
+ group: moveGroup,
+ snappedTime,
+ dropTarget: anchorDropTarget,
+ })
+ : null;
+ groupMoveResultRef.current = nextGroupMoveResult;
+ setDragDropTarget(
+ anchorDropTarget &&
+ (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
+ ? {
+ ...anchorDropTarget,
+ isNewTrack: true,
+ }
+ : null,
+ );
+ }
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [
+ dragState.isDragging,
+ dragState.clickOffsetTime,
+ dragState.elementId,
+ dragState.startMouseY,
+ dragState.trackId,
+ zoomLevel,
+ isElementSelected,
+ selectElement,
+ editor.project,
+ timelineRef,
+ tracksScrollRef,
+ tracksContainerRef,
+ headerRef,
+ isPendingDrag,
+ startDrag,
+ getDragSnapResult,
+ resolveGroupDragMove,
+ sceneTracks,
+ onSnapPointChange,
+ ]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
+ if (!dragState.elementId || !dragState.trackId) return;
+
+ if (mouseDownLocationRef.current) {
+ const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
+ const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
+ if (
+ deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
+ deltaY <= TIMELINE_DRAG_THRESHOLD_PX
+ ) {
+ mouseDownLocationRef.current = null;
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+ }
+
+ const moveGroup = moveGroupRef.current;
+ if (!moveGroup) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ const groupMoveResult = groupMoveResultRef.current;
+ if (!groupMoveResult) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ const didMove = groupMoveResult.moves.some((move) => {
+ const currentMember = moveGroup.members.find(
+ (member) => member.elementId === move.elementId,
+ );
+ const originalStartTime =
+ dragState.startElementTime + (currentMember?.timeOffset ?? 0);
+ return (
+ currentMember?.trackId !== move.targetTrackId ||
+ originalStartTime !== move.newStartTime
+ );
+ });
+ if (!didMove && groupMoveResult.createTracks.length === 0) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ editor.timeline.moveElements({
+ moves: groupMoveResult.moves,
+ createTracks: groupMoveResult.createTracks,
+ });
+ endDrag();
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.elementId,
+ dragState.startElementTime,
+ dragState.trackId,
+ endDrag,
+ onSnapPointChange,
+ editor.timeline,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag, onSnapPointChange]);
+
+ const handleElementMouseDown = useCallback(
+ ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }) => {
+ const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
+
+ // right-click: don't stop propagation so ContextMenu can open
+ if (isRightClick) {
+ const alreadySelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ if (!alreadySelected) {
+ handleSelectionClick({
+ trackId: track.id,
+ elementId: element.id,
+ isMultiKey: false,
+ });
+ }
+ return;
+ }
+
+ event.stopPropagation();
+ mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
+
+ const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
+
+ if (isMultiSelect) {
+ handleSelectionClick({
+ trackId: track.id,
+ elementId: element.id,
+ isMultiKey: true,
+ });
+ }
+
+ const clickOffsetTime = getClickOffsetTime({
+ clientX: event.clientX,
+ elementRect: event.currentTarget.getBoundingClientRect(),
+ zoomLevel,
+ });
+ const elementRef = {
+ trackId: track.id,
+ elementId: element.id,
+ };
+ const pendingSelectedElements = isElementSelected(elementRef)
+ ? selectedElements
+ : [elementRef];
+ pendingDragRef.current = {
+ elementId: element.id,
+ trackId: track.id,
+ selectedElements: pendingSelectedElements,
+ startMouseX: event.clientX,
+ startMouseY: event.clientY,
+ startElementTime: element.startTime,
+ clickOffsetTime,
+ };
+ setIsPendingDrag(true);
+ },
+ [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
+ );
+
+ const handleElementClick = useCallback(
+ ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }) => {
+ event.stopPropagation();
+
+ if (mouseDownLocationRef.current) {
+ const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
+ const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
+ if (
+ deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
+ deltaY > TIMELINE_DRAG_THRESHOLD_PX
+ ) {
+ mouseDownLocationRef.current = null;
+ return;
+ }
+ }
+
+ // modifier keys already handled in mousedown
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+
+ const alreadySelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ if (!alreadySelected || selectedElements.length > 1) {
+ selectElement({ trackId: track.id, elementId: element.id });
+ return;
+ }
+
+ editor.selection.clearKeyframeSelection();
+ },
+ [editor.selection, isElementSelected, selectElement, selectedElements],
+ );
+
+ return {
+ dragState,
+ dragDropTarget,
+ handleElementMouseDown,
+ handleElementClick,
+ lastMouseXRef,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/element/use-element-selection.ts b/apps/web/src/timeline/hooks/element/use-element-selection.ts
new file mode 100644
index 00000000..19037e10
--- /dev/null
+++ b/apps/web/src/timeline/hooks/element/use-element-selection.ts
@@ -0,0 +1,137 @@
+import { useCallback } from "react";
+import { useEditor } from "@/editor/use-editor";
+import type { ElementRef } from "@/timeline/types";
+
+export function useElementSelection() {
+ const editor = useEditor();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+
+ const isElementSelected = useCallback(
+ ({ trackId, elementId }: ElementRef) =>
+ selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ ),
+ [selectedElements],
+ );
+
+ const selectElement = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ editor.selection.setSelectedElements({
+ elements: [{ trackId, elementId }],
+ });
+ },
+ [editor],
+ );
+
+ const addElementToSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ const alreadySelected = selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ );
+ if (alreadySelected) return;
+
+ editor.selection.setSelectedElements({
+ elements: [...selectedElements, { trackId, elementId }],
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ const removeElementFromSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ editor.selection.setSelectedElements({
+ elements: selectedElements.filter(
+ (element) =>
+ !(element.trackId === trackId && element.elementId === elementId),
+ ),
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ const toggleElementSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ const alreadySelected = selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ );
+
+ if (alreadySelected) {
+ removeElementFromSelection({ trackId, elementId });
+ } else {
+ addElementToSelection({ trackId, elementId });
+ }
+ },
+ [selectedElements, addElementToSelection, removeElementFromSelection],
+ );
+
+ const clearElementSelection = useCallback(() => {
+ editor.selection.clearSelection();
+ }, [editor]);
+
+ const setElementSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ editor.selection.setSelectedElements({ elements });
+ },
+ [editor],
+ );
+
+
+ /**
+ * Merges elements into the current selection, deduplicating by identity.
+ * Used for additive box-select where the pre-drag selection is preserved.
+ */
+ const mergeElementsIntoSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ const merged = [
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !elements.some(
+ (element) =>
+ element.trackId === selectedElement.trackId &&
+ element.elementId === selectedElement.elementId,
+ ),
+ ),
+ ...elements,
+ ];
+ editor.selection.setSelectedElements({ elements: merged });
+ },
+ [selectedElements, editor],
+ );
+
+
+ /**
+ * Handles click interaction on an element.
+ * - Regular click: select only this element
+ * - Multi-key click (Ctrl/Cmd): toggle this element in selection
+ */
+ const handleElementClick = useCallback(
+ ({
+ trackId,
+ elementId,
+ isMultiKey,
+ }: ElementRef & { isMultiKey: boolean }) => {
+ if (isMultiKey) {
+ toggleElementSelection({ trackId, elementId });
+ } else {
+ selectElement({ trackId, elementId });
+ }
+ },
+ [toggleElementSelection, selectElement],
+ );
+
+ return {
+ selectedElements,
+ isElementSelected,
+ selectElement,
+ setElementSelection,
+ mergeElementsIntoSelection,
+ addElementToSelection,
+ removeElementFromSelection,
+ toggleElementSelection,
+ clearElementSelection,
+ handleElementClick,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts
new file mode 100644
index 00000000..e471e2d4
--- /dev/null
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts
@@ -0,0 +1,217 @@
+import { useCallback, useRef, useMemo } from "react";
+import { useBoxSelect } from "@/selection/hooks/use-box-select";
+import {
+ useKeyframeSelection,
+ getSelectedKeyframeId,
+} from "./use-keyframe-selection";
+import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types";
+import type { ExpandedRow } from "@/timeline/components/expanded-layout";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import {
+ KEYFRAME_LANE_HEIGHT_PX,
+ KEYFRAME_DIAMOND_SIZE_PX,
+} from "@/timeline/components/layout";
+
+export function useKeyframeBoxSelect({
+ trackId,
+ elementId,
+ rows,
+ keyframes,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+}: {
+ trackId: string;
+ elementId: string;
+ rows: ExpandedRow[];
+ keyframes: ElementKeyframe[];
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+}) {
+ const {
+ selectedKeyframes,
+ keyframeSelectionAnchor,
+ setKeyframeSelection,
+ clearKeyframeSelection,
+ } = useKeyframeSelection();
+
+ const containerRef = useRef<HTMLDivElement>(null);
+ const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]);
+
+ const keyframeEntries = useMemo(() => {
+ const entries: Array<{
+ id: string;
+ ref: SelectedKeyframeRef;
+ rowIndex: number;
+ offsetPx: number;
+ }> = [];
+
+ for (const kf of keyframes) {
+ const rowIndex = rows.findIndex(
+ (r) => r.propertyPath === kf.propertyPath,
+ );
+ if (rowIndex === -1) continue;
+
+ const ref: SelectedKeyframeRef = {
+ trackId,
+ elementId,
+ propertyPath: kf.propertyPath,
+ keyframeId: kf.id,
+ };
+
+ const kfLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime + kf.time,
+ zoomLevel,
+ });
+
+ entries.push({
+ id: getSelectedKeyframeId({ keyframe: ref }),
+ ref,
+ rowIndex,
+ offsetPx: kfLeft - elementLeft,
+ });
+ }
+
+ return entries;
+ }, [
+ keyframes,
+ rows,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ ]);
+
+ const idToRefMap = useMemo(() => {
+ const map = new Map<string, SelectedKeyframeRef>();
+ for (const entry of keyframeEntries) {
+ map.set(entry.id, entry.ref);
+ }
+ return map;
+ }, [keyframeEntries]);
+
+ const selectedIds = useMemo(
+ () =>
+ selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ [selectedKeyframes],
+ );
+
+ const anchorId = useMemo(
+ () =>
+ keyframeSelectionAnchor
+ ? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor })
+ : null,
+ [keyframeSelectionAnchor],
+ );
+
+ const resolveIntersections = useCallback(
+ ({
+ startPos,
+ currentPos,
+ }: {
+ startPos: { x: number; y: number };
+ currentPos: { x: number; y: number };
+ }) => {
+ const container = containerRef.current;
+ if (!container) return [];
+
+ const containerRect = container.getBoundingClientRect();
+
+ const sx = startPos.x - containerRect.left;
+ const sy = startPos.y - containerRect.top;
+ const cx = currentPos.x - containerRect.left;
+ const cy = currentPos.y - containerRect.top;
+
+ const selLeft = Math.min(sx, cx);
+ const selTop = Math.min(sy, cy);
+ const selRight = Math.max(sx, cx);
+ const selBottom = Math.max(sy, cy);
+
+ const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2;
+
+ return keyframeEntries
+ .filter((entry) => {
+ const kfX = entry.offsetPx;
+ const kfY =
+ entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX +
+ KEYFRAME_LANE_HEIGHT_PX / 2;
+
+ return !(
+ kfX + halfHit < selLeft ||
+ kfX - halfHit > selRight ||
+ kfY + halfHit < selTop ||
+ kfY - halfHit > selBottom
+ );
+ })
+ .map((entry) => entry.id);
+ },
+ [keyframeEntries],
+ );
+
+ const onSelectionChange = useCallback(
+ ({
+ intersectedIds,
+ isAdditive,
+ }: {
+ intersectedIds: string[];
+ initialSelectedIds: string[];
+ initialAnchorId: string | null;
+ isAdditive: boolean;
+ }) => {
+ const intersectedRefs = intersectedIds
+ .map((id) => idToRefMap.get(id))
+ .filter((ref): ref is SelectedKeyframeRef => ref != null);
+
+ if (isAdditive) {
+ setKeyframeSelection({
+ keyframes: [...initialKeyframesRef.current, ...intersectedRefs],
+ });
+ } else {
+ setKeyframeSelection({ keyframes: intersectedRefs });
+ }
+ },
+ [idToRefMap, setKeyframeSelection],
+ );
+
+ const {
+ selectionBox,
+ handleMouseDown: boxSelectMouseDown,
+ isSelecting,
+ shouldIgnoreClick,
+ } = useBoxSelect<string>({
+ containerRef,
+ resolveIntersections,
+ selectedIds,
+ anchorId,
+ onSelectionChange,
+ });
+
+ const handleExpandedAreaMouseDown = useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ initialKeyframesRef.current = selectedKeyframes;
+ boxSelectMouseDown(event);
+ },
+ [boxSelectMouseDown, selectedKeyframes],
+ );
+
+ const handleExpandedAreaClick = useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (shouldIgnoreClick()) return;
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ clearKeyframeSelection();
+ },
+ [shouldIgnoreClick, clearKeyframeSelection],
+ );
+
+ return {
+ containerRef,
+ selectionBox,
+ isBoxSelecting: isSelecting,
+ handleExpandedAreaMouseDown,
+ handleExpandedAreaClick,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
new file mode 100644
index 00000000..92b6f0d8
--- /dev/null
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
@@ -0,0 +1,328 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type MouseEvent as ReactMouseEvent,
+} from "react";
+import { useEditor } from "@/editor/use-editor";
+import { getKeyframeById } from "@/animation";
+import { useKeyframeSelection } from "./use-keyframe-selection";
+import { roundToFrame, snappedSeekTime } from "opencut-wasm";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
+import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
+import { BatchCommand } from "@/commands";
+import type { SelectedKeyframeRef } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import type { Command } from "@/commands/base-command";
+import { registerCanceller } from "@/editor/cancel-interaction";
+export interface KeyframeDragState {
+ isDragging: boolean;
+ draggingKeyframeIds: Set<string>;
+ deltaTime: number;
+}
+
+const initialDragState: KeyframeDragState = {
+ isDragging: false,
+ draggingKeyframeIds: new Set(),
+ deltaTime: 0,
+};
+
+interface PendingKeyframeDrag {
+ keyframeRefs: SelectedKeyframeRef[];
+ startMouseX: number;
+}
+
+export function useKeyframeDrag({
+ zoomLevel,
+ element,
+ displayedStartTime,
+}: {
+ zoomLevel: number;
+ element: TimelineElement;
+ displayedStartTime: number;
+}) {
+ const editor = useEditor();
+ const {
+ selectedKeyframes,
+ isKeyframeSelected,
+ setKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ } = useKeyframeSelection();
+
+ const [dragState, setDragState] =
+ useState<KeyframeDragState>(initialDragState);
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+
+ const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
+ const mouseDownXRef = useRef<number | null>(null);
+
+ const activeProject = editor.project.getActive();
+ const fps = activeProject.settings.fps;
+
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+
+ const endDrag = useCallback(() => {
+ setDragState(initialDragState);
+ }, []);
+
+ const cancelDrag = useCallback(() => {
+ pendingDragRef.current = null;
+ mouseDownXRef.current = null;
+ setIsPendingDrag(false);
+ endDrag();
+ }, [endDrag]);
+
+ const commitDrag = useCallback(
+ ({
+ keyframeRefs,
+ deltaTime,
+ }: {
+ keyframeRefs: SelectedKeyframeRef[];
+ deltaTime: number;
+ }) => {
+ const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
+ const keyframe = getKeyframeById({
+ animations: element.animations,
+ propertyPath: keyframeRef.propertyPath,
+ keyframeId: keyframeRef.keyframeId,
+ });
+ if (!keyframe) return [];
+ const nextTime = Math.max(
+ 0,
+ Math.min(element.duration, keyframe.time + deltaTime),
+ );
+ return [
+ new RetimeKeyframeCommand({
+ trackId: keyframeRef.trackId,
+ elementId: keyframeRef.elementId,
+ propertyPath: keyframeRef.propertyPath,
+ keyframeId: keyframeRef.keyframeId,
+ nextTime,
+ }),
+ ];
+ });
+
+ if (commands.length === 1) {
+ editor.command.execute({ command: commands[0] });
+ } else if (commands.length > 1) {
+ editor.command.execute({ command: new BatchCommand(commands) });
+ }
+ },
+ [editor.command, element],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ return registerCanceller({ fn: cancelDrag });
+ }, [dragState.isDragging, isPendingDrag, cancelDrag]);
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = ({ clientX }: MouseEvent) => {
+ if (isPendingDrag && pendingDragRef.current) {
+ const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
+ if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
+
+ const pending = pendingDragRef.current;
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ setDragState({
+ isDragging: true,
+ draggingKeyframeIds: new Set(
+ pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
+ ),
+ deltaTime: 0,
+ });
+ return;
+ }
+
+ if (!dragState.isDragging) return;
+
+ const startX = mouseDownXRef.current ?? clientX;
+ const rawDelta = Math.round(
+ ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
+ );
+ const snappedDelta =
+ roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
+
+ setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = () => {
+ const draggingRefs = selectedKeyframes.filter(
+ (keyframe) =>
+ keyframe.elementId === element.id &&
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
+ );
+
+ if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
+ commitDrag({
+ keyframeRefs: draggingRefs,
+ deltaTime: dragState.deltaTime,
+ });
+ }
+
+ endDrag();
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.draggingKeyframeIds,
+ dragState.deltaTime,
+ selectedKeyframes,
+ element.id,
+ commitDrag,
+ endDrag,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag]);
+
+ const handleKeyframeMouseDown = useCallback(
+ ({
+ event,
+ keyframes,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ mouseDownXRef.current = event.clientX;
+
+ const anySelected = keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+
+ const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
+ if (!anySelected && !isModifierKey) {
+ setKeyframeSelection({ keyframes });
+ }
+
+ const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
+
+ pendingDragRef.current = {
+ keyframeRefs: keyframeRefsToTrack,
+ startMouseX: event.clientX,
+ };
+ setIsPendingDrag(true);
+ },
+ [isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
+ );
+
+ const handleKeyframeClick = useCallback(
+ ({
+ event,
+ keyframes,
+ orderedKeyframes,
+ indicatorTime,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => {
+ event.stopPropagation();
+
+ const wasDrag =
+ mouseDownXRef.current !== null &&
+ Math.abs(event.clientX - mouseDownXRef.current) >
+ TIMELINE_DRAG_THRESHOLD_PX;
+ mouseDownXRef.current = null;
+
+ if (wasDrag) return;
+
+ const duration = editor.timeline.getTotalDuration();
+ const seekTime =
+ snappedSeekTime({
+ time: displayedStartTime + indicatorTime,
+ duration,
+ rate: fps,
+ }) ?? displayedStartTime + indicatorTime;
+ editor.playback.seek({ time: seekTime });
+
+ if (event.shiftKey) {
+ selectKeyframeRange({
+ orderedKeyframes,
+ targetKeyframes: keyframes,
+ isAdditive: event.metaKey || event.ctrlKey,
+ });
+ return;
+ }
+
+ toggleKeyframeSelection({
+ keyframes,
+ isMultiKey: event.metaKey || event.ctrlKey,
+ });
+ },
+ [
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ editor,
+ displayedStartTime,
+ fps,
+ ],
+ );
+
+ const getVisualOffsetPx = useCallback(
+ ({
+ indicatorTime,
+ indicatorOffsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ }: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }): number => {
+ if (!isBeingDragged) return indicatorOffsetPx;
+ const clampedTime = Math.max(
+ 0,
+ Math.min(element.duration, indicatorTime + dragState.deltaTime),
+ );
+ return (
+ timelineTimeToSnappedPixels({
+ time: displayedStartTime + clampedTime,
+ zoomLevel,
+ }) - elementLeft
+ );
+ },
+ [dragState.deltaTime, element.duration, zoomLevel],
+ );
+
+ return {
+ keyframeDragState: dragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts
new file mode 100644
index 00000000..c5d3ffc9
--- /dev/null
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts
@@ -0,0 +1,229 @@
+import { useCallback, useSyncExternalStore } from "react";
+import { useEditor } from "@/editor/use-editor";
+import type { SelectedKeyframeRef } from "@/animation/types";
+
+export function getSelectedKeyframeId({
+ keyframe,
+}: {
+ keyframe: SelectedKeyframeRef;
+}): string {
+ return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`;
+}
+
+function mergeUniqueKeyframes({
+ keyframes,
+}: {
+ keyframes: SelectedKeyframeRef[];
+}): SelectedKeyframeRef[] {
+ const keyframesById = new Map<string, SelectedKeyframeRef>();
+ for (const keyframe of keyframes) {
+ keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe);
+ }
+ return [...keyframesById.values()];
+}
+
+export function useKeyframeSelection() {
+ const editor = useEditor();
+ const selectedKeyframes = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedKeyframes(),
+ );
+ const keyframeSelectionAnchor = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getKeyframeSelectionAnchor(),
+ );
+
+ const isKeyframeSelected = useCallback(
+ ({ keyframe }: { keyframe: SelectedKeyframeRef }) => {
+ const keyframeId = getSelectedKeyframeId({ keyframe });
+ return selectedKeyframes.some(
+ (selectedKeyframe) =>
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId,
+ );
+ },
+ [selectedKeyframes],
+ );
+
+ const setKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const uniqueKeyframes = mergeUniqueKeyframes({ keyframes });
+ editor.selection.setSelectedKeyframes({
+ keyframes: uniqueKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null,
+ });
+ },
+ [editor],
+ );
+
+ const addKeyframesToSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const mergedKeyframes = mergeUniqueKeyframes({
+ keyframes: [...selectedKeyframes, ...keyframes],
+ });
+ editor.selection.setSelectedKeyframes({
+ keyframes: mergedKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const removeKeyframesFromSelection = useCallback(
+ ({
+ keyframes,
+ anchorKeyframe,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ anchorKeyframe?: SelectedKeyframeRef;
+ }) => {
+ const keyframeIdsToRemove = new Set(
+ keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const nextKeyframes = selectedKeyframes.filter(
+ (selectedKeyframe) =>
+ !keyframeIdsToRemove.has(
+ getSelectedKeyframeId({ keyframe: selectedKeyframe }),
+ ),
+ );
+ editor.selection.setSelectedKeyframes({
+ keyframes: nextKeyframes,
+ anchorKeyframe:
+ anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null,
+ });
+ },
+ [selectedKeyframes, editor],
+ );
+
+ const clearKeyframeSelection = useCallback(() => {
+ editor.selection.clearKeyframeSelection();
+ }, [editor]);
+
+ const toggleKeyframeSelection = useCallback(
+ ({
+ keyframes,
+ isMultiKey,
+ }: {
+ keyframes: SelectedKeyframeRef[];
+ isMultiKey: boolean;
+ }) => {
+ const anchorKeyframe = keyframes[0];
+ const areAllKeyframesSelected = keyframes.every((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ if (!isMultiKey) {
+ setKeyframeSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ if (areAllKeyframesSelected) {
+ removeKeyframesFromSelection({ keyframes, anchorKeyframe });
+ return;
+ }
+
+ addKeyframesToSelection({ keyframes, anchorKeyframe });
+ },
+ [
+ setKeyframeSelection,
+ isKeyframeSelected,
+ removeKeyframesFromSelection,
+ addKeyframesToSelection,
+ ],
+ );
+
+ const selectKeyframeRange = useCallback(
+ ({
+ orderedKeyframes,
+ targetKeyframes,
+ isAdditive,
+ }: {
+ orderedKeyframes: SelectedKeyframeRef[];
+ targetKeyframes: SelectedKeyframeRef[];
+ isAdditive: boolean;
+ }) => {
+ if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) {
+ return;
+ }
+
+ const anchorKeyframe =
+ keyframeSelectionAnchor ??
+ selectedKeyframes[selectedKeyframes.length - 1] ??
+ targetKeyframes[0];
+ if (!anchorKeyframe) {
+ return;
+ }
+
+ const targetKeyframeIds = new Set(
+ targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })),
+ );
+ const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe });
+ const anchorIndex = orderedKeyframes.findIndex(
+ (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId,
+ );
+ if (anchorIndex === -1) {
+ if (isAdditive) {
+ addKeyframesToSelection({
+ keyframes: targetKeyframes,
+ anchorKeyframe,
+ });
+ return;
+ }
+ setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe });
+ return;
+ }
+
+ const targetIndexes = orderedKeyframes
+ .map((keyframe, index) => ({
+ keyframeId: getSelectedKeyframeId({ keyframe }),
+ index,
+ }))
+ .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId))
+ .map(({ index }) => index);
+ if (targetIndexes.length === 0) {
+ return;
+ }
+
+ const rangeStart = Math.min(anchorIndex, ...targetIndexes);
+ const rangeEnd = Math.max(anchorIndex, ...targetIndexes);
+ const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1);
+
+ if (isAdditive) {
+ addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ return;
+ }
+
+ setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe });
+ },
+ [
+ keyframeSelectionAnchor,
+ selectedKeyframes,
+ addKeyframesToSelection,
+ setKeyframeSelection,
+ ],
+ );
+
+ return {
+ selectedKeyframes,
+ keyframeSelectionAnchor,
+ isKeyframeSelected,
+ setKeyframeSelection,
+ addKeyframesToSelection,
+ removeKeyframesFromSelection,
+ clearKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts
new file mode 100644
index 00000000..509031bb
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts
@@ -0,0 +1,97 @@
+import { useEffect, useRef } from "react";
+
+interface UseEdgeAutoScrollParams {
+ isActive: boolean;
+ getMouseClientX: () => number;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ contentWidth: number;
+ edgeThreshold?: number;
+ maxScrollSpeed?: number;
+}
+
+export function useEdgeAutoScroll({
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold = 100,
+ maxScrollSpeed = 15,
+}: UseEdgeAutoScrollParams): void {
+ const rafRef = useRef<number | null>(null);
+
+ useEffect(() => {
+ if (!isActive) {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ return;
+ }
+
+ const step = () => {
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) {
+ rafRef.current = requestAnimationFrame(step);
+ return;
+ }
+
+ const viewportRect = rulerViewport.getBoundingClientRect();
+ const mouseX = getMouseClientX();
+ const mouseXRelative = mouseX - viewportRect.left;
+
+ const viewportWidth = rulerViewport.clientWidth;
+ const intrinsicContentWidth = rulerViewport.scrollWidth;
+ const effectiveContentWidth = Math.max(
+ contentWidth,
+ intrinsicContentWidth,
+ );
+ const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
+
+ let scrollSpeed = 0;
+
+ if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
+ const edgeDistance = Math.max(0, mouseXRelative);
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = -maxScrollSpeed * intensity;
+ } else if (
+ mouseXRelative > viewportWidth - edgeThreshold &&
+ rulerViewport.scrollLeft < scrollMax
+ ) {
+ const edgeDistance = Math.max(0, viewportWidth - mouseXRelative);
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = maxScrollSpeed * intensity;
+ }
+
+ if (scrollSpeed !== 0) {
+ const newScrollLeft = Math.max(
+ 0,
+ Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed),
+ );
+ rulerViewport.scrollLeft = newScrollLeft;
+ tracksViewport.scrollLeft = newScrollLeft;
+ }
+
+ rafRef.current = requestAnimationFrame(step);
+ };
+
+ rafRef.current = requestAnimationFrame(step);
+
+ return () => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ };
+ }, [
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold,
+ maxScrollSpeed,
+ ]);
+}
diff --git a/apps/web/src/timeline/hooks/use-element-preview.ts b/apps/web/src/timeline/hooks/use-element-preview.ts
new file mode 100644
index 00000000..67213094
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-element-preview.ts
@@ -0,0 +1,39 @@
+import { useEditor } from "@/editor/use-editor";
+import { findTrackInSceneTracks, type TimelineElement } from "@/timeline";
+
+/**
+ * Subscribes to render tracks and returns the live (preview-aware) version of
+ * an element alongside helpers for previewing and committing updates.
+ *
+ * Use this wherever property fields need to reflect in-progress preview state
+ * (e.g. a slider being dragged) rather than the last committed value.
+ */
+export function useElementPreview<T extends TimelineElement>({
+ trackId,
+ elementId,
+ fallback,
+}: {
+ trackId: string;
+ elementId: string;
+ fallback: T;
+}) {
+ const editor = useEditor();
+ useEditor((e) => e.timeline.getPreviewTracks());
+
+ const previewTracks = editor.timeline.getPreviewTracks();
+ const renderElement =
+ (findTrackInSceneTracks({
+ tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
+ trackId,
+ })?.elements.find((element) => element.id === elementId) as T | undefined) ??
+ fallback;
+
+ const previewUpdates = (updates: Partial<TimelineElement>) =>
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates }],
+ });
+
+ const commit = () => editor.timeline.commitPreview();
+
+ return { renderElement, previewUpdates, commit };
+}
diff --git a/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts
new file mode 100644
index 00000000..0b1cf92a
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts
@@ -0,0 +1,42 @@
+import { type RefObject, useLayoutEffect, useRef } from "react";
+
+interface UseInitialScrollBottomProps {
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
+ onAfterScroll?: () => void;
+ /** Defers the scroll until there is at least one track to measure against. */
+ isReady: boolean;
+}
+
+/**
+ * Scrolls the timeline tracks viewport to the bottom once on mount.
+ * useLayoutEffect runs synchronously after React commits the DOM but before
+ * the browser paints, so the initial scroll position is never visible.
+ */
+export function useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll,
+ isReady,
+}: UseInitialScrollBottomProps): void {
+ const hasScrolledRef = useRef(false);
+
+ useLayoutEffect(() => {
+ if (!isReady || hasScrolledRef.current) return;
+
+ const viewport = tracksScrollRef.current;
+ if (!viewport) return;
+
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
+ if (maxScrollTop <= 0) return;
+
+ viewport.scrollTop = maxScrollTop;
+
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = maxScrollTop;
+ }
+
+ onAfterScroll?.();
+ hasScrolledRef.current = true;
+ }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
+}
diff --git a/apps/web/src/timeline/hooks/use-scroll-position.ts b/apps/web/src/timeline/hooks/use-scroll-position.ts
new file mode 100644
index 00000000..ad2c6374
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-scroll-position.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState, useRef } from "react";
+
+interface UseScrollPositionReturn {
+ scrollLeft: number;
+ viewportWidth: number;
+}
+
+export function useScrollPosition({
+ scrollRef,
+}: {
+ scrollRef: React.RefObject<HTMLElement | null>;
+}): UseScrollPositionReturn {
+ const [scrollLeft, setScrollLeft] = useState(0);
+ const [viewportWidth, setViewportWidth] = useState(0);
+ const rafIdRef = useRef<number | null>(null);
+
+ useEffect(() => {
+ const scrollElement = scrollRef.current;
+ if (!scrollElement) return;
+
+ const updatePosition = () => {
+ if (rafIdRef.current !== null) {
+ cancelAnimationFrame(rafIdRef.current);
+ }
+
+ rafIdRef.current = requestAnimationFrame(() => {
+ setScrollLeft(scrollElement.scrollLeft);
+ setViewportWidth(scrollElement.clientWidth);
+ rafIdRef.current = null;
+ });
+ };
+
+ const resizeObserver = new ResizeObserver(() => {
+ updatePosition();
+ });
+
+ updatePosition();
+
+ scrollElement.addEventListener("scroll", updatePosition, { passive: true });
+ resizeObserver.observe(scrollElement);
+
+ return () => {
+ scrollElement.removeEventListener("scroll", updatePosition);
+ resizeObserver.disconnect();
+ if (rafIdRef.current !== null) {
+ cancelAnimationFrame(rafIdRef.current);
+ }
+ };
+ }, [scrollRef]);
+
+ return { scrollLeft, viewportWidth };
+}
diff --git a/apps/web/src/timeline/hooks/use-scroll-sync.ts b/apps/web/src/timeline/hooks/use-scroll-sync.ts
new file mode 100644
index 00000000..e147c106
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-scroll-sync.ts
@@ -0,0 +1,173 @@
+import { useEffect, useRef } from "react";
+
+interface UseScrollSyncProps {
+ rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
+ bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
+}
+
+export function useScrollSync({
+ tracksScrollRef,
+ rulerScrollRef,
+ trackLabelsScrollRef,
+ bookmarksScrollRef,
+}: UseScrollSyncProps) {
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+ const lastVerticalSync = useRef(0);
+ const lastBookmarksSync = useRef(0);
+
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef?.current ?? null;
+ const tracksViewport = tracksScrollRef.current;
+ const trackLabelsViewport = trackLabelsScrollRef?.current;
+ const bookmarksViewport = bookmarksScrollRef?.current;
+ let handleBookmarksScroll: (() => void) | null = null;
+
+ if (!tracksViewport) return;
+
+ const syncScrollLeft = ({
+ target,
+ scrollLeft,
+ }: {
+ target: HTMLDivElement | null | undefined;
+ scrollLeft: number;
+ }) => {
+ if (target) {
+ target.scrollLeft = scrollLeft;
+ }
+ };
+
+ const handleRulerScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
+ lastRulerSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0;
+ syncScrollLeft({
+ target: bookmarksViewport,
+ scrollLeft: rulerViewport?.scrollLeft ?? 0,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ const handleTracksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
+ lastTracksSync.current = now;
+ isUpdatingRef.current = true;
+ syncScrollLeft({
+ target: rulerViewport,
+ scrollLeft: tracksViewport.scrollLeft,
+ });
+ syncScrollLeft({
+ target: bookmarksViewport,
+ scrollLeft: tracksViewport.scrollLeft,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.addEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
+ }
+
+ if (bookmarksViewport) {
+ handleBookmarksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
+ return;
+ lastBookmarksSync.current = now;
+ isUpdatingRef.current = true;
+ const nextScrollLeft = bookmarksViewport.scrollLeft;
+ tracksViewport.scrollLeft = nextScrollLeft;
+ syncScrollLeft({
+ target: rulerViewport,
+ scrollLeft: nextScrollLeft,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ if (bookmarksViewport !== tracksViewport) {
+ bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
+ }
+ }
+
+ if (trackLabelsViewport) {
+ const handleTrackLabelsScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ const handleTracksVerticalScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
+
+ return () => {
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ }
+ if (
+ bookmarksViewport &&
+ bookmarksViewport !== tracksViewport &&
+ handleBookmarksScroll
+ ) {
+ bookmarksViewport.removeEventListener(
+ "scroll",
+ handleBookmarksScroll,
+ );
+ }
+ trackLabelsViewport.removeEventListener(
+ "scroll",
+ handleTrackLabelsScroll,
+ );
+ tracksViewport.removeEventListener(
+ "scroll",
+ handleTracksVerticalScroll,
+ );
+ };
+ }
+
+ return () => {
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ }
+ if (
+ bookmarksViewport &&
+ bookmarksViewport !== tracksViewport &&
+ handleBookmarksScroll
+ ) {
+ bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
+ }
+ };
+ }, [
+ rulerScrollRef,
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ bookmarksScrollRef,
+ ]);
+}
diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts
new file mode 100644
index 00000000..2081b939
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout";
+interface UseSnapIndicatorPositionParams {
+ snapPoint: { time: number } | null;
+ zoomLevel: number;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+}
+
+interface SnapIndicatorPosition {
+ leftPosition: number;
+ topPosition: number;
+ height: number;
+}
+
+export function useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ timelineRef,
+ tracksScrollRef,
+}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
+ const [scrollLeft, setScrollLeft] = useState(0);
+
+ useEffect(() => {
+ const tracksViewport = tracksScrollRef.current;
+
+ if (!tracksViewport) return;
+
+ const handleScroll = () => {
+ setScrollLeft(tracksViewport.scrollLeft);
+ };
+
+ setScrollLeft(tracksViewport.scrollLeft);
+
+ tracksViewport.addEventListener("scroll", handleScroll);
+ return () => tracksViewport.removeEventListener("scroll", handleScroll);
+ }, [tracksScrollRef]);
+
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ const timelinePosition = timelineTimeToSnappedPixels({
+ time: snapPoint?.time ?? 0,
+ zoomLevel,
+ });
+ const leftPosition =
+ TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
+
+ return {
+ leftPosition,
+ topPosition: 0,
+ height: totalHeight,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
new file mode 100644
index 00000000..775b7337
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
@@ -0,0 +1,648 @@
+import { useState, useCallback, type RefObject } from "react";
+import { useEditor } from "@/editor/use-editor";
+import { processMediaAssets } from "@/media/processing";
+import { toast } from "sonner";
+import { showMediaUploadToast } from "@/media/upload-toast";
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { roundToFrame } from "opencut-wasm";
+import {
+ buildTextElement,
+ buildGraphicElement,
+ buildStickerElement,
+ buildElementFromMedia,
+ buildEffectElement,
+} from "@/timeline/element-utils";
+import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
+import { BatchCommand } from "@/commands";
+import { computeDropTarget } from "@/timeline/components/drop-target";
+import { getDragData, hasDragData } from "@/timeline/drag-data";
+import type { TrackType, DropTarget, ElementType } from "@/timeline";
+import type {
+ MediaDragData,
+ GraphicDragData,
+ StickerDragData,
+ EffectDragData,
+} from "@/timeline/drag";
+
+interface UseTimelineDragDropProps {
+ containerRef: RefObject<HTMLDivElement | null>;
+ headerRef?: RefObject<HTMLElement | null>;
+ tracksScrollRef?: RefObject<HTMLDivElement | null>;
+ zoomLevel: number;
+}
+
+export function useTimelineDragDrop({
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ zoomLevel,
+}: UseTimelineDragDropProps) {
+ const editor = useEditor();
+ const [isDragOver, setIsDragOver] = useState(false);
+ const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
+ const [dragElementType, setElementType] = useState<ElementType | null>(null);
+
+ const getSnappedTime = useCallback(
+ ({ time }: { time: number }) => {
+ const projectFps = editor.project.getActive().settings.fps;
+ return roundToFrame({ time, rate: projectFps }) ?? time;
+ },
+ [editor],
+ );
+
+ const getElementType = useCallback(
+ ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
+ const dragData = getDragData({ dataTransfer });
+ if (!dragData) return null;
+
+ if (dragData.type === "text") return "text";
+ if (dragData.type === "graphic") return "graphic";
+ if (dragData.type === "sticker") return "sticker";
+ if (dragData.type === "effect") return "effect";
+ if (dragData.type === "media") {
+ return dragData.mediaType;
+ }
+ return null;
+ },
+ [],
+ );
+
+ const getElementDuration = useCallback(
+ ({
+ elementType,
+ mediaId,
+ }: {
+ elementType: ElementType;
+ mediaId?: string;
+ }): number => {
+ if (
+ elementType === "text" ||
+ elementType === "graphic" ||
+ elementType === "sticker" ||
+ elementType === "effect"
+ ) {
+ return DEFAULT_NEW_ELEMENT_DURATION;
+ }
+ if (mediaId) {
+ const mediaAssets = editor.media.getAssets();
+ const media = mediaAssets.find((m) => m.id === mediaId);
+ return media?.duration != null
+ ? Math.round(media.duration * TICKS_PER_SECOND)
+ : DEFAULT_NEW_ELEMENT_DURATION;
+ }
+ return DEFAULT_NEW_ELEMENT_DURATION;
+ },
+ [editor],
+ );
+
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
+ const hasFiles = e.dataTransfer.types.includes("Files");
+ if (!hasAsset && !hasFiles) return;
+ setIsDragOver(true);
+ }, []);
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const hasFiles = e.dataTransfer.types.includes("Files");
+ const isExternal =
+ hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
+
+ const elementType = getElementType({ dataTransfer: e.dataTransfer });
+
+ if (!elementType && hasFiles && isExternal) {
+ setDropTarget(null);
+ setElementType(null);
+ return;
+ }
+
+ if (!elementType) return;
+
+ setElementType(elementType);
+
+ const dragData = getDragData({ dataTransfer: e.dataTransfer });
+ const duration = getElementDuration({
+ elementType,
+ mediaId: dragData?.type === "media" ? dragData.id : undefined,
+ });
+
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
+
+ const targetElementTypes =
+ dragData?.type === "effect"
+ ? (dragData as EffectDragData).targetElementTypes
+ : dragData?.type === "media"
+ ? (dragData as MediaDragData).targetElementTypes
+ : undefined;
+
+ const sceneTracks = editor.scenes.getActiveScene().tracks;
+ const currentTime = editor.playback.getCurrentTime();
+ const target = computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks: sceneTracks,
+ playheadTime: currentTime,
+ isExternalDrop: isExternal,
+ elementDuration: duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel,
+ targetElementTypes,
+ });
+
+ target.xPosition = getSnappedTime({ time: target.xPosition });
+
+ setDropTarget(target);
+ e.dataTransfer.dropEffect = "copy";
+ },
+ [
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ zoomLevel,
+ getElementType,
+ getElementDuration,
+ getSnappedTime,
+ editor,
+ ],
+ );
+
+ const handleDragLeave = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (rect) {
+ const { clientX, clientY } = e;
+ if (
+ clientX < rect.left ||
+ clientX > rect.right ||
+ clientY < rect.top ||
+ clientY > rect.bottom
+ ) {
+ setIsDragOver(false);
+ setDropTarget(null);
+ setElementType(null);
+ }
+ }
+ },
+ [containerRef],
+ );
+
+ const executeTextDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: { name?: string; content?: string };
+ }) => {
+ const element = buildTextElement({
+ raw: {
+ name: dragData.name ?? "",
+ content: dragData.content ?? "",
+ },
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = [
+ ...editor.scenes.getActiveScene().tracks.overlay,
+ editor.scenes.getActiveScene().tracks.main,
+ ...editor.scenes.getActiveScene().tracks.audio,
+ ];
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeStickerDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: StickerDragData;
+ }) => {
+ const element = buildStickerElement({
+ stickerId: dragData.stickerId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = [
+ ...editor.scenes.getActiveScene().tracks.overlay,
+ editor.scenes.getActiveScene().tracks.main,
+ ...editor.scenes.getActiveScene().tracks.audio,
+ ];
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeGraphicDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: GraphicDragData;
+ }) => {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = [
+ ...editor.scenes.getActiveScene().tracks.overlay,
+ editor.scenes.getActiveScene().tracks.main,
+ ...editor.scenes.getActiveScene().tracks.audio,
+ ];
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeMediaDrop = useCallback(
+ ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
+ if (target.targetElement) {
+ toast.info("Replace media source is coming soon!");
+ return;
+ }
+
+ const mediaAssets = editor.media.getAssets();
+ const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+
+ const duration =
+ mediaAsset.duration != null
+ ? Math.round(mediaAsset.duration * TICKS_PER_SECOND)
+ : DEFAULT_NEW_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: mediaAsset.id,
+ mediaType: mediaAsset.type,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ });
+
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ }
+
+ const tracks = [
+ ...editor.scenes.getActiveScene().tracks.overlay,
+ editor.scenes.getActiveScene().tracks.main,
+ ...editor.scenes.getActiveScene().tracks.audio,
+ ];
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeEffectDrop = useCallback(
+ ({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: EffectDragData;
+ }) => {
+ if (target.targetElement) {
+ editor.timeline.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const tracks = [
+ ...editor.scenes.getActiveScene().tracks.overlay,
+ editor.scenes.getActiveScene().tracks.main,
+ ...editor.scenes.getActiveScene().tracks.audio,
+ ];
+ const effectTrack = tracks.find((t) => t.type === "effect");
+ let trackId: string;
+
+ if (effectTrack) {
+ trackId = effectTrack.id;
+ } else if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
+ const insertCmd = new InsertElementCommand({
+ element: buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ }),
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ });
+ editor.command.execute({
+ command: new BatchCommand([addTrackCmd, insertCmd]),
+ });
+ return;
+ } else {
+ const track = tracks[target.trackIndex];
+ if (!track || track.type !== "effect") return;
+ trackId = track.id;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
+ },
+ [editor],
+ );
+
+ const executeFileDrop = useCallback(
+ async ({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }) => {
+ const activeProject = editor.project.getActiveOrNull();
+ if (!activeProject) return;
+
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
+
+ for (const asset of processedAssets) {
+ const createdAsset = await editor.media.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
+
+ const duration =
+ createdAsset.duration != null
+ ? Math.round(createdAsset.duration * TICKS_PER_SECOND)
+ : DEFAULT_NEW_ELEMENT_DURATION;
+ const sceneTracks = editor.scenes.getActiveScene().tracks;
+ const currentTime = editor.playback.getCurrentTime();
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ sceneTracks.overlay.length === 0 &&
+ sceneTracks.audio.length === 0 &&
+ sceneTracks.main.elements.length === 0
+ ? sceneTracks.main.id
+ : null;
+ const dropTarget = reuseMainTrackId
+ ? null
+ : computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: sceneTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel,
+ });
+
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
+
+ const startTime = dropTarget?.xPosition ?? currentTime;
+ const element = buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime,
+ });
+
+ if (reuseMainTrackId) {
+ editor.command.execute({
+ command: new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: reuseMainTrackId },
+ }),
+ });
+ } else {
+ if (!dropTarget) continue;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ editor.command.execute({
+ command: new BatchCommand([
+ addTrackCmd,
+ new InsertElementCommand({
+ element,
+ placement: {
+ mode: "explicit",
+ trackId: addTrackCmd.getTrackId(),
+ },
+ }),
+ ]),
+ });
+ } else {
+ const trackId = [
+ ...sceneTracks.overlay,
+ sceneTracks.main,
+ ...sceneTracks.audio,
+ ][dropTarget.trackIndex]?.id;
+ if (!trackId) continue;
+ editor.command.execute({
+ command: new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ }),
+ });
+ }
+ }
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
+ },
+ [editor, zoomLevel],
+ );
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault();
+
+ const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
+ const hasFiles = e.dataTransfer.files?.length > 0;
+
+ if (!hasAsset && !hasFiles) return;
+
+ const currentTarget = dropTarget;
+ setIsDragOver(false);
+ setDropTarget(null);
+ setElementType(null);
+
+ try {
+ if (hasAsset) {
+ if (!currentTarget) return;
+ const dragData = getDragData({ dataTransfer: e.dataTransfer });
+ if (!dragData) return;
+
+ if (dragData.type === "text") {
+ executeTextDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "graphic") {
+ executeGraphicDrop({
+ target: currentTarget,
+ dragData: dragData as GraphicDragData,
+ });
+ } else if (dragData.type === "sticker") {
+ executeStickerDrop({ target: currentTarget, dragData });
+ } else if (dragData.type === "effect") {
+ executeEffectDrop({
+ target: currentTarget,
+ dragData: dragData as EffectDragData,
+ });
+ } else {
+ executeMediaDrop({ target: currentTarget, dragData });
+ }
+ } else if (hasFiles) {
+ const scrollContainer = tracksScrollRef?.current;
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ containerRef.current?.getBoundingClientRect();
+ if (!referenceRect) return;
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const mouseX = e.clientX - referenceRect.left + scrollLeft;
+ const headerHeight =
+ headerRef?.current?.getBoundingClientRect().height ?? 0;
+ const mouseY =
+ e.clientY - referenceRect.top + scrollTop - headerHeight;
+ await executeFileDrop({
+ files: Array.from(e.dataTransfer.files),
+ mouseX,
+ mouseY,
+ });
+ }
+ } catch (err) {
+ console.error("Failed to process drop:", err);
+ }
+ },
+ [
+ dropTarget,
+ executeTextDrop,
+ executeGraphicDrop,
+ executeStickerDrop,
+ executeMediaDrop,
+ executeEffectDrop,
+ executeFileDrop,
+ containerRef,
+ headerRef,
+ tracksScrollRef,
+ ],
+ );
+
+ return {
+ isDragOver,
+ dropTarget,
+ dragElementType,
+ dragProps: {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ },
+ };
+}
diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
new file mode 100644
index 00000000..7d9cf447
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
@@ -0,0 +1,301 @@
+import { snappedSeekTime } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { useEffect, useCallback, useRef } from "react";
+import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
+import { useEditor } from "@/editor/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+} from "@/timeline/snapping";
+import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import {
+ getCenteredLineLeft,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/timeline";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+
+interface UseTimelinePlayheadProps {
+ zoomLevel: number;
+ rulerRef: React.RefObject<HTMLDivElement | null>;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ playheadRef?: React.RefObject<HTMLDivElement | null>;
+}
+
+export function useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
+}: UseTimelinePlayheadProps) {
+ const editor = useEditor();
+ const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
+ const activeProject = editor.project.getActive();
+ const duration = editor.timeline.getTotalDuration();
+ const isShiftHeldRef = useShiftKey();
+
+ const zoomLevelRef = useRef(zoomLevel);
+ const durationRef = useRef(duration);
+ const isScrubbingRef = useRef(isScrubbing);
+ const isPlayingRef = useRef(false);
+
+ useEffect(() => {
+ zoomLevelRef.current = zoomLevel;
+ durationRef.current = duration;
+ isScrubbingRef.current = isScrubbing;
+ isPlayingRef.current = editor.playback.getIsPlaying();
+ }, [zoomLevel, duration, isScrubbing, editor.playback]);
+
+ const seek = useCallback(
+ ({ time }: { time: number }) => editor.playback.seek({ time }),
+ [editor.playback],
+ );
+
+ const scrubTimeRef = useRef<number | null>(null);
+ const isDraggingRulerRef = useRef(false);
+ const hasDraggedRulerRef = useRef(false);
+ const lastMouseXRef = useRef<number>(0);
+
+ const handleScrub = useCallback(
+ ({
+ event,
+ snappingEnabled = true,
+ }: {
+ event: MouseEvent | React.MouseEvent;
+ snappingEnabled?: boolean;
+ }) => {
+ const ruler = rulerRef.current;
+ if (!ruler) return;
+ const rulerRect = ruler.getBoundingClientRect();
+ const relativeMouseX = event.clientX - rulerRect.left;
+
+ const timelineContentWidth = timelineTimeToPixels({
+ time: duration,
+ zoomLevel,
+ });
+
+ const clampedMouseX = Math.max(
+ 0,
+ Math.min(timelineContentWidth, relativeMouseX),
+ );
+
+ const rawTimeSeconds = Math.max(
+ 0,
+ Math.min(
+ duration / TICKS_PER_SECOND,
+ clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+ const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
+
+ const rate = activeProject.settings.fps;
+ const frameTime =
+ snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
+
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ const time = (() => {
+ if (!shouldSnap) return frameTime;
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () => getElementEdgeSnapPoints({ tracks }),
+ () => getBookmarkSnapPoints({ bookmarks }),
+ () => getAnimationKeyframeSnapPointsForTimeline({ tracks }),
+ ],
+ });
+ const snapResult = resolveTimelineSnap({
+ targetTime: frameTime,
+ snapPoints,
+ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
+ });
+ return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
+ })();
+
+ scrubTimeRef.current = time;
+ seek({ time });
+
+ lastMouseXRef.current = event.clientX;
+ },
+ [
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ activeProject.settings.fps,
+ isShiftHeldRef,
+ editor.scenes,
+ ],
+ );
+
+ const handlePlayheadMouseDown = useCallback(
+ ({ event }: { event: React.MouseEvent }) => {
+ event.preventDefault();
+ event.stopPropagation();
+ editor.playback.setScrubbing({ isScrubbing: true });
+ handleScrub({ event });
+ },
+ [handleScrub, editor.playback],
+ );
+
+ const handleRulerMouseDown = useCallback(
+ ({ event }: { event: React.MouseEvent }) => {
+ if (event.button !== 0) return;
+ if (playheadRef?.current?.contains(event.target as Node)) return;
+
+ event.preventDefault();
+ isDraggingRulerRef.current = true;
+ hasDraggedRulerRef.current = false;
+
+ editor.playback.setScrubbing({ isScrubbing: true });
+ handleScrub({ event, snappingEnabled: false });
+ },
+ [handleScrub, playheadRef, editor.playback],
+ );
+
+ const handlePlayheadMouseDownEvent = useCallback(
+ (event: React.MouseEvent) => handlePlayheadMouseDown({ event }),
+ [handlePlayheadMouseDown],
+ );
+
+ const handleRulerMouseDownEvent = useCallback(
+ (event: React.MouseEvent) => handleRulerMouseDown({ event }),
+ [handleRulerMouseDown],
+ );
+
+ useEdgeAutoScroll({
+ isActive: isScrubbing,
+ getMouseClientX: () => lastMouseXRef.current,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
+ });
+
+ useEffect(() => {
+ if (!isScrubbing) return;
+
+ const handleMouseMove = ({ event }: { event: MouseEvent }) => {
+ handleScrub({ event });
+ if (isDraggingRulerRef.current) {
+ hasDraggedRulerRef.current = true;
+ }
+ };
+
+ const handleMouseUp = ({ event }: { event: MouseEvent }) => {
+ editor.playback.setScrubbing({ isScrubbing: false });
+ const finalTime = scrubTimeRef.current;
+ if (finalTime !== null) {
+ seek({ time: finalTime });
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
+ playheadTime: finalTime,
+ },
+ });
+ }
+ scrubTimeRef.current = null;
+
+ if (isDraggingRulerRef.current) {
+ isDraggingRulerRef.current = false;
+ if (!hasDraggedRulerRef.current) {
+ handleScrub({ event, snappingEnabled: false });
+ }
+ hasDraggedRulerRef.current = false;
+ }
+ };
+
+ const onMouseMove = (event: MouseEvent) => handleMouseMove({ event });
+ const onMouseUp = (event: MouseEvent) => handleMouseUp({ event });
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+
+ return () => {
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+ }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
+
+ const updatePlayheadLeft = useCallback(
+ (time: number) => {
+ const playheadEl = playheadRef?.current;
+ if (!playheadEl) return;
+ const centerPosition = timelineTimeToSnappedPixels({
+ time,
+ zoomLevel: zoomLevelRef.current,
+ });
+ const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
+ playheadEl.style.left = `${leftPosition - scrollLeft}px`;
+ },
+ [playheadRef, rulerScrollRef],
+ );
+
+ useEffect(() => {
+ const scrollEl = rulerScrollRef.current;
+ if (!scrollEl) return;
+
+ const handleScroll = () => {
+ updatePlayheadLeft(editor.playback.getCurrentTime());
+ };
+
+ scrollEl.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handleScroll);
+ }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
+
+ useEffect(() => {
+ const handlePlaybackUpdate = (e: Event) => {
+ const time = (e as CustomEvent<{ time: number }>).detail.time;
+ updatePlayheadLeft(time);
+
+ if (!isPlayingRef.current || isScrubbingRef.current) return;
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels = timelineTimeToPixels({
+ time,
+ zoomLevel: zoomLevelRef.current,
+ });
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMinimum = 0;
+ const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+
+ const needsScroll =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (needsScroll) {
+ const desiredScroll = Math.max(
+ scrollMinimum,
+ Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ };
+
+ const initialTime = editor.playback.getCurrentTime();
+ handlePlaybackUpdate({
+ detail: { time: initialTime },
+ } as CustomEvent<{ time: number }>);
+
+ window.addEventListener("playback-update", handlePlaybackUpdate);
+ window.addEventListener("playback-seek", handlePlaybackUpdate);
+ return () => {
+ window.removeEventListener("playback-update", handlePlaybackUpdate);
+ window.removeEventListener("playback-seek", handlePlaybackUpdate);
+ };
+ }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
+
+ return {
+ handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
+ handleRulerMouseDown: handleRulerMouseDownEvent,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts
new file mode 100644
index 00000000..fb8da291
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts
@@ -0,0 +1,332 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { EditorCore } from "@/core";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { useEditor } from "@/editor/use-editor";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { useTimelineStore } from "@/timeline/timeline-store";
+import {
+ computeGroupResize,
+ type GroupResizeMember,
+ type GroupResizeResult,
+ type ResizeSide,
+} from "@/timeline/group-resize";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+ type SnapPoint,
+} from "@/timeline/snapping";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
+import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
+import { isRetimableElement } from "@/timeline";
+import { registerCanceller } from "@/editor/cancel-interaction";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+
+interface ResizeInteractionState {
+ side: ResizeSide;
+ startX: number;
+ members: GroupResizeMember[];
+}
+
+export function useTimelineResize({
+ zoomLevel,
+ onSnapPointChange,
+}: {
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
+ const { selectedElements } = useElementSelection();
+ const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
+ null,
+ );
+ const latestResultRef = useRef<GroupResizeResult | null>(null);
+
+ const cancelResize = useCallback(() => {
+ editor.timeline.discardPreview();
+ setResizeState(null);
+ latestResultRef.current = null;
+ onSnapPointChange?.(null);
+ }, [editor.timeline, onSnapPointChange]);
+
+ useEffect(() => {
+ if (!resizeState) {
+ return;
+ }
+
+ return registerCanceller({ fn: cancelResize });
+ }, [resizeState, cancelResize]);
+
+ const handleResizeStart = useCallback(
+ ({
+ event,
+ element,
+ track,
+ side,
+ }: {
+ event: React.MouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ side: ResizeSide;
+ }) => {
+ event.stopPropagation();
+ event.preventDefault();
+
+ const elementRef = {
+ trackId: track.id,
+ elementId: element.id,
+ };
+ const activeSelection = selectedElements.some(
+ (selectedElement) =>
+ selectedElement.trackId === track.id &&
+ selectedElement.elementId === element.id,
+ )
+ ? selectedElements
+ : [elementRef];
+ const members = buildResizeMembers({
+ tracks: editor.scenes.getActiveScene().tracks,
+ selectedElements: activeSelection,
+ });
+ if (members.length === 0) {
+ return;
+ }
+
+ editor.timeline.discardPreview();
+ latestResultRef.current = null;
+ setResizeState({
+ side,
+ startX: event.clientX,
+ members,
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ useEffect(() => {
+ if (!resizeState) {
+ return;
+ }
+
+ const handleMouseMove = ({ clientX }: MouseEvent) => {
+ const deltaX = clientX - resizeState.startX;
+ const rawDeltaTime = Math.round(
+ (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
+ TICKS_PER_SECOND,
+ );
+ const snappedDeltaTime = getSnappedResizeDelta({
+ editor,
+ resizeState,
+ rawDeltaTime,
+ zoomLevel,
+ shouldSnap: snappingEnabled && !isShiftHeldRef.current,
+ onSnapPointChange,
+ });
+ const fps = editor.project.getActive().settings.fps;
+ const result = computeGroupResize({
+ members: resizeState.members,
+ side: resizeState.side,
+ deltaTime: snappedDeltaTime.deltaTime,
+ fps,
+ });
+
+ latestResultRef.current = result;
+ editor.timeline.previewElements({
+ updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ trackId,
+ elementId,
+ updates: patch,
+ })),
+ });
+ };
+
+ const handleMouseUp = () => {
+ const result = latestResultRef.current;
+ editor.timeline.discardPreview();
+ if (
+ result &&
+ hasResizeChanges({ members: resizeState.members, result })
+ ) {
+ editor.timeline.updateElements({
+ updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ trackId,
+ elementId,
+ patch,
+ })),
+ });
+ }
+
+ setResizeState(null);
+ latestResultRef.current = null;
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [
+ resizeState,
+ zoomLevel,
+ snappingEnabled,
+ isShiftHeldRef,
+ editor,
+ onSnapPointChange,
+ ]);
+
+ return {
+ isResizing: resizeState !== null,
+ handleResizeStart,
+ };
+}
+
+function buildResizeMembers({
+ tracks,
+ selectedElements,
+}: {
+ tracks: SceneTracks;
+ selectedElements: Array<{ trackId: string; elementId: string }>;
+}): GroupResizeMember[] {
+ const selectedElementIds = new Set(
+ selectedElements.map((selectedElement) => selectedElement.elementId),
+ );
+ const trackMap = new Map(
+ [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
+ track.id,
+ track,
+ ]),
+ );
+
+ return selectedElements.flatMap(({ trackId, elementId }) => {
+ const track = trackMap.get(trackId);
+ const element = track?.elements.find(
+ (trackElement) => trackElement.id === elementId,
+ );
+ if (!track || !element) {
+ return [];
+ }
+
+ const otherElements = track.elements.filter(
+ (trackElement) => !selectedElementIds.has(trackElement.id),
+ );
+ const leftNeighborBound = otherElements
+ .filter(
+ (trackElement) =>
+ trackElement.startTime + trackElement.duration <= element.startTime,
+ )
+ .reduce((bound, trackElement) => {
+ return Math.max(bound, trackElement.startTime + trackElement.duration);
+ }, -Infinity);
+ const rightNeighborBound = otherElements
+ .filter(
+ (trackElement) =>
+ trackElement.startTime >= element.startTime + element.duration,
+ )
+ .reduce((bound, trackElement) => {
+ return Math.min(bound, trackElement.startTime);
+ }, Infinity);
+
+ return [
+ {
+ trackId,
+ elementId,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ sourceDuration: element.sourceDuration,
+ retime: isRetimableElement(element) ? element.retime : undefined,
+ leftNeighborBound,
+ rightNeighborBound,
+ },
+ ];
+ });
+}
+
+function getSnappedResizeDelta({
+ editor,
+ resizeState,
+ rawDeltaTime,
+ zoomLevel,
+ shouldSnap,
+ onSnapPointChange,
+}: {
+ editor: EditorCore;
+ resizeState: ResizeInteractionState;
+ rawDeltaTime: number;
+ zoomLevel: number;
+ shouldSnap: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}): { deltaTime: number } {
+ if (!shouldSnap) {
+ onSnapPointChange?.(null);
+ return { deltaTime: rawDeltaTime };
+ }
+
+ const tracks = editor.scenes.getActiveScene().tracks;
+ const playheadTime = editor.playback.getCurrentTime();
+ const excludeElementIds = new Set(
+ resizeState.members.map((member) => member.elementId),
+ );
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
+ () => getPlayheadSnapPoints({ playheadTime }),
+ () =>
+ getAnimationKeyframeSnapPointsForTimeline({
+ tracks,
+ excludeElementIds,
+ }),
+ ],
+ });
+ const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestSnapDistance = Infinity;
+ let deltaTime = rawDeltaTime;
+
+ for (const member of resizeState.members) {
+ const baseEdgeTime =
+ resizeState.side === "left"
+ ? member.startTime
+ : member.startTime + member.duration;
+ const snapResult = resolveTimelineSnap({
+ targetTime: baseEdgeTime + rawDeltaTime,
+ snapPoints,
+ maxSnapDistance,
+ });
+ if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
+ closestSnapDistance = snapResult.snapDistance;
+ closestSnapPoint = snapResult.snapPoint;
+ deltaTime = snapResult.snappedTime - baseEdgeTime;
+ }
+ }
+
+ onSnapPointChange?.(closestSnapPoint);
+ return { deltaTime };
+}
+
+function hasResizeChanges({
+ members,
+ result,
+}: {
+ members: GroupResizeMember[];
+ result: GroupResizeResult;
+}): boolean {
+ return result.updates.some((update) => {
+ const member = members.find(
+ (candidateMember) => candidateMember.elementId === update.elementId,
+ );
+ return (
+ member?.trimStart !== update.patch.trimStart ||
+ member?.trimEnd !== update.patch.trimEnd ||
+ member?.startTime !== update.patch.startTime ||
+ member?.duration !== update.patch.duration
+ );
+ });
+}
diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts
new file mode 100644
index 00000000..889177c9
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts
@@ -0,0 +1,197 @@
+import { useCallback, useRef } from "react";
+import type { MutableRefObject, RefObject } from "react";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { snappedSeekTime } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { useEditor } from "@/editor/use-editor";
+
+interface UseTimelineSeekProps {
+ playheadRef: RefObject<HTMLDivElement | null>;
+ trackLabelsRef: RefObject<HTMLDivElement | null>;
+ rulerScrollRef: RefObject<HTMLDivElement | null>;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ zoomLevel: number;
+ duration: number;
+ isSelecting: boolean;
+ clearSelectedElements: () => void;
+ seek: (time: number) => void;
+}
+
+function resetMouseTracking({
+ mouseTrackingRef,
+}: {
+ mouseTrackingRef: MutableRefObject<{
+ isMouseDown: boolean;
+ downX: number;
+ downY: number;
+ downTime: number;
+ }>;
+}) {
+ mouseTrackingRef.current = {
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ };
+}
+
+function setMouseTracking({
+ mouseTrackingRef,
+ event,
+}: {
+ mouseTrackingRef: MutableRefObject<{
+ isMouseDown: boolean;
+ downX: number;
+ downY: number;
+ downTime: number;
+ }>;
+ event: React.MouseEvent;
+}) {
+ mouseTrackingRef.current = {
+ isMouseDown: true,
+ downX: event.clientX,
+ downY: event.clientY,
+ downTime: event.timeStamp,
+ };
+}
+
+export function useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration,
+ isSelecting,
+ clearSelectedElements,
+ seek,
+}: UseTimelineSeekProps) {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+
+ const mouseTrackingRef = useRef({
+ isMouseDown: false,
+ downX: 0,
+ downY: 0,
+ downTime: 0,
+ });
+
+ const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
+ if (event.button !== 0) return;
+ setMouseTracking({ mouseTrackingRef, event });
+ }, []);
+
+ const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
+ if (event.button !== 0) return;
+ setMouseTracking({ mouseTrackingRef, event });
+ }, []);
+
+ const shouldProcessTimelineClick = useCallback(
+ ({ event }: { event: React.MouseEvent }) => {
+ const target = event.target as HTMLElement;
+ const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
+ const deltaX = Math.abs(event.clientX - downX);
+ const deltaY = Math.abs(event.clientY - downY);
+ const deltaTime = event.timeStamp - downTime;
+ const isPlayhead = !!playheadRef.current?.contains(target);
+ const isTrackLabels = !!trackLabelsRef.current?.contains(target);
+ const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
+
+ if (!isMouseDown) return false;
+ if (shouldBlockForDrag) return false;
+ if (isSelecting) return false;
+ if (isPlayhead) return false;
+ if (isTrackLabels) {
+ clearSelectedElements();
+ return false;
+ }
+
+ return true;
+ },
+ [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
+ );
+
+ const handleTimelineSeek = useCallback(
+ ({
+ event,
+ source,
+ }: {
+ event: React.MouseEvent;
+ source: "ruler" | "tracks";
+ }) => {
+ const scrollContainer =
+ source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
+
+ if (!scrollContainer) return;
+
+ const rect = scrollContainer.getBoundingClientRect();
+ const mouseX = event.clientX - rect.left;
+ const scrollLeft = scrollContainer.scrollLeft;
+
+ const rawTimeSeconds = Math.max(
+ 0,
+ Math.min(
+ duration / TICKS_PER_SECOND,
+ (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+ const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
+
+ const rate = activeProject?.settings.fps;
+ const time = rate
+ ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime)
+ : rawTime;
+ seek(time);
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: scrollContainer.scrollLeft,
+ playheadTime: time,
+ },
+ });
+ },
+ [
+ duration,
+ zoomLevel,
+ rulerScrollRef,
+ tracksScrollRef,
+ seek,
+ editor,
+ activeProject?.settings.fps.numerator,
+ activeProject?.settings.fps.denominator,
+ ],
+ );
+
+ const handleTracksClick = useCallback(
+ (event: React.MouseEvent) => {
+ const shouldProcess = shouldProcessTimelineClick({ event });
+ resetMouseTracking({ mouseTrackingRef });
+
+ if (shouldProcess) {
+ clearSelectedElements();
+ handleTimelineSeek({ event, source: "tracks" });
+ }
+ },
+ [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
+ );
+
+ const handleRulerClick = useCallback(
+ (event: React.MouseEvent) => {
+ const shouldProcess = shouldProcessTimelineClick({ event });
+ resetMouseTracking({ mouseTrackingRef });
+
+ if (shouldProcess) {
+ clearSelectedElements();
+ handleTimelineSeek({ event, source: "ruler" });
+ }
+ },
+ [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
+ );
+
+ return {
+ handleTracksMouseDown,
+ handleTracksClick,
+ handleRulerMouseDown,
+ handleRulerClick,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
new file mode 100644
index 00000000..7929d495
--- /dev/null
+++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
@@ -0,0 +1,291 @@
+import {
+ type WheelEvent as ReactWheelEvent,
+ type RefObject,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction";
+import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale";
+import { timelineTimeToPixels } from "@/timeline/pixel-utils";
+import { useEditor } from "@/editor/use-editor";
+import { zoomToSlider } from "@/timeline/zoom-utils";
+
+interface UseTimelineZoomProps {
+ containerRef: RefObject<HTMLDivElement | null>;
+ minZoom?: number;
+ initialZoom?: number;
+ initialScrollLeft?: number;
+ initialPlayheadTime?: number;
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ rulerScrollRef: RefObject<HTMLDivElement | null>;
+}
+
+interface UseTimelineZoomReturn {
+ zoomLevel: number;
+ setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void;
+ handleWheel: (event: ReactWheelEvent) => void;
+ saveScrollPosition: () => void;
+}
+
+export function useTimelineZoom({
+ containerRef,
+ minZoom = TIMELINE_ZOOM_MIN,
+ initialZoom,
+ initialScrollLeft,
+ initialPlayheadTime,
+ tracksScrollRef,
+ rulerScrollRef,
+}: UseTimelineZoomProps): UseTimelineZoomReturn {
+ const editor = useEditor();
+ const hasInitializedRef = useRef(false);
+ const hasRestoredPlayheadRef = useRef(false);
+ const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
+ null,
+ );
+
+ const [zoomLevel, setZoomLevelRaw] = useState(() => {
+ if (initialZoom !== undefined) {
+ hasInitializedRef.current = true;
+ return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom));
+ }
+ return minZoom;
+ });
+ const previousZoomRef = useRef(zoomLevel);
+ const hasRestoredScrollRef = useRef(false);
+ const preZoomScrollLeftRef = useRef(0);
+ const prePlayheadAnchorScrollLeftRef = useRef(0);
+ const isInPlayheadAnchorModeRef = useRef(false);
+
+ const setZoomLevel = useCallback(
+ (updater: number | ((prev: number) => number)) => {
+ const scrollElement = tracksScrollRef.current;
+ if (scrollElement) {
+ preZoomScrollLeftRef.current = scrollElement.scrollLeft;
+ }
+ setZoomLevelRaw(updater);
+ },
+ [tracksScrollRef],
+ );
+
+ const handleWheel = useCallback(
+ (event: ReactWheelEvent) => {
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ const isHorizontalScrollGesture =
+ event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
+
+ if (isHorizontalScrollGesture) {
+ return;
+ }
+
+ // pinch-zoom (ctrl/meta + wheel)
+ if (isZoomGesture) {
+ const normalizedDelta =
+ event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
+ const cappedDelta =
+ Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ setZoomLevel((prev) => {
+ const nextZoom = Math.max(
+ minZoom,
+ Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
+ );
+ return nextZoom;
+ });
+ return;
+ }
+ },
+ [minZoom, setZoomLevel],
+ );
+
+ useEffect(() => {
+ if (initialZoom !== undefined && !hasInitializedRef.current) {
+ hasInitializedRef.current = true;
+ setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
+ return;
+ }
+ setZoomLevel((prev) => {
+ if (prev < minZoom) {
+ return minZoom;
+ }
+ return prev;
+ });
+ }, [minZoom, initialZoom, setZoomLevel]);
+
+ const wrappedSetZoomLevel = useCallback(
+ (zoomLevelOrUpdater: number | ((prev: number) => number)) => {
+ setZoomLevel((prev) => {
+ const nextZoom =
+ typeof zoomLevelOrUpdater === "function"
+ ? zoomLevelOrUpdater(prev)
+ : zoomLevelOrUpdater;
+ const clampedZoom = Math.max(
+ minZoom,
+ Math.min(TIMELINE_ZOOM_MAX, nextZoom),
+ );
+ return clampedZoom;
+ });
+ },
+ [minZoom, setZoomLevel],
+ );
+
+ useLayoutEffect(() => {
+ const previousZoom = previousZoomRef.current;
+ if (previousZoom === zoomLevel) return;
+
+ const scrollElement = tracksScrollRef.current;
+ if (!scrollElement) {
+ previousZoomRef.current = zoomLevel;
+ return;
+ }
+
+ const currentScrollLeft = preZoomScrollLeftRef.current;
+ const playheadTime = editor.playback.getCurrentTime();
+ const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
+ const previousSliderPercent = zoomToSlider({
+ zoomLevel: previousZoom,
+ minZoom,
+ });
+ const isCrossingThresholdUp =
+ previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+ const isCrossingThresholdDown =
+ previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+
+ const syncScroll = (scrollLeft: number) => {
+ scrollElement.scrollLeft = scrollLeft;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = scrollLeft;
+ }
+ };
+
+ const clampScrollLeft = (scrollLeft: number) => {
+ const maxScrollLeft =
+ scrollElement.scrollWidth - scrollElement.clientWidth;
+ return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
+ };
+
+ if (isCrossingThresholdUp) {
+ prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
+ isInPlayheadAnchorModeRef.current = true;
+ }
+
+ if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
+ const playheadPixelsBefore = timelineTimeToPixels({
+ time: playheadTime,
+ zoomLevel: previousZoom,
+ });
+ const playheadPixelsAfter = timelineTimeToPixels({
+ time: playheadTime,
+ zoomLevel,
+ });
+
+ const viewportOffset = playheadPixelsBefore - currentScrollLeft;
+ const newScrollLeft = playheadPixelsAfter - viewportOffset;
+
+ syncScroll(clampScrollLeft(newScrollLeft));
+ } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
+ syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
+ isInPlayheadAnchorModeRef.current = false;
+ }
+
+ previousZoomRef.current = zoomLevel;
+
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime,
+ },
+ });
+ }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
+ const saveScrollPosition = useCallback(() => {
+ if (scrollSaveTimeoutRef.current) {
+ clearTimeout(scrollSaveTimeoutRef.current);
+ }
+ scrollSaveTimeoutRef.current = setTimeout(() => {
+ const scrollElement = tracksScrollRef.current;
+ if (scrollElement) {
+ editor.project.setTimelineViewState({
+ viewState: {
+ zoomLevel,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime: editor.playback.getCurrentTime(),
+ },
+ });
+ }
+ }, 300);
+ }, [zoomLevel, editor]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
+ useEffect(() => {
+ if (initialScrollLeft === undefined) return;
+ if (hasRestoredScrollRef.current) return;
+ const scrollElement = tracksScrollRef.current;
+ if (!scrollElement) return;
+
+ const restoreScroll = () => {
+ scrollElement.scrollLeft = initialScrollLeft;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = initialScrollLeft;
+ }
+ hasRestoredScrollRef.current = true;
+ };
+
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ } else {
+ const observer = new ResizeObserver(() => {
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ observer.disconnect();
+ }
+ });
+ observer.observe(scrollElement);
+ return () => observer.disconnect();
+ }
+ }, [initialScrollLeft]);
+
+ useEffect(() => {
+ if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
+ hasRestoredPlayheadRef.current = true;
+ editor.playback.seek({ time: initialPlayheadTime });
+ }
+ }, [initialPlayheadTime, editor]);
+
+ // prevent browser zoom in the timeline
+ useEffect(() => {
+ const preventZoom = (event: WheelEvent) => {
+ const isZoomKeyPressed = event.ctrlKey || event.metaKey;
+ const isInContainer = containerRef.current?.contains(
+ event.target as Node,
+ );
+ // only check isInContainer, not isInTimeline state - the state check
+ // causes race conditions where the closure captures stale state
+ if (isZoomKeyPressed && isInContainer) {
+ event.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, {
+ passive: false,
+ capture: true,
+ });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom, { capture: true });
+ };
+ }, [containerRef]);
+
+ return {
+ zoomLevel,
+ setZoomLevel: wrappedSetZoomLevel,
+ handleWheel,
+ saveScrollPosition,
+ };
+}
diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts
new file mode 100644
index 00000000..5ee079b1
--- /dev/null
+++ b/apps/web/src/timeline/index.ts
@@ -0,0 +1,29 @@
+import type { SceneTracks } from "./types";
+
+export * from "./types";
+export * from "./drag";
+export * from "./track-capabilities";
+export * from "./track-element-update";
+export * from "./element-utils";
+export * from "./audio-separation";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+export * from "./pixel-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: SceneTracks;
+}): number {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ if (orderedTracks.length === 0) return 0;
+
+ const trackEndTimes = orderedTracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd = element.startTime + element.duration;
+ return Math.max(maxEnd, elementEnd);
+ }, 0),
+ );
+
+ return Math.max(...trackEndTimes, 0);
+}
diff --git a/apps/web/src/timeline/pixel-utils.ts b/apps/web/src/timeline/pixel-utils.ts
new file mode 100644
index 00000000..71e8466a
--- /dev/null
+++ b/apps/web/src/timeline/pixel-utils.ts
@@ -0,0 +1,80 @@
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+
+export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
+
+function getDevicePixelRatio({
+ devicePixelRatio,
+}: {
+ devicePixelRatio?: number;
+}): number {
+ if (
+ typeof devicePixelRatio === "number" &&
+ Number.isFinite(devicePixelRatio) &&
+ devicePixelRatio > 0
+ ) {
+ return devicePixelRatio;
+ }
+
+ if (typeof window === "undefined") {
+ return 1;
+ }
+
+ if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
+ return window.devicePixelRatio;
+ }
+
+ return 1;
+}
+
+export function getTimelinePixelsPerSecond({
+ zoomLevel,
+}: {
+ zoomLevel: number;
+}): number {
+ return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+}
+
+export function timelineTimeToPixels({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}): number {
+ return (time / TICKS_PER_SECOND) * getTimelinePixelsPerSecond({ zoomLevel });
+}
+
+export function snapPixelToDeviceGrid({
+ pixel,
+ devicePixelRatio,
+}: {
+ pixel: number;
+ devicePixelRatio?: number;
+}): number {
+ const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
+ return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
+}
+
+export function timelineTimeToSnappedPixels({
+ time,
+ zoomLevel,
+ devicePixelRatio,
+}: {
+ time: number;
+ zoomLevel: number;
+ devicePixelRatio?: number;
+}): number {
+ const rawPixel = timelineTimeToPixels({ time, zoomLevel });
+ return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
+}
+
+export function getCenteredLineLeft({
+ centerPixel,
+ lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
+}: {
+ centerPixel: number;
+ lineWidthPx?: number;
+}): number {
+ return centerPixel - lineWidthPx / 2;
+}
diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
new file mode 100644
index 00000000..ebbfe578
--- /dev/null
+++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts
@@ -0,0 +1,661 @@
+import { describe, expect, test } from "bun:test";
+import type {
+ AudioElement,
+ AudioTrack,
+ GraphicElement,
+ GraphicTrack,
+ OverlayTrack,
+ SceneTracks,
+ TextElement,
+ TextTrack,
+ TimelineTrack,
+ VideoElement,
+ VideoTrack,
+} from "@/timeline";
+import type { Transform } from "@/rendering";
+import { resolveTrackPlacement } from "@/timeline/placement";
+
+function buildTransform(): Transform {
+ return {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ };
+}
+
+type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
+
+function buildElement(params: {
+ id: string;
+ type: "audio";
+ startTime: number;
+ duration: number;
+}): AudioElement;
+function buildElement(params: {
+ id: string;
+ type: "graphic";
+ startTime: number;
+ duration: number;
+}): GraphicElement;
+function buildElement(params: {
+ id: string;
+ type: "text";
+ startTime: number;
+ duration: number;
+}): TextElement;
+function buildElement(params: {
+ id: string;
+ type: "video";
+ startTime: number;
+ duration: number;
+}): VideoElement;
+function buildElement({
+ id,
+ type,
+ startTime,
+ duration,
+}: {
+ id: string;
+ type: TestElement["type"];
+ startTime: number;
+ duration: number;
+}): TestElement {
+ switch (type) {
+ case "audio":
+ return {
+ id,
+ type: "audio",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ volume: 1,
+ sourceType: "upload",
+ mediaId: `media-${id}`,
+ } satisfies AudioElement;
+ case "graphic":
+ return {
+ id,
+ type: "graphic",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ definitionId: `graphic-${id}`,
+ params: {},
+ transform: buildTransform(),
+ opacity: 1,
+ } satisfies GraphicElement;
+ case "text":
+ return {
+ id,
+ type: "text",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ content: id,
+ fontSize: 32,
+ fontFamily: "sans-serif",
+ color: "#ffffff",
+ background: {
+ enabled: false,
+ color: "#000000",
+ },
+ textAlign: "left",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ transform: buildTransform(),
+ opacity: 1,
+ } satisfies TextElement;
+ case "video":
+ return {
+ id,
+ type: "video",
+ name: id,
+ startTime,
+ duration,
+ trimStart: 0,
+ trimEnd: 0,
+ mediaId: `media-${id}`,
+ transform: buildTransform(),
+ opacity: 1,
+ } satisfies VideoElement;
+ }
+
+ throw new Error(`Unsupported test element type: ${type}`);
+}
+
+type BuildTrackParams =
+ | {
+ id: string;
+ type: "audio";
+ elements?: AudioTrack["elements"];
+ }
+ | {
+ id: string;
+ type: "graphic";
+ elements?: GraphicTrack["elements"];
+ }
+ | {
+ id: string;
+ type: "text";
+ elements?: TextTrack["elements"];
+ }
+ | {
+ id: string;
+ type: "video";
+ elements?: VideoTrack["elements"];
+ };
+
+function buildTrack(params: {
+ id: string;
+ type: "audio";
+ elements?: AudioTrack["elements"];
+}): AudioTrack;
+function buildTrack(params: {
+ id: string;
+ type: "graphic";
+ elements?: GraphicTrack["elements"];
+}): GraphicTrack;
+function buildTrack(params: {
+ id: string;
+ type: "text";
+ elements?: TextTrack["elements"];
+}): TextTrack;
+function buildTrack(params: {
+ id: string;
+ type: "video";
+ elements?: VideoTrack["elements"];
+}): VideoTrack;
+function buildTrack(params: BuildTrackParams): TimelineTrack {
+ const { id, type } = params;
+
+ switch (type) {
+ case "audio":
+ return {
+ id,
+ type: "audio",
+ name: id,
+ elements: params.elements ?? [],
+ muted: false,
+ };
+ case "graphic":
+ return {
+ id,
+ type: "graphic",
+ name: id,
+ elements: params.elements ?? [],
+ hidden: false,
+ };
+ case "text":
+ return {
+ id,
+ type: "text",
+ name: id,
+ elements: params.elements ?? [],
+ hidden: false,
+ };
+ case "video":
+ return {
+ id,
+ type: "video",
+ name: id,
+ elements: params.elements ?? [],
+ muted: false,
+ hidden: false,
+ };
+ }
+
+ throw new Error(`Unsupported test track type: ${type}`);
+}
+
+function buildTimeSpan({
+ startTime,
+ duration,
+ excludeElementId,
+}: {
+ startTime: number;
+ duration: number;
+ excludeElementId?: string;
+}) {
+ return { startTime, duration, excludeElementId };
+}
+
+function buildSceneTracks({
+ overlay = [],
+ main,
+ audio = [],
+}: {
+ overlay?: Array<OverlayTrack>;
+ main?: VideoTrack;
+ audio?: Array<AudioTrack>;
+}): SceneTracks {
+ return {
+ overlay,
+ main:
+ main ??
+ buildTrack({
+ id: "video-main",
+ type: "video",
+ }),
+ audio,
+ };
+}
+
+describe("resolveTrackPlacement", () => {
+ test("explicit returns the requested compatible track", () => {
+ const tracks = buildSceneTracks({
+ overlay: [buildTrack({ id: "text-1", type: "text" })],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "text",
+ timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })],
+ strategy: { type: "explicit", trackId: "text-1" },
+ }),
+ ).toEqual({
+ kind: "existingTrack",
+ trackId: "text-1",
+ trackIndex: 0,
+ trackType: "text",
+ });
+ });
+
+ test("explicit rejects missing and incompatible tracks", () => {
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-1", type: "video" }),
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "text",
+ timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
+ strategy: { type: "explicit", trackId: "missing" },
+ }),
+ ).toBeNull();
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "text",
+ timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
+ strategy: { type: "explicit", trackId: "video-1" },
+ }),
+ ).toBeNull();
+ });
+
+ test("firstAvailable picks the first compatible track without overlap", () => {
+ const tracks = buildSceneTracks({
+ overlay: [
+ buildTrack({
+ id: "text-1",
+ type: "text",
+ elements: [
+ buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
+ ],
+ }),
+ buildTrack({ id: "text-2", type: "text" }),
+ ],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "text",
+ timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })],
+ strategy: { type: "firstAvailable" },
+ }),
+ ).toEqual({
+ kind: "existingTrack",
+ trackId: "text-2",
+ trackIndex: 1,
+ trackType: "text",
+ });
+ });
+
+ test("firstAvailable creates a new track when all compatible tracks are full", () => {
+ const tracks = buildSceneTracks({
+ overlay: [
+ buildTrack({
+ id: "graphic-1",
+ type: "graphic",
+ elements: [
+ buildElement({
+ id: "a",
+ type: "graphic",
+ startTime: 0,
+ duration: 5,
+ }),
+ ],
+ }),
+ ],
+ main: buildTrack({
+ id: "video-main",
+ type: "video",
+ }),
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "graphic",
+ timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
+ strategy: { type: "firstAvailable" },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "graphic",
+ insertIndex: 0,
+ insertPosition: null,
+ });
+ });
+
+ test("preferIndex uses the preferred track when it fits", () => {
+ const tracks = buildSceneTracks({
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "audio",
+ timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: 0,
+ hoverDirection: "below",
+ },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 1,
+ insertPosition: "below",
+ });
+ });
+
+ test("preferIndex creates a new overlay track above the main track", () => {
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "graphic",
+ timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: 1,
+ hoverDirection: "below",
+ },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "graphic",
+ insertIndex: 0,
+ insertPosition: "above",
+ });
+ });
+
+ test("preferIndex keeps audio tracks below the main track", () => {
+ const tracks = buildSceneTracks({
+ overlay: [buildTrack({ id: "text-1", type: "text" })],
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "audio",
+ timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: 0,
+ hoverDirection: "above",
+ createNewTrackOnly: true,
+ },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 2,
+ insertPosition: "below",
+ });
+ });
+
+ test("aboveSource tries the track above source, then any compatible track", () => {
+ const tracks = buildSceneTracks({
+ overlay: [
+ buildTrack({ id: "text-top", type: "text" }),
+ buildTrack({
+ id: "text-middle",
+ type: "text",
+ elements: [
+ buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
+ ],
+ }),
+ buildTrack({ id: "text-source", type: "text" }),
+ ],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "text",
+ timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
+ strategy: { type: "aboveSource", sourceTrackIndex: 2 },
+ }),
+ ).toEqual({
+ kind: "existingTrack",
+ trackId: "text-top",
+ trackIndex: 0,
+ trackType: "text",
+ });
+ });
+
+ test("aboveSource creates a new overlay track in the overlay zone when none fit", () => {
+ const tracks = buildSceneTracks({
+ overlay: [
+ buildTrack({
+ id: "text-top",
+ type: "text",
+ elements: [
+ buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
+ ],
+ }),
+ buildTrack({
+ id: "text-source",
+ type: "text",
+ elements: [
+ buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
+ ],
+ }),
+ ],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "text",
+ timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
+ strategy: { type: "aboveSource", sourceTrackIndex: 1 },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "text",
+ insertIndex: 0,
+ insertPosition: null,
+ });
+ });
+
+ test("alwaysNew honors highest and default insertion rules", () => {
+ const tracks = buildSceneTracks({
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "audio",
+ timeSpans: [],
+ strategy: { type: "alwaysNew", position: "highest" },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 1,
+ insertPosition: null,
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "audio",
+ timeSpans: [],
+ strategy: { type: "alwaysNew", position: "default" },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 2,
+ insertPosition: null,
+ });
+ });
+
+ test("batch time spans reject tracks when any span overlaps", () => {
+ const tracks = buildSceneTracks({
+ audio: [
+ buildTrack({
+ id: "audio-1",
+ type: "audio",
+ elements: [
+ buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
+ buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
+ ],
+ }),
+ ],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "audio",
+ timeSpans: [
+ buildTimeSpan({ startTime: 2.5, duration: 1 }),
+ buildTimeSpan({ startTime: 5.5, duration: 1 }),
+ ],
+ strategy: { type: "firstAvailable" },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 1,
+ insertPosition: null,
+ });
+ });
+
+ test("handles main-only timelines, single tracks, and track-type derivation", () => {
+ expect(
+ resolveTrackPlacement({
+ tracks: buildSceneTracks({}),
+ elementType: "video",
+ timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: 0,
+ hoverDirection: "below",
+ createNewTrackOnly: true,
+ },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "video",
+ insertIndex: 0,
+ insertPosition: "above",
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks: buildSceneTracks({
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ }),
+ elementType: "audio",
+ timeSpans: [],
+ strategy: { type: "alwaysNew", position: "default" },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 2,
+ insertPosition: null,
+ });
+ });
+
+ test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
+ const tracks = buildSceneTracks({
+ main: buildTrack({
+ id: "video-main",
+ type: "video",
+ elements: [
+ buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
+ ],
+ }),
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "video",
+ timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })],
+ strategy: { type: "explicit", trackId: "video-main" },
+ }),
+ ).toEqual({
+ kind: "existingTrack",
+ trackId: "video-main",
+ trackIndex: 0,
+ trackType: "video",
+ adjustedStartTime: 0,
+ });
+ });
+
+ test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
+ const tracks = buildSceneTracks({
+ overlay: [buildTrack({ id: "text-1", type: "text" })],
+ main: buildTrack({ id: "video-main", type: "video" }),
+ audio: [buildTrack({ id: "audio-1", type: "audio" })],
+ });
+
+ expect(
+ resolveTrackPlacement({
+ tracks,
+ elementType: "audio",
+ timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
+ strategy: {
+ type: "preferIndex",
+ trackIndex: 0,
+ hoverDirection: "above",
+ verticalDragDirection: "down",
+ },
+ }),
+ ).toEqual({
+ kind: "newTrack",
+ trackType: "audio",
+ insertIndex: 2,
+ insertPosition: "below",
+ });
+ });
+});
diff --git a/apps/web/src/timeline/placement/apply.ts b/apps/web/src/timeline/placement/apply.ts
new file mode 100644
index 00000000..214b0611
--- /dev/null
+++ b/apps/web/src/timeline/placement/apply.ts
@@ -0,0 +1,158 @@
+import type {
+ AudioTrack,
+ EffectTrack,
+ GraphicTrack,
+ OverlayTrack,
+ SceneTracks,
+ TextTrack,
+ TimelineElement,
+ TimelineTrack,
+ VideoTrack,
+} from "@/timeline";
+import { generateUUID } from "@/utils/id";
+import { buildEmptyTrack } from "./track-factory";
+import type { PlacementResult } from "./types";
+import { updateTrackInSceneTracks } from "@/timeline/track-element-update";
+
+export function applyPlacement({
+ tracks,
+ placementResult,
+ elements,
+ newTrackInsertIndexOverride,
+}: {
+ tracks: SceneTracks;
+ placementResult: PlacementResult;
+ elements: TimelineElement[];
+ newTrackInsertIndexOverride?: number;
+}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ if (placementResult.kind === "existingTrack") {
+ const targetTrack = orderedTracks[placementResult.trackIndex];
+ if (!targetTrack) {
+ return null;
+ }
+
+ const updatedTracks = updateTrackInSceneTracks({
+ tracks,
+ trackId: targetTrack.id,
+ update: (track) => ({
+ ...track,
+ elements: [...track.elements, ...elements],
+ }),
+ });
+
+ return { updatedTracks, targetTrackId: targetTrack.id };
+ }
+
+ const newTrackId = generateUUID();
+ const insertIndex =
+ newTrackInsertIndexOverride ?? placementResult.insertIndex;
+ const updatedTracks =
+ placementResult.trackType === "audio"
+ ? {
+ ...tracks,
+ audio: insertIntoAudioTracks({
+ tracks,
+ insertIndex,
+ track: buildPlacedAudioTrack({
+ id: newTrackId,
+ elements,
+ }),
+ }),
+ }
+ : {
+ ...tracks,
+ overlay: insertIntoOverlayTracks({
+ tracks,
+ insertIndex,
+ track: buildPlacedOverlayTrack({
+ id: newTrackId,
+ type: placementResult.trackType,
+ elements,
+ }),
+ }),
+ };
+ return { updatedTracks, targetTrackId: newTrackId };
+}
+
+function insertIntoOverlayTracks({
+ tracks,
+ insertIndex,
+ track,
+}: {
+ tracks: SceneTracks;
+ insertIndex: number;
+ track: OverlayTrack;
+}): OverlayTrack[] {
+ const normalizedInsertIndex = Math.max(
+ 0,
+ Math.min(insertIndex, tracks.overlay.length),
+ );
+ const nextTracks = [...tracks.overlay];
+ nextTracks.splice(normalizedInsertIndex, 0, track);
+ return nextTracks;
+}
+
+function insertIntoAudioTracks({
+ tracks,
+ insertIndex,
+ track,
+}: {
+ tracks: SceneTracks;
+ insertIndex: number;
+ track: AudioTrack;
+}): AudioTrack[] {
+ const audioInsertIndex = Math.max(
+ 0,
+ Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
+ );
+ const nextTracks = [...tracks.audio];
+ nextTracks.splice(audioInsertIndex, 0, track);
+ return nextTracks;
+}
+
+function buildPlacedAudioTrack({
+ id,
+ elements,
+}: {
+ id: string;
+ elements: TimelineElement[];
+}): AudioTrack {
+ return {
+ ...buildEmptyTrack({ id, type: "audio" }),
+ elements: elements as AudioTrack["elements"],
+ };
+}
+
+function buildPlacedOverlayTrack({
+ id,
+ type,
+ elements,
+}: {
+ id: string;
+ type: Exclude<OverlayTrack["type"], "audio">;
+ elements: TimelineElement[];
+}): OverlayTrack {
+ switch (type) {
+ case "video":
+ return {
+ ...buildEmptyTrack({ id, type: "video" }),
+ elements: elements as VideoTrack["elements"],
+ };
+ case "text":
+ return {
+ ...buildEmptyTrack({ id, type: "text" }),
+ elements: elements as TextTrack["elements"],
+ };
+ case "graphic":
+ return {
+ ...buildEmptyTrack({ id, type: "graphic" }),
+ elements: elements as GraphicTrack["elements"],
+ };
+ case "effect":
+ return {
+ ...buildEmptyTrack({ id, type: "effect" }),
+ elements: elements as EffectTrack["elements"],
+ };
+ }
+}
diff --git a/apps/web/src/timeline/placement/compatibility.ts b/apps/web/src/timeline/placement/compatibility.ts
new file mode 100644
index 00000000..b1af547c
--- /dev/null
+++ b/apps/web/src/timeline/placement/compatibility.ts
@@ -0,0 +1,51 @@
+import type { ElementType, TrackType } from "@/timeline";
+
+const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
+ audio: "audio",
+ text: "text",
+ sticker: "graphic",
+ graphic: "graphic",
+ effect: "effect",
+ video: "video",
+ image: "video",
+};
+
+export function getTrackTypeForElementType({
+ elementType,
+}: {
+ elementType: ElementType;
+}): TrackType {
+ return ELEMENT_TRACK_MAP[elementType];
+}
+
+export function canElementGoOnTrack({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TrackType;
+}): boolean {
+ return getTrackTypeForElementType({ elementType }) === trackType;
+}
+
+export function validateElementTrackCompatibility({
+ element,
+ track,
+}: {
+ element: { type: ElementType };
+ track: { type: TrackType };
+}): { isValid: boolean; errorMessage?: string } {
+ const isValid = canElementGoOnTrack({
+ elementType: element.type,
+ trackType: track.type,
+ });
+
+ if (!isValid) {
+ return {
+ isValid: false,
+ errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
+ };
+ }
+
+ return { isValid: true };
+}
diff --git a/apps/web/src/timeline/placement/index.ts b/apps/web/src/timeline/placement/index.ts
new file mode 100644
index 00000000..530cb69f
--- /dev/null
+++ b/apps/web/src/timeline/placement/index.ts
@@ -0,0 +1,12 @@
+export { applyPlacement } from "./apply";
+export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
+export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
+export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track";
+export { resolveTrackPlacement } from "./resolve";
+export { buildEmptyTrack } from "./track-factory";
+export type {
+ PlacementResult,
+ PlacementStrategy,
+ PlacementSubject,
+ PlacementTimeSpan,
+} from "./types";
diff --git a/apps/web/src/timeline/placement/insert-index.ts b/apps/web/src/timeline/placement/insert-index.ts
new file mode 100644
index 00000000..5e2786dc
--- /dev/null
+++ b/apps/web/src/timeline/placement/insert-index.ts
@@ -0,0 +1,88 @@
+import type { SceneTracks, TrackType } from "@/timeline";
+
+export function getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: SceneTracks;
+ trackType: TrackType;
+}): number {
+ if (trackType === "audio") {
+ return tracks.overlay.length + 1 + tracks.audio.length;
+ }
+
+ if (trackType === "effect") {
+ return 0;
+ }
+
+ return tracks.overlay.length;
+}
+
+export function getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: SceneTracks;
+ trackType: TrackType;
+}): number {
+ if (trackType === "audio") {
+ return tracks.overlay.length + 1;
+ }
+
+ return 0;
+}
+
+export function resolvePreferredNewTrackPlacement({
+ tracks,
+ trackType,
+ preferredIndex,
+ direction,
+}: {
+ tracks: SceneTracks;
+ trackType: TrackType;
+ preferredIndex: number;
+ direction: "above" | "below";
+}): { insertIndex: number; insertPosition: "above" | "below" | null } {
+ const trackCount = tracks.overlay.length + 1 + tracks.audio.length;
+ if (trackCount === 0) {
+ return {
+ insertIndex: 0,
+ insertPosition: trackType === "audio" ? "below" : null,
+ };
+ }
+
+ const safePreferredIndex = Math.min(
+ Math.max(preferredIndex, 0),
+ trackCount - 1,
+ );
+ const mainTrackIndex = tracks.overlay.length;
+
+ if (trackType === "audio") {
+ if (safePreferredIndex <= mainTrackIndex) {
+ return {
+ insertIndex: mainTrackIndex + 1,
+ insertPosition: "below",
+ };
+ }
+
+ return {
+ insertIndex:
+ direction === "above" ? safePreferredIndex : safePreferredIndex + 1,
+ insertPosition: direction,
+ };
+ }
+
+ const insertIndex =
+ direction === "above" ? safePreferredIndex : safePreferredIndex + 1;
+ if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) {
+ return {
+ insertIndex: mainTrackIndex,
+ insertPosition: "above",
+ };
+ }
+
+ return {
+ insertIndex,
+ insertPosition: direction,
+ };
+}
diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts
new file mode 100644
index 00000000..ab2ab7bd
--- /dev/null
+++ b/apps/web/src/timeline/placement/main-track.ts
@@ -0,0 +1,54 @@
+import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline";
+
+export const MAIN_TRACK_NAME = "Main Track";
+
+export function getEarliestMainTrackElement({
+ mainTrack,
+ excludeElementId,
+}: {
+ mainTrack: VideoTrack;
+ excludeElementId?: string;
+}): TimelineElement | null {
+ const elements = mainTrack.elements.filter((element) => {
+ return !excludeElementId || element.id !== excludeElementId;
+ });
+ if (elements.length === 0) {
+ return null;
+ }
+
+ return elements.reduce((earliestElement, element) => {
+ return element.startTime < earliestElement.startTime
+ ? element
+ : earliestElement;
+ });
+}
+
+export function enforceMainTrackStart({
+ tracks,
+ targetTrackId,
+ requestedStartTime,
+ excludeElementId,
+}: {
+ tracks: SceneTracks;
+ targetTrackId: string;
+ requestedStartTime: number;
+ excludeElementId?: string;
+}): number {
+ if (tracks.main.id !== targetTrackId) {
+ return requestedStartTime;
+ }
+
+ const earliestElement = getEarliestMainTrackElement({
+ mainTrack: tracks.main,
+ excludeElementId,
+ });
+ if (!earliestElement) {
+ return 0;
+ }
+
+ if (requestedStartTime <= earliestElement.startTime) {
+ return 0;
+ }
+
+ return requestedStartTime;
+}
diff --git a/apps/web/src/timeline/placement/overlap.ts b/apps/web/src/timeline/placement/overlap.ts
new file mode 100644
index 00000000..daf3e627
--- /dev/null
+++ b/apps/web/src/timeline/placement/overlap.ts
@@ -0,0 +1,44 @@
+import type { TimelineElement } from "@/timeline";
+import type { PlacementTimeSpan } from "./types";
+
+interface TrackWithElements {
+ elements: TimelineElement[];
+}
+
+function wouldElementOverlap({
+ elements,
+ startTime,
+ endTime,
+ excludeElementId,
+}: {
+ elements: TimelineElement[];
+ startTime: number;
+ endTime: number;
+ excludeElementId?: string;
+}): boolean {
+ return elements.some((element) => {
+ if (excludeElementId && element.id === excludeElementId) {
+ return false;
+ }
+
+ const elementEnd = element.startTime + element.duration;
+ return startTime < elementEnd && endTime > element.startTime;
+ });
+}
+
+export function canPlaceTimeSpansOnTrack({
+ track,
+ timeSpans,
+}: {
+ track: TrackWithElements;
+ timeSpans: PlacementTimeSpan[];
+}): boolean {
+ return timeSpans.every(({ startTime, duration, excludeElementId }) => {
+ return !wouldElementOverlap({
+ elements: track.elements,
+ startTime,
+ endTime: startTime + duration,
+ excludeElementId,
+ });
+ });
+}
diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts
new file mode 100644
index 00000000..26275392
--- /dev/null
+++ b/apps/web/src/timeline/placement/resolve.ts
@@ -0,0 +1,277 @@
+import type { SceneTracks, TrackType, TimelineTrack } from "@/timeline";
+import {
+ getDefaultInsertIndexForTrack,
+ getHighestInsertIndexForTrack,
+ resolvePreferredNewTrackPlacement,
+} from "./insert-index";
+import { getTrackTypeForElementType } from "./compatibility";
+import { enforceMainTrackStart } from "./main-track";
+import { canPlaceTimeSpansOnTrack } from "./overlap";
+import type {
+ PlacementResult,
+ PlacementStrategy,
+ PlacementSubject,
+ PlacementTimeSpan,
+} from "./types";
+
+type ResolveTrackPlacementParams = PlacementSubject & {
+ tracks: SceneTracks;
+ timeSpans: PlacementTimeSpan[];
+ strategy: PlacementStrategy;
+};
+
+function buildExistingTrackResult({
+ track,
+ trackIndex,
+ tracks,
+ timeSpans,
+}: {
+ track: TimelineTrack;
+ trackIndex: number;
+ tracks: SceneTracks;
+ timeSpans: PlacementTimeSpan[];
+}): PlacementResult {
+ const firstSpan = timeSpans[0];
+ const requestedStartTime = firstSpan?.startTime ?? 0;
+ const adjustedStartTime = enforceMainTrackStart({
+ tracks,
+ targetTrackId: track.id,
+ requestedStartTime,
+ excludeElementId: firstSpan?.excludeElementId,
+ });
+ return {
+ kind: "existingTrack",
+ trackId: track.id,
+ trackIndex,
+ trackType: track.type,
+ ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}),
+ };
+}
+
+function buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition,
+}: {
+ trackType: TrackType;
+ insertIndex: number;
+ insertPosition: "above" | "below" | null;
+}): PlacementResult {
+ return {
+ kind: "newTrack",
+ trackType,
+ insertIndex,
+ insertPosition,
+ };
+}
+
+function findFirstAvailableTrackIndex({
+ tracks,
+ trackType,
+ timeSpans,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+ timeSpans: PlacementTimeSpan[];
+}): number {
+ return tracks.findIndex((track) => {
+ return (
+ track.type === trackType &&
+ canPlaceTimeSpansOnTrack({
+ track,
+ timeSpans,
+ })
+ );
+ });
+}
+
+function resolveAlwaysNewTrack({
+ tracks,
+ trackType,
+ position,
+}: {
+ tracks: SceneTracks;
+ trackType: TrackType;
+ position: "highest" | "default";
+}): PlacementResult {
+ const insertIndex =
+ position === "highest"
+ ? getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+ })
+ : getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+ });
+
+ return buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition: null,
+ });
+}
+
+function getInsertDirection({
+ hoverDirection,
+ verticalDragDirection,
+}: {
+ hoverDirection: "above" | "below";
+ verticalDragDirection?: "up" | "down" | null;
+}): "above" | "below" {
+ if (verticalDragDirection === "up") {
+ return "above";
+ }
+
+ if (verticalDragDirection === "down") {
+ return "below";
+ }
+
+ return hoverDirection;
+}
+
+export function resolveTrackPlacement({
+ tracks,
+ ...placement
+}: ResolveTrackPlacementParams): PlacementResult | null {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ const trackType =
+ "trackType" in placement
+ ? placement.trackType
+ : getTrackTypeForElementType({
+ elementType: placement.elementType,
+ });
+ const { timeSpans, strategy } = placement;
+
+ if (strategy.type === "explicit") {
+ const trackIndex = orderedTracks.findIndex(
+ (track) => track.id === strategy.trackId,
+ );
+ if (trackIndex < 0) {
+ return null;
+ }
+
+ const track = orderedTracks[trackIndex];
+ if (track.type !== trackType) {
+ return null;
+ }
+
+ return buildExistingTrackResult({
+ track,
+ trackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ if (strategy.type === "firstAvailable") {
+ const existingTrackIndex = findFirstAvailableTrackIndex({
+ tracks: orderedTracks,
+ trackType,
+ timeSpans,
+ });
+ if (existingTrackIndex >= 0) {
+ return buildExistingTrackResult({
+ track: orderedTracks[existingTrackIndex],
+ trackIndex: existingTrackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ return resolveAlwaysNewTrack({
+ tracks,
+ trackType,
+ position: "highest",
+ });
+ }
+
+ if (strategy.type === "preferIndex") {
+ const preferredTrack = orderedTracks[strategy.trackIndex];
+ const isPreferredTrackCompatible =
+ !!preferredTrack && preferredTrack.type === trackType;
+ const canUseExistingTrack =
+ !strategy.createNewTrackOnly &&
+ isPreferredTrackCompatible &&
+ canPlaceTimeSpansOnTrack({
+ track: preferredTrack,
+ timeSpans,
+ });
+ if (canUseExistingTrack) {
+ return buildExistingTrackResult({
+ track: preferredTrack,
+ trackIndex: strategy.trackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
+ tracks,
+ trackType,
+ preferredIndex: strategy.trackIndex,
+ direction: getInsertDirection({
+ hoverDirection: strategy.hoverDirection,
+ verticalDragDirection: !isPreferredTrackCompatible
+ ? strategy.verticalDragDirection
+ : null,
+ }),
+ });
+ return buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition,
+ });
+ }
+
+ if (strategy.type === "aboveSource") {
+ const aboveTrackIndex = strategy.sourceTrackIndex - 1;
+ const aboveTrack = orderedTracks[aboveTrackIndex];
+ if (
+ aboveTrack &&
+ aboveTrack.type === trackType &&
+ canPlaceTimeSpansOnTrack({
+ track: aboveTrack,
+ timeSpans,
+ })
+ ) {
+ return buildExistingTrackResult({
+ track: aboveTrack,
+ trackIndex: aboveTrackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
+ tracks: orderedTracks,
+ trackType,
+ timeSpans,
+ });
+ if (firstAvailableTrackIndex >= 0) {
+ return buildExistingTrackResult({
+ track: orderedTracks[firstAvailableTrackIndex],
+ trackIndex: firstAvailableTrackIndex,
+ tracks,
+ timeSpans,
+ });
+ }
+
+ const insertIndex = getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+ });
+
+ return buildNewTrackResult({
+ trackType,
+ insertIndex,
+ insertPosition: null,
+ });
+ }
+
+ return resolveAlwaysNewTrack({
+ tracks,
+ trackType,
+ position: strategy.position,
+ });
+}
diff --git a/apps/web/src/timeline/placement/track-factory.ts b/apps/web/src/timeline/placement/track-factory.ts
new file mode 100644
index 00000000..9c69ea8c
--- /dev/null
+++ b/apps/web/src/timeline/placement/track-factory.ts
@@ -0,0 +1,123 @@
+import { DEFAULT_TRACK_NAMES } from "@/timeline/tracks";
+import type {
+ AudioTrack,
+ EffectTrack,
+ GraphicTrack,
+ TextTrack,
+ TrackType,
+ TimelineTrack,
+ VideoTrack,
+} from "@/timeline";
+
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: "video";
+ name?: string;
+}): VideoTrack;
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: "text";
+ name?: string;
+}): TextTrack;
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: "audio";
+ name?: string;
+}): AudioTrack;
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: "graphic";
+ name?: string;
+}): GraphicTrack;
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: "effect";
+ name?: string;
+}): EffectTrack;
+
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: TrackType;
+ name?: string;
+}): TimelineTrack;
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: TrackType;
+ name?: string;
+}): TimelineTrack {
+ const trackName = name ?? DEFAULT_TRACK_NAMES[type];
+
+ switch (type) {
+ case "video":
+ return {
+ id,
+ name: trackName,
+ type: "video",
+ elements: [],
+ hidden: false,
+ muted: false,
+ };
+ case "text":
+ return {
+ id,
+ name: trackName,
+ type: "text",
+ elements: [],
+ hidden: false,
+ };
+ case "graphic":
+ return {
+ id,
+ name: trackName,
+ type: "graphic",
+ elements: [],
+ hidden: false,
+ };
+ case "audio":
+ return {
+ id,
+ name: trackName,
+ type: "audio",
+ elements: [],
+ muted: false,
+ };
+ case "effect":
+ return {
+ id,
+ name: trackName,
+ type: "effect",
+ elements: [],
+ hidden: false,
+ };
+ default:
+ throw new Error(`Unsupported track type: ${type}`);
+ }
+}
diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts
new file mode 100644
index 00000000..6847ab36
--- /dev/null
+++ b/apps/web/src/timeline/placement/types.ts
@@ -0,0 +1,39 @@
+import type { ElementType, TrackType } from "@/timeline";
+
+export interface PlacementTimeSpan {
+ startTime: number;
+ duration: number;
+ excludeElementId?: string;
+}
+
+export type PlacementSubject =
+ | { elementType: ElementType }
+ | { trackType: TrackType };
+
+export type PlacementStrategy =
+ | { type: "explicit"; trackId: string }
+ | { type: "firstAvailable" }
+ | {
+ type: "preferIndex";
+ trackIndex: number;
+ hoverDirection: "above" | "below";
+ verticalDragDirection?: "up" | "down" | null;
+ createNewTrackOnly?: boolean;
+ }
+ | { type: "aboveSource"; sourceTrackIndex: number }
+ | { type: "alwaysNew"; position: "highest" | "default" };
+
+export type PlacementResult =
+ | {
+ kind: "existingTrack";
+ trackId: string;
+ trackIndex: number;
+ trackType: TrackType;
+ adjustedStartTime?: number;
+ }
+ | {
+ kind: "newTrack";
+ insertIndex: number;
+ insertPosition: "above" | "below" | null;
+ trackType: TrackType;
+ };
diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts
new file mode 100644
index 00000000..88e21eba
--- /dev/null
+++ b/apps/web/src/timeline/playhead-snap-source.ts
@@ -0,0 +1,9 @@
+import type { SnapPoint } from "@/timeline/snapping";
+
+export function getPlayheadSnapPoints({
+ playheadTime,
+}: {
+ playheadTime: number;
+}): SnapPoint[] {
+ return [{ time: playheadTime, type: "playhead" }];
+}
diff --git a/apps/web/src/timeline/ruler-utils.ts b/apps/web/src/timeline/ruler-utils.ts
new file mode 100644
index 00000000..d355ef31
--- /dev/null
+++ b/apps/web/src/timeline/ruler-utils.ts
@@ -0,0 +1,257 @@
+import type { FrameRate } from "opencut-wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { frameRateToFloat } from "@/fps/utils";
+
+/**
+ * frame intervals for labels - starts at 2 so there's always at least
+ * one tick between labels even at max zoom.
+ * pattern: 2, 3, 5, 10, 15 (matches CapCut)
+ */
+const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const;
+
+/**
+ * frame intervals for ticks - can go down to 1 for max granularity.
+ */
+const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const;
+
+/**
+ * second intervals for when we're zoomed out past frame-level detail.
+ */
+const SECOND_MULTIPLIERS = [
+ 1, 2, 3, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
+] as const;
+
+/**
+ * minimum pixel spacing between labels to keep them readable
+ */
+const MIN_LABEL_SPACING_PX = 120;
+
+/**
+ * minimum pixel spacing between ticks. much denser than labels.
+ */
+const MIN_TICK_SPACING_PX = 18;
+
+export interface RulerConfig {
+ /** time interval in seconds between each label */
+ labelIntervalSeconds: number;
+ /** time interval in seconds between each tick */
+ tickIntervalSeconds: number;
+}
+
+/**
+ * determines the optimal label and tick intervals based on zoom level and FPS.
+ *
+ * labels and ticks scale independently:
+ * - labels need wide spacing (~50px) to stay readable
+ * - ticks can be denser (~8px) to show finer subdivisions
+ *
+ * example at different zoom levels:
+ * - very zoomed in: labels every 2f, ticks every 1f
+ * - zoomed in: labels every 10f, ticks every 1f
+ * - zoomed out: labels every 15f, ticks every 3f
+ * - very zoomed out: labels every 1s, ticks every 5f
+ */
+export function getRulerConfig({
+ zoomLevel,
+ fps,
+}: {
+ zoomLevel: number;
+ fps: FrameRate;
+}): RulerConfig {
+ const fpsFloat = frameRateToFloat(fps);
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+ const pixelsPerFrame = pixelsPerSecond / fpsFloat;
+
+ const labelIntervalSeconds = findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps: fpsFloat,
+ minSpacingPx: MIN_LABEL_SPACING_PX,
+ frameIntervals: LABEL_FRAME_INTERVALS,
+ });
+
+ const rawTickIntervalSeconds = findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps: fpsFloat,
+ minSpacingPx: MIN_TICK_SPACING_PX,
+ frameIntervals: TICK_FRAME_INTERVALS,
+ });
+
+ // ensure tick interval divides evenly into label interval so labels always land on ticks
+ const tickIntervalSeconds = ensureTickDividesLabel({
+ tickIntervalSeconds: rawTickIntervalSeconds,
+ labelIntervalSeconds,
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps: fpsFloat,
+ });
+
+ return { labelIntervalSeconds, tickIntervalSeconds };
+}
+
+/**
+ * adjusts tick interval to ensure it divides evenly into the label interval.
+ * this guarantees labels always land on tick positions.
+ */
+function ensureTickDividesLabel({
+ tickIntervalSeconds,
+ labelIntervalSeconds,
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps,
+}: {
+ tickIntervalSeconds: number;
+ labelIntervalSeconds: number;
+ pixelsPerFrame: number;
+ pixelsPerSecond: number;
+ fps: number;
+}): number {
+ const labelFrames = Math.round(labelIntervalSeconds * fps);
+ const tickFrames = Math.round(tickIntervalSeconds * fps);
+
+ // if tick already divides label evenly, we're good
+ if (labelFrames % tickFrames === 0) {
+ return tickIntervalSeconds;
+ }
+
+ // find the smallest tick interval that divides the label interval and has adequate spacing
+ for (const candidateFrames of TICK_FRAME_INTERVALS) {
+ if (labelFrames % candidateFrames === 0) {
+ const candidateSpacing = pixelsPerFrame * candidateFrames;
+ // accept if spacing meets minimum threshold
+ if (candidateSpacing >= MIN_TICK_SPACING_PX) {
+ return candidateFrames / fps;
+ }
+ }
+ }
+
+ // try second-level tick intervals that divide the label interval cleanly
+ for (const candidateSeconds of SECOND_MULTIPLIERS) {
+ const ratio = labelIntervalSeconds / candidateSeconds;
+ const isDivisor = Math.abs(ratio - Math.round(ratio)) < 0.0001;
+ if (isDivisor) {
+ const candidateSpacing = pixelsPerSecond * candidateSeconds;
+ if (candidateSpacing >= MIN_TICK_SPACING_PX) {
+ return candidateSeconds;
+ }
+ }
+ }
+
+ // fallback: use the label interval itself (no intermediate ticks)
+ return labelIntervalSeconds;
+}
+
+function findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps,
+ minSpacingPx,
+ frameIntervals,
+}: {
+ pixelsPerFrame: number;
+ pixelsPerSecond: number;
+ fps: number;
+ minSpacingPx: number;
+ frameIntervals: readonly number[];
+}): number {
+ // try frame-level intervals first
+ for (const frameInterval of frameIntervals) {
+ const pixelSpacing = pixelsPerFrame * frameInterval;
+ if (pixelSpacing >= minSpacingPx) {
+ return frameInterval / fps;
+ }
+ }
+
+ // then try second-level intervals
+ for (const secondMultiplier of SECOND_MULTIPLIERS) {
+ const pixelSpacing = pixelsPerSecond * secondMultiplier;
+ if (pixelSpacing >= minSpacingPx) {
+ return secondMultiplier;
+ }
+ }
+
+ return 60;
+}
+
+/**
+ * checks if a time should have a label based on the label interval.
+ */
+export function shouldShowLabel({
+ time,
+ labelIntervalSeconds,
+}: {
+ time: number;
+ labelIntervalSeconds: number;
+}): boolean {
+ const epsilon = 0.0001;
+ const remainder = time % labelIntervalSeconds;
+ return remainder < epsilon || remainder > labelIntervalSeconds - epsilon;
+}
+
+/**
+ * formats a ruler tick label.
+ *
+ * - on second boundaries: "MM:SS" (e.g., "00:00", "01:30")
+ * - between seconds: "Xf" (e.g., "5f", "15f")
+ */
+export function formatRulerLabel({
+ timeInSeconds,
+ fps,
+}: {
+ timeInSeconds: number;
+ fps: FrameRate;
+}): string {
+ if (isSecondBoundary({ timeInSeconds })) {
+ return formatTimestamp({ timeInSeconds });
+ }
+
+ const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) });
+ return `${frameWithinSecond}f`;
+}
+
+/**
+ * checks if a time falls exactly on a second boundary.
+ */
+function isSecondBoundary({
+ timeInSeconds,
+}: {
+ timeInSeconds: number;
+}): boolean {
+ const epsilon = 0.0001;
+ const remainder = timeInSeconds % 1;
+ return remainder < epsilon || remainder > 1 - epsilon;
+}
+
+/**
+ * gets the frame number within the current second.
+ */
+function getFrameWithinSecond({
+ timeInSeconds,
+ fps,
+}: {
+ timeInSeconds: number;
+ fps: number;
+}): number {
+ const fractionalPart = timeInSeconds % 1;
+ return Math.round(fractionalPart * fps);
+}
+
+/**
+ * formats a timestamp as MM:SS or H:MM:SS when >= 1 hour.
+ */
+function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
+ const totalSeconds = Math.round(timeInSeconds);
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ const mm = minutes.toString().padStart(2, "0");
+ const ss = seconds.toString().padStart(2, "0");
+
+ if (hours > 0) {
+ return `${hours}:${mm}:${ss}`;
+ }
+
+ return `${mm}:${ss}`;
+}
diff --git a/apps/web/src/timeline/scale.ts b/apps/web/src/timeline/scale.ts
new file mode 100644
index 00000000..7770f59f
--- /dev/null
+++ b/apps/web/src/timeline/scale.ts
@@ -0,0 +1,3 @@
+export const BASE_TIMELINE_PIXELS_PER_SECOND = 50;
+export const TIMELINE_ZOOM_MIN = 0.1;
+export const TIMELINE_ZOOM_MAX = 100;
diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts
new file mode 100644
index 00000000..d48c943c
--- /dev/null
+++ b/apps/web/src/timeline/scenes.ts
@@ -0,0 +1,113 @@
+import type { TScene } from "@/timeline";
+import { generateUUID } from "@/utils/id";
+import { calculateTotalDuration } from "@/timeline";
+import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
+
+export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
+ return scenes.find((scene) => scene.isMain) || null;
+}
+
+export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] {
+ const hasMain = scenes.some((scene) => scene.isMain);
+ if (!hasMain) {
+ const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
+ return [mainScene, ...scenes];
+ }
+ return scenes;
+}
+
+export function buildDefaultScene({
+ name,
+ isMain,
+}: {
+ name: string;
+ isMain: boolean;
+}): TScene {
+ return {
+ id: generateUUID(),
+ name,
+ isMain,
+ tracks: {
+ overlay: [],
+ main: {
+ id: generateUUID(),
+ name: MAIN_TRACK_NAME,
+ type: "video",
+ elements: [],
+ muted: false,
+ hidden: false,
+ },
+ audio: [],
+ },
+ bookmarks: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+export function canDeleteScene({ scene }: { scene: TScene }): {
+ canDelete: boolean;
+ reason?: string;
+} {
+ if (scene.isMain) {
+ return { canDelete: false, reason: "Cannot delete main scene" };
+ }
+ return { canDelete: true };
+}
+
+export function getFallbackSceneAfterDelete({
+ scenes,
+ deletedSceneId,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ deletedSceneId: string;
+ currentSceneId: string | null;
+}): TScene | null {
+ if (currentSceneId !== deletedSceneId) {
+ return scenes.find((s) => s.id === currentSceneId) || null;
+ }
+ return getMainScene({ scenes });
+}
+
+export function findCurrentScene({
+ scenes,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ currentSceneId: string;
+}): TScene | null {
+ return (
+ scenes.find((s) => s.id === currentSceneId) ||
+ getMainScene({ scenes }) ||
+ scenes[0] ||
+ null
+ );
+}
+
+export function getProjectDurationFromScenes({
+ scenes,
+}: {
+ scenes: TScene[];
+}): number {
+ const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
+ if (!mainScene?.tracks) {
+ return 0;
+ }
+
+ return calculateTotalDuration({ tracks: mainScene.tracks });
+}
+
+export function updateSceneInArray({
+ scenes,
+ sceneId,
+ updates,
+}: {
+ scenes: TScene[];
+ sceneId: string;
+ updates: Partial<TScene>;
+}): TScene[] {
+ return scenes.map((scene) =>
+ scene.id === sceneId ? { ...scene, ...updates } : scene,
+ );
+}
diff --git a/apps/web/src/timeline/snapping/build.ts b/apps/web/src/timeline/snapping/build.ts
new file mode 100644
index 00000000..87d3db2e
--- /dev/null
+++ b/apps/web/src/timeline/snapping/build.ts
@@ -0,0 +1,17 @@
+import type { SnapPoint, TimelineSnapPointSource } from "./types";
+
+export function buildTimelineSnapPoints({
+ sources,
+}: {
+ sources: TimelineSnapPointSource[];
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+
+ for (const source of sources) {
+ for (const snapPoint of source()) {
+ snapPoints.push(snapPoint);
+ }
+ }
+
+ return snapPoints;
+}
diff --git a/apps/web/src/timeline/snapping/index.ts b/apps/web/src/timeline/snapping/index.ts
new file mode 100644
index 00000000..73f49272
--- /dev/null
+++ b/apps/web/src/timeline/snapping/index.ts
@@ -0,0 +1,9 @@
+export { buildTimelineSnapPoints } from "./build";
+export { resolveTimelineSnap } from "./resolve";
+export { getTimelineSnapThresholdInTicks } from "./threshold";
+export type {
+ SnapPoint,
+ SnapPointType,
+ SnapResult,
+ TimelineSnapPointSource,
+} from "./types";
diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts
new file mode 100644
index 00000000..2f00d081
--- /dev/null
+++ b/apps/web/src/timeline/snapping/resolve.ts
@@ -0,0 +1,28 @@
+import type { SnapPoint, SnapResult } from "./types";
+
+export function resolveTimelineSnap({
+ targetTime,
+ snapPoints,
+ maxSnapDistance,
+}: {
+ targetTime: number;
+ snapPoints: SnapPoint[];
+ maxSnapDistance: number;
+}): SnapResult {
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ for (const snapPoint of snapPoints) {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance <= maxSnapDistance && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ }
+
+ return {
+ snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+}
diff --git a/apps/web/src/timeline/snapping/threshold.ts b/apps/web/src/timeline/snapping/threshold.ts
new file mode 100644
index 00000000..f03111dc
--- /dev/null
+++ b/apps/web/src/timeline/snapping/threshold.ts
@@ -0,0 +1,15 @@
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+
+const DEFAULT_TIMELINE_SNAP_THRESHOLD_PX = 10;
+
+export function getTimelineSnapThresholdInTicks({
+ zoomLevel,
+ snapThresholdPx = DEFAULT_TIMELINE_SNAP_THRESHOLD_PX,
+}: {
+ zoomLevel: number;
+ snapThresholdPx?: number;
+}): number {
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+ return (snapThresholdPx / pixelsPerSecond) * TICKS_PER_SECOND;
+}
diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts
new file mode 100644
index 00000000..2a6d0903
--- /dev/null
+++ b/apps/web/src/timeline/snapping/types.ts
@@ -0,0 +1,21 @@
+export type SnapPointType =
+ | "element-start"
+ | "element-end"
+ | "playhead"
+ | "bookmark"
+ | "keyframe";
+
+export interface SnapPoint {
+ time: number;
+ type: SnapPointType;
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+export type TimelineSnapPointSource = () => Iterable<SnapPoint>;
diff --git a/apps/web/src/timeline/timeline-store.ts b/apps/web/src/timeline/timeline-store.ts
new file mode 100644
index 00000000..b7ddb885
--- /dev/null
+++ b/apps/web/src/timeline/timeline-store.ts
@@ -0,0 +1,57 @@
+/**
+ * UI state for the timeline
+ * For core logic, use EditorCore instead.
+ */
+
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+interface TimelineStore {
+ snappingEnabled: boolean;
+ toggleSnapping: () => void;
+ rippleEditingEnabled: boolean;
+ toggleRippleEditing: () => void;
+ expandedElementIds: Set<string>;
+ toggleElementExpanded: (elementId: string) => void;
+}
+
+export const useTimelineStore = create<TimelineStore>()(
+ persist(
+ (set) => ({
+ snappingEnabled: true,
+
+ toggleSnapping: () => {
+ set((state) => ({ snappingEnabled: !state.snappingEnabled }));
+ },
+
+ rippleEditingEnabled: false,
+
+ toggleRippleEditing: () => {
+ set((state) => ({
+ rippleEditingEnabled: !state.rippleEditingEnabled,
+ }));
+ },
+
+ expandedElementIds: new Set<string>(),
+
+ toggleElementExpanded: (elementId) => {
+ set((state) => {
+ const next = new Set(state.expandedElementIds);
+ if (next.has(elementId)) {
+ next.delete(elementId);
+ } else {
+ next.add(elementId);
+ }
+ return { expandedElementIds: next };
+ });
+ },
+ }),
+ {
+ name: "timeline-store",
+ partialize: (state) => ({
+ snappingEnabled: state.snappingEnabled,
+ rippleEditingEnabled: state.rippleEditingEnabled,
+ }),
+ },
+ ),
+);
diff --git a/apps/web/src/timeline/track-capabilities.ts b/apps/web/src/timeline/track-capabilities.ts
new file mode 100644
index 00000000..e6ff3945
--- /dev/null
+++ b/apps/web/src/timeline/track-capabilities.ts
@@ -0,0 +1,20 @@
+import type {
+ TimelineTrack,
+ VideoTrack,
+ AudioTrack,
+ GraphicTrack,
+ TextTrack,
+ EffectTrack,
+} from "@/timeline";
+
+export function canTrackHaveAudio(
+ track: TimelineTrack,
+): track is VideoTrack | AudioTrack {
+ return track.type === "audio" || track.type === "video";
+}
+
+export function canTrackBeHidden(
+ track: TimelineTrack,
+): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
+ return track.type !== "audio";
+}
diff --git a/apps/web/src/timeline/track-element-update.ts b/apps/web/src/timeline/track-element-update.ts
new file mode 100644
index 00000000..7d86a4e8
--- /dev/null
+++ b/apps/web/src/timeline/track-element-update.ts
@@ -0,0 +1,147 @@
+import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
+
+export function findTrackInSceneTracks({
+ tracks,
+ trackId,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+}): TimelineTrack | null {
+ if (tracks.main.id === trackId) {
+ return tracks.main;
+ }
+
+ return (
+ tracks.overlay.find((track) => track.id === trackId) ??
+ tracks.audio.find((track) => track.id === trackId) ??
+ null
+ );
+}
+
+export function updateTrackInSceneTracks({
+ tracks,
+ trackId,
+ update,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
+}): SceneTracks {
+ if (tracks.main.id === trackId) {
+ return {
+ ...tracks,
+ main: update(tracks.main),
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
+ if (overlayTrackIndex >= 0) {
+ return {
+ ...tracks,
+ overlay: tracks.overlay.map((track, index) =>
+ index === overlayTrackIndex ? update(track) : track,
+ ),
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
+ if (audioTrackIndex >= 0) {
+ return {
+ ...tracks,
+ audio: tracks.audio.map((track, index) =>
+ index === audioTrackIndex ? update(track) : track,
+ ),
+ };
+ }
+
+ return tracks;
+}
+
+function updateElementInTrack<TTrack extends TimelineTrack>({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ track: TTrack;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): TTrack {
+ const nextElements = track.elements.map((element) => {
+ if (element.id !== elementId) {
+ return element;
+ }
+ if (elementPredicate && !elementPredicate(element)) {
+ return element;
+ }
+ return update(element);
+ });
+
+ return {
+ ...track,
+ elements: nextElements,
+ } as TTrack;
+}
+
+export function updateElementInSceneTracks({
+ tracks,
+ trackId,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): SceneTracks {
+ if (tracks.main.id === trackId) {
+ return {
+ ...tracks,
+ main: updateElementInTrack({
+ track: tracks.main,
+ elementId,
+ update,
+ elementPredicate,
+ }),
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
+ if (overlayTrackIndex >= 0) {
+ return {
+ ...tracks,
+ overlay: tracks.overlay.map((track, index) =>
+ index === overlayTrackIndex
+ ? updateElementInTrack({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+ })
+ : track,
+ ),
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
+ if (audioTrackIndex >= 0) {
+ return {
+ ...tracks,
+ audio: tracks.audio.map((track, index) =>
+ index === audioTrackIndex
+ ? updateElementInTrack({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+ })
+ : track,
+ ),
+ };
+ }
+
+ return tracks;
+}
diff --git a/apps/web/src/timeline/tracks.ts b/apps/web/src/timeline/tracks.ts
new file mode 100644
index 00000000..9ce5511d
--- /dev/null
+++ b/apps/web/src/timeline/tracks.ts
@@ -0,0 +1,9 @@
+import type { TrackType } from "@/timeline";
+
+export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = {
+ video: "Video track",
+ text: "Text track",
+ audio: "Audio track",
+ graphic: "Graphic track",
+ effect: "Effect track",
+} as const;
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts
new file mode 100644
index 00000000..cd543cdf
--- /dev/null
+++ b/apps/web/src/timeline/types.ts
@@ -0,0 +1,314 @@
+import type { ElementAnimations } from "@/animation/types";
+import type { Effect } from "@/effects/types";
+import type { Mask } from "@/masks/types";
+import type { ParamValues } from "@/params";
+import type { BlendMode, Transform } from "@/rendering";
+
+export type ElementRef = {
+ trackId: string;
+ elementId: string;
+};
+
+export interface Bookmark {
+ time: number;
+ note?: string;
+ color?: string;
+ duration?: number;
+}
+
+export interface TScene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ tracks: SceneTracks;
+ bookmarks: Bookmark[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
+
+interface BaseTrack {
+ id: string;
+ name: string;
+}
+
+export interface VideoTrack extends BaseTrack {
+ type: "video";
+ elements: (VideoElement | ImageElement)[];
+ muted: boolean;
+ hidden: boolean;
+}
+
+export interface TextTrack extends BaseTrack {
+ type: "text";
+ elements: TextElement[];
+ hidden: boolean;
+}
+
+export interface AudioTrack extends BaseTrack {
+ type: "audio";
+ elements: AudioElement[];
+ muted: boolean;
+}
+
+export interface GraphicTrack extends BaseTrack {
+ type: "graphic";
+ elements: (StickerElement | GraphicElement)[];
+ hidden: boolean;
+}
+
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | GraphicTrack
+ | EffectTrack;
+
+export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
+
+export interface SceneTracks {
+ overlay: OverlayTrack[];
+ main: VideoTrack;
+ audio: AudioTrack[];
+}
+
+export interface RetimeConfig {
+ rate: number;
+ maintainPitch?: boolean;
+}
+
+interface BaseAudioElement extends BaseTimelineElement {
+ type: "audio";
+ volume: number;
+ muted?: boolean;
+ buffer?: AudioBuffer;
+ retime?: RetimeConfig;
+}
+
+export interface UploadAudioElement extends BaseAudioElement {
+ sourceType: "upload";
+ mediaId: string;
+}
+
+export interface LibraryAudioElement extends BaseAudioElement {
+ sourceType: "library";
+ sourceUrl: string;
+}
+
+export type AudioElement = UploadAudioElement | LibraryAudioElement;
+
+interface BaseTimelineElement {
+ id: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+ sourceDuration?: number;
+ animations?: ElementAnimations;
+}
+
+export interface VideoElement extends BaseTimelineElement {
+ type: "video";
+ mediaId: string;
+ volume?: number;
+ muted?: boolean;
+ isSourceAudioEnabled?: boolean;
+ hidden?: boolean;
+ retime?: RetimeConfig;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface ImageElement extends BaseTimelineElement {
+ type: "image";
+ mediaId: string;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface TextBackground {
+ enabled: boolean;
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
+}
+
+export interface TextElement extends BaseTimelineElement {
+ type: "text";
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ background: TextBackground;
+ textAlign: "left" | "center" | "right";
+ fontWeight: "normal" | "bold";
+ fontStyle: "normal" | "italic";
+ textDecoration: "none" | "underline" | "line-through";
+ letterSpacing?: number;
+ lineHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface StickerElement extends BaseTimelineElement {
+ type: "sticker";
+ stickerId: string;
+ /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface GraphicElement extends BaseTimelineElement {
+ type: "graphic";
+ definitionId: string;
+ params: ParamValues;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: ParamValues;
+}
+
+export type ElementUpdatePatch =
+ | { transform: Transform }
+ | { opacity: number }
+ | { volume: number };
+
+export type TimelineElement =
+ | AudioElement
+ | VideoElement
+ | ImageElement
+ | TextElement
+ | StickerElement
+ | GraphicElement
+ | EffectElement;
+
+export type ElementType = TimelineElement["type"];
+
+function elementTypes<T extends ElementType[]>(...types: T): T {
+ return types;
+}
+
+export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
+
+export type MaskableElement = Extract<
+ TimelineElement,
+ { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
+
+export type RetimableElement = Extract<
+ TimelineElement,
+ { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const VISUAL_ELEMENT_TYPES = elementTypes(
+ "video",
+ "image",
+ "text",
+ "sticker",
+ "graphic",
+);
+
+export type VisualElement = Extract<
+ TimelineElement,
+ { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
+>;
+
+export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
+export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
+export type CreateAudioElement =
+ | CreateUploadAudioElement
+ | CreateLibraryAudioElement;
+export type CreateVideoElement = Omit<VideoElement, "id">;
+export type CreateImageElement = Omit<ImageElement, "id">;
+export type CreateTextElement = Omit<TextElement, "id">;
+export type CreateStickerElement = Omit<StickerElement, "id">;
+export type CreateGraphicElement = Omit<GraphicElement, "id">;
+export type CreateEffectElement = Omit<EffectElement, "id">;
+export type CreateTimelineElement =
+ | CreateAudioElement
+ | CreateVideoElement
+ | CreateImageElement
+ | CreateTextElement
+ | CreateStickerElement
+ | CreateGraphicElement
+ | CreateEffectElement;
+
+export interface ElementDragState {
+ isDragging: boolean;
+ elementId: string | null;
+ dragElementIds: string[];
+ dragTimeOffsets: Record<string, number>;
+ trackId: string | null;
+ startMouseX: number;
+ startMouseY: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ currentMouseY: number;
+}
+
+export interface DropTarget {
+ trackIndex: number;
+ isNewTrack: boolean;
+ insertPosition: "above" | "below" | null;
+ xPosition: number;
+ targetElement: { elementId: string; trackId: string } | null;
+}
+
+export interface ComputeDropTargetParams {
+ elementType: ElementType;
+ mouseX: number;
+ mouseY: number;
+ tracks: SceneTracks;
+ playheadTime: number;
+ isExternalDrop: boolean;
+ elementDuration: number;
+ pixelsPerSecond: number;
+ zoomLevel: number;
+ verticalDragDirection?: "up" | "down" | null;
+ startTimeOverride?: number;
+ excludeElementId?: string;
+ targetElementTypes?: string[];
+}
+
+export interface ClipboardItem {
+ trackId: string;
+ trackType: TrackType;
+ element: CreateTimelineElement;
+}
diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts
new file mode 100644
index 00000000..beacf897
--- /dev/null
+++ b/apps/web/src/timeline/update-pipeline.ts
@@ -0,0 +1,211 @@
+import { clampAnimationsToDuration } from "@/animation";
+import {
+ clampRetimeRate,
+ getSourceSpanAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/retime";
+import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline";
+import { isRetimableElement } from "@/timeline";
+
+type ElementUpdateField = keyof TimelineElement | string;
+
+export interface ElementUpdateContext {
+ tracks: SceneTracks;
+ trackId: string;
+}
+
+interface ElementUpdateRuleResult {
+ element: TimelineElement;
+ changedFields?: ElementUpdateField[];
+}
+
+interface ElementUpdateRuleParams {
+ element: TimelineElement;
+ originalElement: TimelineElement;
+ patch: Partial<TimelineElement>;
+ context: ElementUpdateContext;
+}
+
+interface ElementUpdateRule {
+ triggers: ElementUpdateField[];
+ apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult;
+}
+
+const deriveRules: ElementUpdateRule[] = [
+ {
+ triggers: ["retime"],
+ apply: ({ element, originalElement, patch }) => {
+ if (!("retime" in patch) || !isRetimableElement(element)) {
+ return { element };
+ }
+
+ const nextRetime = patch.retime
+ ? {
+ ...patch.retime,
+ rate: clampRetimeRate({ rate: patch.retime.rate }),
+ }
+ : undefined;
+
+ const sourceDuration = getSourceDuration({
+ trimStart: originalElement.trimStart,
+ trimEnd: originalElement.trimEnd,
+ duration: originalElement.duration,
+ sourceDuration: isRetimableElement(originalElement)
+ ? originalElement.sourceDuration
+ : undefined,
+ retime: isRetimableElement(originalElement)
+ ? originalElement.retime
+ : undefined,
+ });
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - element.trimStart - element.trimEnd,
+ );
+ const nextDuration = getTimelineDurationForSourceSpan({
+ sourceSpan: visibleSourceSpan,
+ retime: nextRetime,
+ });
+
+ return {
+ element: {
+ ...element,
+ retime: nextRetime,
+ duration: nextDuration,
+ },
+ changedFields: ["retime", "duration"],
+ };
+ },
+ },
+];
+
+const enforceRules: ElementUpdateRule[] = [
+ {
+ triggers: ["duration"],
+ apply: ({ element }) => ({
+ element: {
+ ...element,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: element.duration,
+ }),
+ },
+ }),
+ },
+ {
+ triggers: ["startTime"],
+ apply: ({ element, context }) => {
+ const requestedStartTime = Math.max(0, element.startTime);
+ if (context.trackId !== context.tracks.main.id) {
+ return {
+ element: {
+ ...element,
+ startTime: requestedStartTime,
+ },
+ };
+ }
+
+ const earliestElement = context.tracks.main.elements
+ .filter((candidate) => candidate.id !== element.id)
+ .reduce<TimelineElement | null>((earliest, candidate) => {
+ if (!earliest || candidate.startTime < earliest.startTime) {
+ return candidate;
+ }
+ return earliest;
+ }, null);
+
+ return {
+ element: {
+ ...element,
+ startTime:
+ !earliestElement || requestedStartTime <= earliestElement.startTime
+ ? 0
+ : requestedStartTime,
+ },
+ };
+ },
+ },
+];
+
+export function applyElementUpdate({
+ element,
+ patch,
+ context,
+}: {
+ element: TimelineElement;
+ patch: Partial<TimelineElement>;
+ context: ElementUpdateContext;
+}): TimelineElement {
+ let nextElement = { ...element, ...patch } as TimelineElement;
+ const changedFields = new Set(
+ Object.keys(patch) as ElementUpdateField[],
+ );
+
+ for (const rule of deriveRules) {
+ if (!shouldApplyRule({ rule, changedFields })) {
+ continue;
+ }
+
+ const result = rule.apply({
+ element: nextElement,
+ originalElement: element,
+ patch,
+ context,
+ });
+ nextElement = result.element;
+ for (const field of result.changedFields ?? []) {
+ changedFields.add(field);
+ }
+ }
+
+ for (const rule of enforceRules) {
+ if (!shouldApplyRule({ rule, changedFields })) {
+ continue;
+ }
+
+ nextElement = rule.apply({
+ element: nextElement,
+ originalElement: element,
+ patch,
+ context,
+ }).element;
+ }
+
+ return nextElement;
+}
+
+function shouldApplyRule({
+ rule,
+ changedFields,
+}: {
+ rule: ElementUpdateRule;
+ changedFields: Set<ElementUpdateField>;
+}): boolean {
+ return rule.triggers.some((trigger) => changedFields.has(trigger));
+}
+
+function getSourceDuration({
+ trimStart,
+ trimEnd,
+ duration,
+ sourceDuration,
+ retime,
+}: {
+ trimStart: number;
+ trimEnd: number;
+ duration: number;
+ sourceDuration?: number;
+ retime?: RetimeConfig;
+}): number {
+ if (typeof sourceDuration === "number") {
+ return sourceDuration;
+ }
+
+ return (
+ trimStart +
+ getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime,
+ }) +
+ trimEnd
+ );
+}
diff --git a/apps/web/src/timeline/zoom-utils.ts b/apps/web/src/timeline/zoom-utils.ts
new file mode 100644
index 00000000..39090d92
--- /dev/null
+++ b/apps/web/src/timeline/zoom-utils.ts
@@ -0,0 +1,90 @@
+import {
+ BASE_TIMELINE_PIXELS_PER_SECOND,
+ TIMELINE_ZOOM_MAX,
+} from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+
+const PADDING_MAX_RATIO = 0.75;
+const PADDING_MIN_RATIO = 0.15;
+const PADDING_MIN_AT_ZOOM_PERCENT = 0.2;
+
+export function getTimelineZoomMin({
+ duration,
+ containerWidth,
+}: {
+ duration: number;
+ containerWidth: number | null | undefined;
+}): number {
+ const safeDurationSeconds = Math.max(duration / TICKS_PER_SECOND, 1);
+ const safeContainerWidth = containerWidth ?? 1000;
+ const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
+ const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
+ const zoomToFit =
+ availableWidth / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND);
+
+ return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
+}
+
+export function getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom,
+}: {
+ containerWidth: number;
+ zoomLevel: number;
+ minZoom: number;
+}): number {
+ const zoomPercent = getZoomPercent({ zoomLevel, minZoom });
+ const paddingTransitionPercent = Math.min(
+ zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT,
+ 1,
+ );
+ const paddingRatio =
+ PADDING_MAX_RATIO -
+ (PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent;
+
+ return containerWidth * paddingRatio;
+}
+
+export function getZoomPercent({
+ zoomLevel,
+ minZoom,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+}): number {
+ return (zoomLevel - minZoom) / (TIMELINE_ZOOM_MAX - minZoom);
+}
+
+/**
+ * convert linear slider position (0-1) to exponential zoom level.
+ * at low slider values, zoom changes are small. at high values, changes are large.
+ */
+export function sliderToZoom({
+ sliderPosition,
+ minZoom,
+ maxZoom = TIMELINE_ZOOM_MAX,
+}: {
+ sliderPosition: number;
+ minZoom: number;
+ maxZoom?: number;
+}): number {
+ const clampedPosition = Math.max(0, Math.min(1, sliderPosition));
+ return minZoom * (maxZoom / minZoom) ** clampedPosition;
+}
+
+/**
+ * convert exponential zoom level to linear slider position (0-1).
+ */
+export function zoomToSlider({
+ zoomLevel,
+ minZoom,
+ maxZoom = TIMELINE_ZOOM_MAX,
+}: {
+ zoomLevel: number;
+ minZoom: number;
+ maxZoom?: number;
+}): number {
+ const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel));
+ return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom);
+}