From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/timeline/audio-constants.ts | 2 + apps/web/src/timeline/audio-display.ts | 64 + apps/web/src/timeline/audio-separation/index.ts | 143 +++ apps/web/src/timeline/audio-state.ts | 116 ++ .../timeline/bookmarks/components/bookmarks.tsx | 425 +++++++ .../timeline/bookmarks/hooks/use-bookmark-drag.ts | 298 +++++ apps/web/src/timeline/bookmarks/index.ts | 19 + .../timeline/bookmarks/preview-overlay-source.tsx | 85 ++ apps/web/src/timeline/bookmarks/snap-source.ts | 20 + apps/web/src/timeline/bookmarks/utils.ts | 145 +++ apps/web/src/timeline/components/audio-tab.tsx | 151 +++ .../src/timeline/components/audio-volume-line.tsx | 275 +++++ .../web/src/timeline/components/audio-waveform.tsx | 345 ++++++ apps/web/src/timeline/components/drag-line.tsx | 29 + apps/web/src/timeline/components/drop-target.ts | 262 ++++ .../web/src/timeline/components/expanded-layout.ts | 117 ++ .../components/graph-editor/bezier-graph.tsx | 235 ++++ .../graph-editor/custom-presets-store.ts | 101 ++ .../components/graph-editor/easing-presets.ts | 19 + .../timeline/components/graph-editor/popover.tsx | 334 +++++ .../timeline/components/graph-editor/session.ts | 704 +++++++++++ .../components/graph-editor/use-controller.ts | 178 +++ apps/web/src/timeline/components/index.tsx | 946 ++++++++++++++ apps/web/src/timeline/components/interaction.ts | 4 + apps/web/src/timeline/components/layers.ts | 6 + apps/web/src/timeline/components/layout.ts | 20 + .../timeline/components/selection-hit-testing.ts | 135 ++ .../web/src/timeline/components/snap-indicator.tsx | 50 + apps/web/src/timeline/components/theme.ts | 31 + .../src/timeline/components/timeline-element.tsx | 1302 ++++++++++++++++++++ .../src/timeline/components/timeline-playhead.tsx | 117 ++ .../web/src/timeline/components/timeline-ruler.tsx | 146 +++ apps/web/src/timeline/components/timeline-tick.tsx | 42 + .../src/timeline/components/timeline-toolbar.tsx | 376 ++++++ .../web/src/timeline/components/timeline-track.tsx | 132 ++ apps/web/src/timeline/components/track-layout.ts | 60 + apps/web/src/timeline/creation.ts | 3 + apps/web/src/timeline/defaults.ts | 77 ++ apps/web/src/timeline/drag-data.ts | 48 + apps/web/src/timeline/drag-utils.ts | 21 + apps/web/src/timeline/drag.ts | 42 + apps/web/src/timeline/element-snap-source.ts | 38 + apps/web/src/timeline/element-utils.ts | 426 +++++++ apps/web/src/timeline/group-move/build-group.ts | 87 ++ apps/web/src/timeline/group-move/index.ts | 12 + apps/web/src/timeline/group-move/resolve-move.ts | 470 +++++++ apps/web/src/timeline/group-move/snap.ts | 85 ++ .../web/src/timeline/group-move/track-placement.ts | 83 ++ apps/web/src/timeline/group-move/types.ts | 36 + .../src/timeline/group-resize/compute-resize.ts | 249 ++++ apps/web/src/timeline/group-resize/index.ts | 9 + apps/web/src/timeline/group-resize/types.ts | 36 + .../hooks/element/use-element-interaction.ts | 760 ++++++++++++ .../hooks/element/use-element-selection.ts | 137 ++ .../hooks/element/use-keyframe-box-select.ts | 217 ++++ .../timeline/hooks/element/use-keyframe-drag.ts | 328 +++++ .../hooks/element/use-keyframe-selection.ts | 229 ++++ .../web/src/timeline/hooks/use-edge-auto-scroll.ts | 97 ++ apps/web/src/timeline/hooks/use-element-preview.ts | 39 + .../timeline/hooks/use-initial-scroll-bottom.ts | 42 + apps/web/src/timeline/hooks/use-scroll-position.ts | 52 + apps/web/src/timeline/hooks/use-scroll-sync.ts | 173 +++ .../timeline/hooks/use-snap-indicator-position.ts | 55 + .../src/timeline/hooks/use-timeline-drag-drop.ts | 648 ++++++++++ .../src/timeline/hooks/use-timeline-playhead.ts | 301 +++++ apps/web/src/timeline/hooks/use-timeline-resize.ts | 332 +++++ apps/web/src/timeline/hooks/use-timeline-seek.ts | 197 +++ apps/web/src/timeline/hooks/use-timeline-zoom.ts | 291 +++++ apps/web/src/timeline/index.ts | 29 + apps/web/src/timeline/pixel-utils.ts | 80 ++ .../timeline/placement/__tests__/resolve.test.ts | 661 ++++++++++ apps/web/src/timeline/placement/apply.ts | 158 +++ apps/web/src/timeline/placement/compatibility.ts | 51 + apps/web/src/timeline/placement/index.ts | 12 + apps/web/src/timeline/placement/insert-index.ts | 88 ++ apps/web/src/timeline/placement/main-track.ts | 54 + apps/web/src/timeline/placement/overlap.ts | 44 + apps/web/src/timeline/placement/resolve.ts | 277 +++++ apps/web/src/timeline/placement/track-factory.ts | 123 ++ apps/web/src/timeline/placement/types.ts | 39 + apps/web/src/timeline/playhead-snap-source.ts | 9 + apps/web/src/timeline/ruler-utils.ts | 257 ++++ apps/web/src/timeline/scale.ts | 3 + apps/web/src/timeline/scenes.ts | 113 ++ apps/web/src/timeline/snapping/build.ts | 17 + apps/web/src/timeline/snapping/index.ts | 9 + apps/web/src/timeline/snapping/resolve.ts | 28 + apps/web/src/timeline/snapping/threshold.ts | 15 + apps/web/src/timeline/snapping/types.ts | 21 + apps/web/src/timeline/timeline-store.ts | 57 + apps/web/src/timeline/track-capabilities.ts | 20 + apps/web/src/timeline/track-element-update.ts | 147 +++ apps/web/src/timeline/tracks.ts | 9 + apps/web/src/timeline/types.ts | 314 +++++ apps/web/src/timeline/update-pipeline.ts | 211 ++++ apps/web/src/timeline/zoom-utils.ts | 90 ++ 96 files changed, 15990 insertions(+) create mode 100644 apps/web/src/timeline/audio-constants.ts create mode 100644 apps/web/src/timeline/audio-display.ts create mode 100644 apps/web/src/timeline/audio-separation/index.ts create mode 100644 apps/web/src/timeline/audio-state.ts create mode 100644 apps/web/src/timeline/bookmarks/components/bookmarks.tsx create mode 100644 apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts create mode 100644 apps/web/src/timeline/bookmarks/index.ts create mode 100644 apps/web/src/timeline/bookmarks/preview-overlay-source.tsx create mode 100644 apps/web/src/timeline/bookmarks/snap-source.ts create mode 100644 apps/web/src/timeline/bookmarks/utils.ts create mode 100644 apps/web/src/timeline/components/audio-tab.tsx create mode 100644 apps/web/src/timeline/components/audio-volume-line.tsx create mode 100644 apps/web/src/timeline/components/audio-waveform.tsx create mode 100644 apps/web/src/timeline/components/drag-line.tsx create mode 100644 apps/web/src/timeline/components/drop-target.ts create mode 100644 apps/web/src/timeline/components/expanded-layout.ts create mode 100644 apps/web/src/timeline/components/graph-editor/bezier-graph.tsx create mode 100644 apps/web/src/timeline/components/graph-editor/custom-presets-store.ts create mode 100644 apps/web/src/timeline/components/graph-editor/easing-presets.ts create mode 100644 apps/web/src/timeline/components/graph-editor/popover.tsx create mode 100644 apps/web/src/timeline/components/graph-editor/session.ts create mode 100644 apps/web/src/timeline/components/graph-editor/use-controller.ts create mode 100644 apps/web/src/timeline/components/index.tsx create mode 100644 apps/web/src/timeline/components/interaction.ts create mode 100644 apps/web/src/timeline/components/layers.ts create mode 100644 apps/web/src/timeline/components/layout.ts create mode 100644 apps/web/src/timeline/components/selection-hit-testing.ts create mode 100644 apps/web/src/timeline/components/snap-indicator.tsx create mode 100644 apps/web/src/timeline/components/theme.ts create mode 100644 apps/web/src/timeline/components/timeline-element.tsx create mode 100644 apps/web/src/timeline/components/timeline-playhead.tsx create mode 100644 apps/web/src/timeline/components/timeline-ruler.tsx create mode 100644 apps/web/src/timeline/components/timeline-tick.tsx create mode 100644 apps/web/src/timeline/components/timeline-toolbar.tsx create mode 100644 apps/web/src/timeline/components/timeline-track.tsx create mode 100644 apps/web/src/timeline/components/track-layout.ts create mode 100644 apps/web/src/timeline/creation.ts create mode 100644 apps/web/src/timeline/defaults.ts create mode 100644 apps/web/src/timeline/drag-data.ts create mode 100644 apps/web/src/timeline/drag-utils.ts create mode 100644 apps/web/src/timeline/drag.ts create mode 100644 apps/web/src/timeline/element-snap-source.ts create mode 100644 apps/web/src/timeline/element-utils.ts create mode 100644 apps/web/src/timeline/group-move/build-group.ts create mode 100644 apps/web/src/timeline/group-move/index.ts create mode 100644 apps/web/src/timeline/group-move/resolve-move.ts create mode 100644 apps/web/src/timeline/group-move/snap.ts create mode 100644 apps/web/src/timeline/group-move/track-placement.ts create mode 100644 apps/web/src/timeline/group-move/types.ts create mode 100644 apps/web/src/timeline/group-resize/compute-resize.ts create mode 100644 apps/web/src/timeline/group-resize/index.ts create mode 100644 apps/web/src/timeline/group-resize/types.ts create mode 100644 apps/web/src/timeline/hooks/element/use-element-interaction.ts create mode 100644 apps/web/src/timeline/hooks/element/use-element-selection.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-drag.ts create mode 100644 apps/web/src/timeline/hooks/element/use-keyframe-selection.ts create mode 100644 apps/web/src/timeline/hooks/use-edge-auto-scroll.ts create mode 100644 apps/web/src/timeline/hooks/use-element-preview.ts create mode 100644 apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts create mode 100644 apps/web/src/timeline/hooks/use-scroll-position.ts create mode 100644 apps/web/src/timeline/hooks/use-scroll-sync.ts create mode 100644 apps/web/src/timeline/hooks/use-snap-indicator-position.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-drag-drop.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-playhead.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-resize.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-seek.ts create mode 100644 apps/web/src/timeline/hooks/use-timeline-zoom.ts create mode 100644 apps/web/src/timeline/index.ts create mode 100644 apps/web/src/timeline/pixel-utils.ts create mode 100644 apps/web/src/timeline/placement/__tests__/resolve.test.ts create mode 100644 apps/web/src/timeline/placement/apply.ts create mode 100644 apps/web/src/timeline/placement/compatibility.ts create mode 100644 apps/web/src/timeline/placement/index.ts create mode 100644 apps/web/src/timeline/placement/insert-index.ts create mode 100644 apps/web/src/timeline/placement/main-track.ts create mode 100644 apps/web/src/timeline/placement/overlap.ts create mode 100644 apps/web/src/timeline/placement/resolve.ts create mode 100644 apps/web/src/timeline/placement/track-factory.ts create mode 100644 apps/web/src/timeline/placement/types.ts create mode 100644 apps/web/src/timeline/playhead-snap-source.ts create mode 100644 apps/web/src/timeline/ruler-utils.ts create mode 100644 apps/web/src/timeline/scale.ts create mode 100644 apps/web/src/timeline/scenes.ts create mode 100644 apps/web/src/timeline/snapping/build.ts create mode 100644 apps/web/src/timeline/snapping/index.ts create mode 100644 apps/web/src/timeline/snapping/resolve.ts create mode 100644 apps/web/src/timeline/snapping/threshold.ts create mode 100644 apps/web/src/timeline/snapping/types.ts create mode 100644 apps/web/src/timeline/timeline-store.ts create mode 100644 apps/web/src/timeline/track-capabilities.ts create mode 100644 apps/web/src/timeline/track-element-update.ts create mode 100644 apps/web/src/timeline/tracks.ts create mode 100644 apps/web/src/timeline/types.ts create mode 100644 apps/web/src/timeline/update-pipeline.ts create mode 100644 apps/web/src/timeline/zoom-utils.ts (limited to 'apps/web/src/timeline') 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; + +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 ( +
+ +
+ ); +} + +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 ( + + + + + event.preventDefault()} + > + setIsPopoverOpen(false)} + /> + + + ); +} + +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 ( + <> +
+ + handleUpdate({ note: event.target.value })} + className="h-8 text-sm" + /> +
+
+ +
+ 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() && ( + + )} +
+
+
+ +
+ { + 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" + /> +
+
+ + + ); +} 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; + 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({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef(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 ( +
+ {bookmarks.map((bookmark) => ( +
+ {bookmark.note} +
+ ))} +
+ ); +} + +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: () => , + }, + ], + }; +} 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>; +}): 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 && ( +
+

Audio has been separated.

+ +
+ )} +
+ + Audio + + + + + } + > + } + 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" + /> + + + +
+ + ); +} 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(null); + const activePointerIdRef = useRef(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) => { + 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 ( +
+
+
+ {/* 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 */} +
+ {isDragging && + tooltipClientPos && + createPortal( +
+ {volumeLabel} +
, + document.body, + )} +
+
+ ); +} 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(null); + const containerRef = useRef(null); + const summaryRef = useRef(null); + const gainSamplesRef = useRef(gainSamples); + const pixelsPerSecondRef = useRef(pixelsPerSecond); + const clipDurationSecRef = useRef(clipDurationSec); + const retimeRef = useRef(retime); + const sourceStartSecRef = useRef(sourceStartSec); + const scrollParentRef = useRef(null); + const heightRef = useRef(0); + const lastRenderSignatureRef = useRef(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 ( +
+ +
+ ); +} 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 ( +
+ ); +} 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> = { + "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; +}): 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; +}): 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(null); + const [activeHandle, setActiveHandle] = useState(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) => { + event.preventDefault(); + event.stopPropagation(); + setActiveHandle(handle); + event.currentTarget.setPointerCapture(event.pointerId); + }; + } + + function onPointerMove({ event }: { event: PointerEvent }) { + 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 ( + onPointerMove({ event })} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + > + Bezier curve editor + + + + + + + + + + ); +} 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 ( + { + if (!nextOpen) { + onCancelPreview?.(); + } + onOpenChange?.(nextOpen); + }} + > + {children} + + {componentOptions.length > 1 && ( +
+
+ {componentOptions.map((component) => ( + + ))} +
+
+ )} + +
+ {value ? ( + + ) : ( + + )} +
+ + + + + Presets + + + Saved + + + + COLLAPSED_MAX} + onExpand={() => setIsExpanded(true)} + > + {BUILTIN_PRESETS.map((preset) => ( + onCommitValue?.(preset.value)} + /> + ))} + + + +
+ {custom.map((preset) => ( + onCommitValue?.(preset.value)} + onDelete={() => removePreset({ id: preset.id })} + /> + ))} + +
+
+
+
+
+ ); +} + +function GraphEditorEmptyState({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +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 ( +
+
+ {children} +
+ {!isExpanded && shouldExpand && ( +
+ +
+ )} +
+ ); +} + +function PresetItem({ + preset, + isActive, + onSelect, + onDelete, + disabled, +}: { + preset: EasingPreset; + isActive: boolean; + onSelect: () => void; + onDelete?: () => void; + disabled?: boolean; +}) { + return ( + + )} + + ); +} + +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 ( + + Curve preset preview + + + ); +} 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[] = []; + + 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( + (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( + null, + ); + const hasPreviewRef = useRef(false); + + const state = useMemo( + () => + 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 = { + video: , + text: ( + + ), + audio: ( + + ), + graphic: , + effect: ( + + ), +}; + +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( + () => + 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(null); + const timelineHeaderRef = useRef(null); + const rulerRef = useRef(null); + const rulerScrollRef = useRef(null); + const tracksContainerRef = useRef(null); + const tracksScrollRef = useRef(null); + const trackLabelsRef = useRef(null); + const playheadRef = useRef(null); + const trackLabelsScrollRef = useRef(null); + + const [currentSnapPoint, setCurrentSnapPoint] = useState( + 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 | 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 ( +
+ setZoomLevel(zoom)} + /> + +
+ + +
+ + + + +
+
+ + +
+
+ + { + syncFollowers(); + saveScrollPosition(); + }} + > +
+ {/* 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 */} +
{ + 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 && ( + { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackMouseUp={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + isDragOver={isDragOver} + dropTarget={dropTarget} + /> + )} +
+ { + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={handleTracksClick} + /> +
+
+ + +
+ +
+
+ ); +} + +function TrackLabelsPanel({ + trackLabelsRef, + trackLabelsScrollRef, + timelineHeaderHeight, + hasHorizontalScrollbar, + getTrackExpansionHeight, +}: { + trackLabelsRef: React.RefObject; + trackLabelsScrollRef: React.RefObject; + timelineHeaderHeight: number; + hasHorizontalScrollbar: boolean; + getTrackExpansionHeight: (trackIndex: number) => number; +}) { + const editor = useEditor(); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + 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 ( +
+
+
+
+ {tracks.length > 0 && ( +
+ {tracks.map((track, index) => { + const expandedRows = trackExpandedRowsMap[index]; + const baseHeight = getTrackHeight({ type: track.type }); + + return ( +
+
+ {canTrackHaveAudio(track) && ( + + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + +
+ {expandedRows.length > 0 && ( + + )} +
+ ); + })} +
+ )} +
+
+
+
+ ); +} + +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; + lastMouseXRef: React.RefObject; + 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( + () => + 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 }) => ( + + +
+ +
+
+ + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackMute({ trackId: track.id }); + }} + > + {canTrackHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackVisibility({ trackId: track.id }); + }} + > + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + + {track.id !== mainTrackId && ( + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.removeTrack({ trackId: track.id }); + }} + variant="destructive" + > + Delete track + + )} + +
+ ))} + + ); +} + +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
; +} + +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 ? ( + + ) : ( + + )} + + ); +} + +function PropertyTree({ rows }: { rows: ExpandedRow[] }) { + return ( +
+ {rows.map((row) => ( +
+ + {getPropertyLabel(row.propertyPath)} + +
+ ))} +
+ ); +} 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 = { + 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; + tracksScrollRef: React.RefObject; +} + +export function SnapIndicator({ + snapPoint, + zoomLevel, + isVisible, + timelineRef, + tracksScrollRef, +}: SnapIndicatorProps) { + const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + timelineRef, + tracksScrollRef, + }); + + if (!isVisible || !snapPoint) { + return null; + } + + return ( +
+
+
+ ); +} 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(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(); + 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 ? ( + + ) : null; + + return ( + + + +
0 + ? `${baseTrackHeight + expansionHeight}px` + : "100%", + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, + }} + > + 0} + baseTrackHeight={baseTrackHeight} + expandedContent={expandedContent} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + onResizeStart={onResizeStart} + isDropTarget={isDropTarget} + /> + {isSelected && ( +
+ +
+ )} +
+
+ + } + > + Split + + + {selectedElements.length === 1 && ( + } + > + Duplicate + + )} + {canElementHaveAudio(element) && hasAudio && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canToggleCurrentSourceAudio && ( + + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("toggle-source-audio"); + }} + > + {sourceAudioLabel} + + )} + {canElementBeHidden(element) && ( + 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {hasKeyframes && ( + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + toggleElementExpanded(element.id); + }} + > + {isExpanded ? "Collapse keyframes" : "Expand keyframes"} + + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + } + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } + > + Reveal media + + } + disabled + > + Replace media + + + )} + + 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + +
+
+ ); +} + +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 ( +
+
+
+ +
+
+ + {isSelected && ( + <> + + + + )} +
+ ); +} + +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 ( + + ); +} + +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 ( + + ); + }); +} + +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; + 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 +
+ {rows.map((row) => { + const laneKeyframes = keyframes.filter( + (kf) => kf.propertyPath === row.propertyPath, + ); + return ( +
+ {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 ( + + ); + })} +
+ ); + })} + {selectionBox && ( + + )} +
+ ); +} + +interface ElementContentProps { + element: TimelineElementType; + track: TimelineTrack; +} + +function TextElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.content} +
+ ); +} + +function EffectElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ + {element.name} +
+ ); +} + +function StickerElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} + +function GraphicElementContent({ + element, +}: { + element: Extract; +}) { + return ( +
+ {element.name} + {element.name} +
+ ); +} + +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 ( +
+ +
+ + +
+
+ ); + } + + return ( +
+
+ + {element.name} + +
+ +
+ ); +} + +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 ( + + ); +} + +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 ( + + {element.name} + + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; + + return ( + <> +
+ + ) : null + } + hasFade={true} + /> + + ); +} + +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( +
+ {leading &&
{leading}
} + {name && ( + + {name} + + )} +
+ ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return ; + case "effect": + return ; + case "sticker": + return ; + case "graphic": + return ; + case "audio": + return ; + case "video": + case "image": + return ; + } +} + +function CopyMenuItem() { + return ( + } + > + Copy + + ); +} + +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return ; + } + return isMuted ? ( + + ) : ( + + ); + }; + + return ( + + {isMuted ? "Unmute" : "Mute"} + + ); +} + +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; + + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return ; + } + return isHidden ? ( + + ) : ( + + ); + }; + + return ( + + {isHidden ? "Show" : "Hide"} + + ); +} + +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + } + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit, "onClick" | "textRight"> & { + action: TActionWithOptionalArgs; + children: ReactNode; +}) { + return ( + { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut({ action })} + {...props} + > + {children} + + ); +} 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; + rulerScrollRef: React.RefObject; + tracksScrollRef: React.RefObject; + timelineRef: React.RefObject; + playheadRef?: React.RefObject; + 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(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, + ) => { + 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 ( +
+
+ +
+ ); +} 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; + tracksScrollRef: React.RefObject; + 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 = []; + 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( + , + ); + } + + return ( +
{ + // 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={() => {}} + > +
+ {timelineTicks} +
+
+ ); +} 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 ( + + {label} + + ); + } + + return ( +
+ ); +} 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 ( + +
+ + + + + setZoomLevel({ zoom })} + onZoom={handleZoom} + /> +
+
+ ); +} + +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 ( +
+ + } + tooltip="Split element" + onClick={({ event }) => handleAction({ action: "split", event })} + /> + + } + tooltip="Split left" + onClick={({ event }) => handleAction({ action: "split-left", event })} + /> + + } + tooltip="Split right" + onClick={({ event }) => + handleAction({ action: "split-right", event }) + } + /> + + + } + tooltip={sourceAudioLabel} + disabled={!canToggleSelectedSourceAudio} + onClick={({ event }) => + handleAction({ action: "toggle-source-audio", event }) + } + /> + + } + tooltip="Duplicate element" + onClick={({ event }) => + handleAction({ action: "duplicate-selected", event }) + } + /> + + } + tooltip="Freeze frame (coming soon)" + disabled={true} + onClick={({ event: _event }) => {}} + /> + + } + tooltip="Delete element" + onClick={({ event }) => + handleAction({ action: "delete-selected", event }) + } + /> + +
+ + + } + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} + onClick={({ event }) => + handleAction({ action: "toggle-bookmark", event }) + } + /> + + + + } + tooltip={graphEditor.tooltip} + disabled={!graphEditor.canOpen} + buttonWrapper={(button) => + graphEditor.canOpen ? ( + {button} + ) : ( + button + ) + } + /> + + +
+ ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scenes.getActiveScene(); + + return ( +
+ + {currentScene?.name || "No Scene"} + + + {}}> + + + + +
+ ); +} + +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 ( +
+ + } + isActive={snappingEnabled} + tooltip="Auto snapping" + onClick={() => toggleSnapping()} + /> + + } + isActive={rippleEditingEnabled} + tooltip="Ripple editing" + onClick={() => toggleRippleEditing()} + /> + + +
+ +
+ + + onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom })) + } + min={0} + max={1} + step={0.005} + /> + +
+
+ ); +} + +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 = ( + + ); + const trigger = disabled ? ( + {button} + ) : buttonWrapper ? ( + buttonWrapper(button) + ) : ( + button + ); + + return ( + + {trigger} + {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; + tracksScrollRef: React.RefObject; + lastMouseXRef: React.RefObject; + 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 ( +
+