diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline')
96 files changed, 15990 insertions, 0 deletions
diff --git a/apps/web/src/timeline/audio-constants.ts b/apps/web/src/timeline/audio-constants.ts new file mode 100644 index 00000000..f5bb8a3e --- /dev/null +++ b/apps/web/src/timeline/audio-constants.ts @@ -0,0 +1,2 @@ +export const VOLUME_DB_MIN = -60;
+export const VOLUME_DB_MAX = 20;
diff --git a/apps/web/src/timeline/audio-display.ts b/apps/web/src/timeline/audio-display.ts new file mode 100644 index 00000000..9958c472 --- /dev/null +++ b/apps/web/src/timeline/audio-display.ts @@ -0,0 +1,64 @@ +import { clampDb } from "./audio-state"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; + +const SLIDER_CURVE_EXPONENT = 2; +const MIN_DISPLAY_DB = -40; +const WAVEFORM_BAR_EXPONENT = 1.5; +const MIN_LINEAR_GAIN = 10 ** (VOLUME_DB_MIN / 20); +const MAX_LINEAR_GAIN = 10 ** (VOLUME_DB_MAX / 20); +const LINEAR_GAIN_RANGE = MAX_LINEAR_GAIN - MIN_LINEAR_GAIN; + +function getNormalizedGainFromDb({ db }: { db: number }): number { + const clampedDb = clampDb(db); + const linearGain = 10 ** (clampedDb / 20); + return (linearGain - MIN_LINEAR_GAIN) / LINEAR_GAIN_RANGE; +} + +/** + * Maps the clip's volume setting to the line position. The curve is defined in + * linear gain space so dragging near 0 dB is precise while mute compresses into + * the bottom of the clip. + */ +export function getLinePosFromDb({ db }: { db: number }): number { + const normalizedGain = Math.max( + 0, + Math.min(1, getNormalizedGainFromDb({ db })), + ); + const progress = normalizedGain ** (1 / SLIDER_CURVE_EXPONENT); + return (1 - progress) * 100; +} + +/** + * Inverse of getLinePosFromDb. Converts a drag position back into the clip's + * volume setting without depending on the underlying audio content. + */ +export function getDbFromLinePos({ percent }: { percent: number }): number { + const clampedPercent = Math.max(0, Math.min(100, percent)); + const progress = 1 - clampedPercent / 100; + const normalizedGain = progress ** SLIDER_CURVE_EXPONENT; + const linearGain = MIN_LINEAR_GAIN + normalizedGain * LINEAR_GAIN_RANGE; + return clampDb(20 * Math.log10(linearGain)); +} + +/** + * Maps an output amplitude (raw sample amplitude × gain) to a visible waveform + * height fraction using a dB scale. Agnostic to whether the input is peak or + * RMS — the caller decides which measure feeds this function; the mapping + * curve is the same either way. + * + * The log scale keeps quiet content visible while reserving the top of the + * element for amplitudes that approach 0 dBFS. + */ +export function getBarFractionFromOutputAmplitude({ + outputAmplitude, +}: { + outputAmplitude: number; +}): number { + if (outputAmplitude <= 0) return 0; + const db = 20 * Math.log10(outputAmplitude); + if (db <= MIN_DISPLAY_DB) return 0; + return Math.min( + 1, + ((db - MIN_DISPLAY_DB) / -MIN_DISPLAY_DB) ** WAVEFORM_BAR_EXPONENT, + ); +} diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts new file mode 100644 index 00000000..beb488de --- /dev/null +++ b/apps/web/src/timeline/audio-separation/index.ts @@ -0,0 +1,143 @@ +import { cloneAnimations } from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; +import type { MediaAsset } from "@/media/types"; +import { DEFAULTS } from "@/timeline/defaults"; +import type { + CreateUploadAudioElement, + TimelineElement, + AudioElement, + VideoElement, +} from "../types"; + +type MediaAudioState = Pick<MediaAsset, "hasAudio">; + +export function isSourceAudioEnabled({ + element, +}: { + element: VideoElement; +}): boolean { + return element.isSourceAudioEnabled !== false; +} + +export function isSourceAudioSeparated({ + element, +}: { + element: VideoElement; +}): boolean { + return !isSourceAudioEnabled({ element }); +} + +export function canExtractSourceAudio( + element: TimelineElement, + mediaAsset: MediaAudioState | null | undefined, +): element is VideoElement { + return ( + element.type === "video" && + isSourceAudioEnabled({ element }) && + !!mediaAsset && + mediaAsset.hasAudio !== false + ); +} + +export function canRecoverSourceAudio( + element: TimelineElement, +): element is VideoElement { + return element.type === "video" && isSourceAudioSeparated({ element }); +} + +export function canToggleSourceAudio( + element: TimelineElement, + mediaAsset: MediaAudioState | null | undefined, +): element is VideoElement { + return ( + canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset) + ); +} + +export function doesElementHaveEnabledAudio({ + element, + mediaAsset, +}: { + element: AudioElement | VideoElement; + mediaAsset?: MediaAudioState | null; +}): boolean { + if (element.type === "audio") { + return true; + } + + return ( + !!mediaAsset && + mediaAsset.hasAudio !== false && + isSourceAudioEnabled({ element }) + ); +} + +export function buildSeparatedAudioElement({ + sourceElement, +}: { + sourceElement: VideoElement; +}): CreateUploadAudioElement { + return { + type: "audio", + sourceType: "upload", + mediaId: sourceElement.mediaId, + name: sourceElement.name, + duration: sourceElement.duration, + startTime: sourceElement.startTime, + trimStart: sourceElement.trimStart, + trimEnd: sourceElement.trimEnd, + sourceDuration: sourceElement.sourceDuration, + volume: sourceElement.volume ?? DEFAULTS.element.volume, + muted: sourceElement.muted ?? false, + retime: sourceElement.retime + ? { + rate: sourceElement.retime.rate, + maintainPitch: sourceElement.retime.maintainPitch, + } + : undefined, + animations: cloneVolumeAnimations({ + animations: sourceElement.animations, + }), + }; +} + +export function getSourceAudioActionLabel({ + element, +}: { + element: VideoElement; +}): "Extract audio" | "Recover audio" { + return isSourceAudioSeparated({ element }) + ? "Recover audio" + : "Extract audio"; +} + +function cloneVolumeAnimations({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementAnimations | undefined { + const volumeBinding = animations?.bindings.volume; + if (!volumeBinding) { + return undefined; + } + + const subsetChannels = Object.fromEntries( + volumeBinding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + return channel ? [[component.channelId, channel] as const] : []; + }), + ); + if (Object.keys(subsetChannels).length === 0) { + return undefined; + } + + return cloneAnimations({ + animations: { + bindings: { + volume: volumeBinding, + }, + channels: subsetChannels, + }, + shouldRegenerateKeyframeIds: true, + }); +} diff --git a/apps/web/src/timeline/audio-state.ts b/apps/web/src/timeline/audio-state.ts new file mode 100644 index 00000000..4ef559ea --- /dev/null +++ b/apps/web/src/timeline/audio-state.ts @@ -0,0 +1,116 @@ +import { hasKeyframesForPath } from "@/animation/keyframe-query"; +import { resolveNumberAtTime } from "@/animation/resolve"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; +import type { TimelineElement } from "./types"; +const DEFAULT_STEP_SECONDS = 1 / 60; + +export type AudioCapableElement = Extract< + TimelineElement, + { type: "audio" | "video" } +>; + +export function clampDb(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value)); +} + +export function dBToLinear(db: number): number { + return 10 ** (clampDb(db) / 20); +} + +export function hasAnimatedVolume({ + element, +}: { + element: AudioCapableElement; +}): boolean { + return hasKeyframesForPath({ + animations: element.animations, + propertyPath: "volume", + }); +} + +import { TICKS_PER_SECOND } from "@/wasm"; + +export function resolveEffectiveAudioGain({ + element, + trackMuted = false, + localTime, +}: { + element: AudioCapableElement; + trackMuted?: boolean; + localTime: number; +}): number { + if (trackMuted || element.muted === true) { + return 0; + } + + const resolvedDb = resolveNumberAtTime({ + baseValue: element.volume ?? 0, + animations: element.animations, + propertyPath: "volume", + localTime: Math.round(localTime * TICKS_PER_SECOND), + }); + + return dBToLinear(resolvedDb); +} + +export function buildWaveformGainSamples({ + element, + count, +}: { + element: AudioCapableElement; + count: number; +}): number[] { + const durationSeconds = element.duration / TICKS_PER_SECOND; + return Array.from({ length: count }, (_, i) => { + const localTime = ((i + 0.5) / count) * durationSeconds; + return resolveEffectiveAudioGain({ element, localTime }); + }); +} + +export function buildAudioGainAutomation({ + element, + trackMuted = false, + fromLocalTime, + toLocalTime, + stepSeconds = DEFAULT_STEP_SECONDS, +}: { + element: AudioCapableElement; + trackMuted?: boolean; + fromLocalTime: number; + toLocalTime: number; + stepSeconds?: number; +}): Array<{ localTime: number; gain: number }> { + const startTime = Math.max(0, fromLocalTime); + const endTime = Math.max(startTime, toLocalTime); + const safeStep = + Number.isFinite(stepSeconds) && stepSeconds > 0 + ? stepSeconds + : DEFAULT_STEP_SECONDS; + const points: Array<{ localTime: number; gain: number }> = []; + + for (let localTime = startTime; localTime < endTime; localTime += safeStep) { + points.push({ + localTime, + gain: resolveEffectiveAudioGain({ + element, + trackMuted, + localTime, + }), + }); + } + + points.push({ + localTime: endTime, + gain: resolveEffectiveAudioGain({ + element, + trackMuted, + localTime: endTime, + }), + }); + + return points; +} diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx new file mode 100644 index 00000000..20e22c57 --- /dev/null +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EditorCore } from "@/core"; +import { useEditor } from "@/editor/use-editor"; +import type { BookmarkDragState } from "../hooks/use-bookmark-drag"; +import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme"; +import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout"; +import { DEFAULT_FPS } from "@/fps/defaults"; +import { snappedSeekTime } from "opencut-wasm"; +import { + ArrowTurnBackwardIcon, + Delete02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { Bookmark } from "@/timeline"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { uppercase } from "@/utils/string"; +import { clamp, formatNumberForDisplay } from "@/utils/math"; +import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; + +const MIN_BOOKMARK_WIDTH_PX = 2; +const BOOKMARK_MARKER_WIDTH_PX = 12; +const BOOKMARK_MARKER_HEIGHT_PX = 15; +const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2; +const BOOKMARK_MARKER_CLIP_PATH = + "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)"; + +function seekToBookmarkTime({ + editor, + time, +}: { + editor: EditorCore; + time: number; +}) { + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + const rate = activeProject?.settings.fps ?? DEFAULT_FPS; + const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + editor.playback.seek({ time: snappedTime }); +} + +interface TimelineBookmarksRowProps { + zoomLevel: number; + dynamicTimelineWidth: number; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; + handleWheel: (event: React.WheelEvent) => void; + handleTimelineContentClick: (event: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (event: React.MouseEvent) => void; + handleRulerMouseDown: (event: React.MouseEvent) => void; +} + +export function TimelineBookmarksRow({ + zoomLevel, + dynamicTimelineWidth, + dragState, + onBookmarkMouseDown, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineBookmarksRowProps) { + const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks); + + return ( + <div + className="relative flex-1 overflow-hidden" + style={{ height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX }} + > + <button + className="relative w-full cursor-default select-none border-0 bg-transparent p-0" + style={{ + height: TIMELINE_BOOKMARK_ROW_HEIGHT_PX, + width: `${dynamicTimelineWidth}px`, + }} + aria-label="Timeline ruler" + type="button" + onWheel={handleWheel} + onClick={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; + handleTimelineContentClick(event); + }} + onMouseDown={(event) => { + if (!event.currentTarget.contains(event.target as Node)) return; + handleRulerMouseDown(event); + handleRulerTrackingMouseDown(event); + }} + > + {bookmarks.map((bookmark) => ( + <TimelineBookmark + key={`bookmark-${bookmark.time}`} + bookmark={bookmark} + zoomLevel={zoomLevel} + dragState={dragState} + onBookmarkMouseDown={onBookmarkMouseDown} + /> + ))} + </button> + </div> + ); +} + +function TimelineBookmark({ + bookmark, + zoomLevel, + dragState, + onBookmarkMouseDown, +}: { + bookmark: Bookmark; + zoomLevel: number; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; +}) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const isDragging = + dragState.isDragging && + dragState.bookmarkTime !== null && + dragState.bookmarkTime === bookmark.time; + + const displayTime = isDragging ? dragState.currentTime : bookmark.time; + const time = bookmark.time; + const bookmarkDuration = bookmark.duration ?? 0; + const durationWidth = + bookmarkDuration > 0 + ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) + : 0; + const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; + const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); + const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); + const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; + const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); + const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR; + + const handleSeek = () => seekToBookmarkTime({ editor, time }); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (event.detail === 2) { + setIsPopoverOpen(true); + } else { + handleSeek(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleSeek(); + }; + + const handleMouseDown = (event: React.MouseEvent) => { + onBookmarkMouseDown({ event, bookmark }); + event.preventDefault(); + event.stopPropagation(); + }; + + return ( + <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}> + <PopoverAnchor asChild> + <button + className="absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0" + style={{ + left: `${bookmarkLeft}px`, + width: `${bookmarkWidth}px`, + }} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} + type="button" + onMouseDown={handleMouseDown} + onClick={handleClick} + onKeyDown={handleKeyDown} + > + {hasDurationRange ? ( + <div + className="absolute opacity-30" + style={{ + top: 1.5, + height: BOOKMARK_MARKER_HEIGHT_PX - 2.5, + left: BOOKMARK_HALF_WIDTH_PX, + width: durationWidth, + backgroundColor: iconColor, + }} + /> + ) : null} + <div + className="absolute top-0 overflow-hidden" + style={{ + left: 0, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute inset-0" + style={{ + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + <div + className="absolute top-0 overflow-hidden" + style={{ + left: rightHalfLeft, + width: BOOKMARK_HALF_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + }} + > + <div + className="absolute top-0" + style={{ + left: -BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX, + height: BOOKMARK_MARKER_HEIGHT_PX, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: "hsl(var(--background))", + }} + /> + <div + className="absolute" + style={{ + top: 1, + left: 1 - BOOKMARK_HALF_WIDTH_PX, + width: BOOKMARK_MARKER_WIDTH_PX - 2, + height: BOOKMARK_MARKER_HEIGHT_PX - 2, + clipPath: BOOKMARK_MARKER_CLIP_PATH, + backgroundColor: iconColor, + }} + /> + </div> + </button> + </PopoverAnchor> + <PopoverContent + className="w-64 flex flex-col gap-3 p-3" + align="start" + side="bottom" + sideOffset={8} + onOpenAutoFocus={(event) => event.preventDefault()} + > + <BookmarkPopoverContent + bookmark={bookmark} + time={time} + timelineDuration={duration} + onPopoverClose={() => setIsPopoverOpen(false)} + /> + </PopoverContent> + </Popover> + ); +} + +function BookmarkPopoverContent({ + bookmark, + time, + timelineDuration, + onPopoverClose, +}: { + bookmark: Bookmark; + time: number; + timelineDuration: number; + onPopoverClose: () => void; +}) { + const editor = useEditor(); + const [draftColorHex, setDraftColorHex] = useState( + (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) + .replace("#", "") + .toUpperCase(), + ); + + useEffect(() => { + setDraftColorHex( + (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) + .replace("#", "") + .toUpperCase(), + ); + }, [bookmark.color]); + + const handleRemove = () => { + editor.scenes.removeBookmark({ time }); + onPopoverClose(); + }; + + const handleUpdate = ({ + note, + color, + duration, + }: Partial<{ note: string; color: string; duration: number }>) => { + const updates: Partial<{ note: string; color: string; duration: number }> = + {}; + if (note !== undefined && note !== bookmark.note) updates.note = note; + if ( + color !== undefined && + color.toUpperCase() !== (bookmark.color ?? "").toUpperCase() + ) { + updates.color = color; + } + if (duration !== undefined && duration !== bookmark.duration) { + updates.duration = duration; + } + if (Object.keys(updates).length === 0) return; + editor.scenes.updateBookmark({ time, updates }); + }; + + return ( + <> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Note</Label> + <Input + placeholder="Add a note..." + value={bookmark.note ?? ""} + onChange={(event) => handleUpdate({ note: event.target.value })} + className="h-8 text-sm" + /> + </div> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Color</Label> + <div className="relative"> + <ColorPicker + value={uppercase({ string: draftColorHex })} + onChange={(color) => setDraftColorHex(uppercase({ string: color }))} + onChangeEnd={(color) => + handleUpdate({ color: `#${uppercase({ string: color })}` }) + } + className="bg-background border" + /> + {bookmark.color && + bookmark.color.replace(/^#/, "").toUpperCase() !== + DEFAULT_TIMELINE_BOOKMARK_COLOR.replace( + /^#/, + "", + ).toUpperCase() && ( + <Button + type="button" + variant="text" + size="text" + aria-label="Reset to default color" + className="absolute top-1/2 right-1 -translate-y-1/2 mr-1" + onClick={() => + editor.scenes.updateBookmark({ + time, + updates: { color: undefined }, + }) + } + > + <HugeiconsIcon + icon={ArrowTurnBackwardIcon} + className="!size-3.5" + /> + </Button> + )} + </div> + </div> + <div className="flex flex-col gap-2"> + <Label className="text-xs">Duration</Label> + <div className="flex items-center gap-1.5"> + <Input + type="number" + min={0} + step={0.1} + value={bookmark.duration ?? 0} + onChange={(event) => { + const parsed = parseFloat(event.target.value); + const value = Number.isNaN(parsed) + ? 0 + : clamp({ + value: parsed, + min: 0, + max: Math.max(0, timelineDuration - time), + }); + handleUpdate({ duration: value }); + }} + className="h-8 text-sm" + containerClassName="w-full" + /> + </div> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="text-destructive hover:bg-destructive/10" + onClick={handleRemove} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + handleRemove(); + } + }} + aria-label="delete bookmark" + > + <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" /> + Delete + </Button> + </> + ); +} diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts new file mode 100644 index 00000000..1071c6dd --- /dev/null +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -0,0 +1,298 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type RefObject, +} from "react"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { roundToFrame } from "opencut-wasm"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getBookmarkSnapPoints } from "../snap-source"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import type { Bookmark } from "@/timeline"; + +export interface BookmarkDragState { + isDragging: boolean; + bookmarkTime: number | null; + currentTime: number; +} + +interface PendingBookmarkDrag { + bookmarkTime: number; + startMouseX: number; + startMouseY: number; +} + +interface UseBookmarkDragProps { + zoomLevel: number; + scrollRef: RefObject<HTMLElement | null>; + snappingEnabled: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +export function useBookmarkDrag({ + zoomLevel, + scrollRef, + snappingEnabled, + onSnapPointChange, +}: UseBookmarkDragProps) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const tracks = editor.scenes.getActiveScene().tracks; + const activeScene = editor.scenes.getActiveScene(); + const bookmarks = activeScene?.bookmarks ?? []; + const playheadTime = editor.playback.getCurrentTime(); + const duration = editor.timeline.getTotalDuration(); + + const [dragState, setDragState] = useState<BookmarkDragState>({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); + const lastMouseXRef = useRef(0); + + const startDrag = useCallback( + ({ + bookmarkTime, + initialCurrentTime, + }: { + bookmarkTime: number; + initialCurrentTime: number; + }) => { + setDragState({ + isDragging: true, + bookmarkTime, + currentTime: initialCurrentTime, + }); + }, + [], + ); + + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + }, []); + + const getSnapResult = useCallback( + ({ + rawTime, + excludeBookmarkTime, + }: { + rawTime: number; + excludeBookmarkTime: number; + }): { snappedTime: number; snapPoint: SnapPoint | null } => { + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (!shouldSnap) { + return { snappedTime: rawTime, snapPoint: null }; + } + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks }), + () => getPlayheadSnapPoints({ playheadTime }), + () => getBookmarkSnapPoints({ bookmarks, excludeBookmarkTime }), + () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), + ], + }); + const result = resolveTimelineSnap({ + targetTime: rawTime, + snapPoints, + maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), + }); + return { + snappedTime: result.snappedTime, + snapPoint: result.snapPoint, + }; + }, + [ + snappingEnabled, + tracks, + playheadTime, + bookmarks, + zoomLevel, + isShiftHeldRef, + ], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = (event: MouseEvent) => { + lastMouseXRef.current = event.clientX; + + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + + if (isPendingDrag && pendingDragRef.current) { + const { startMouseX, startMouseY, bookmarkTime } = + pendingDragRef.current; + const deltaX = Math.abs(event.clientX - startMouseX); + const deltaY = Math.abs(event.clientY - startMouseY); + + if ( + deltaX <= TIMELINE_DRAG_THRESHOLD_PX && + deltaY <= TIMELINE_DRAG_THRESHOLD_PX + ) { + return; + } + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const frameSnappedTime = + roundToFrame({ + time: Math.max(0, Math.min(mouseTime, duration)), + rate: activeProject.settings.fps, + }) ?? Math.max(0, Math.min(mouseTime, duration)); + const { snappedTime: initialTime } = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: bookmarkTime, + }); + + startDrag({ + bookmarkTime, + initialCurrentTime: initialTime, + }); + pendingDragRef.current = null; + setIsPendingDrag(false); + return; + } + + if (!dragState.isDragging || dragState.bookmarkTime === null) return; + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const clampedTime = Math.max(0, Math.min(mouseTime, duration)); + const frameSnappedTime = + roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? + clampedTime; + const snapResult = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: dragState.bookmarkTime, + }); + + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snapResult.snappedTime, + })); + onSnapPointChange?.(snapResult.snapPoint); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + zoomLevel, + duration, + editor.project, + scrollRef, + isPendingDrag, + startDrag, + getSnapResult, + onSnapPointChange, + ]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + if (dragState.bookmarkTime === null) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const clampedTime = Math.max( + 0, + Math.min(dragState.currentTime, duration), + ); + + editor.scenes.moveBookmark({ + fromTime: dragState.bookmarkTime, + toTime: clampedTime, + }); + + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + dragState.currentTime, + duration, + endDrag, + onSnapPointChange, + editor.scenes, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag, onSnapPointChange]); + + const handleBookmarkMouseDown = useCallback( + ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => { + if (event.button !== 0) return; + + event.preventDefault(); + event.stopPropagation(); + + pendingDragRef.current = { + bookmarkTime: bookmark.time, + startMouseX: event.clientX, + startMouseY: event.clientY, + }; + setIsPendingDrag(true); + }, + [], + ); + + return { + dragState, + handleBookmarkMouseDown, + lastMouseXRef, + }; +} diff --git a/apps/web/src/timeline/bookmarks/index.ts b/apps/web/src/timeline/bookmarks/index.ts new file mode 100644 index 00000000..667979b4 --- /dev/null +++ b/apps/web/src/timeline/bookmarks/index.ts @@ -0,0 +1,19 @@ +export { + findBookmarkIndex, + isBookmarkAtTime, + toggleBookmarkInArray, + removeBookmarkFromArray, + updateBookmarkInArray, + moveBookmarkInArray, + getFrameTime, + getBookmarkAtTime, + getBookmarksActiveAtTime, +} from "./utils"; +export { getBookmarkSnapPoints } from "./snap-source"; +export { + bookmarkNotesPreviewOverlay, + getBookmarkPreviewOverlaySource, +} from "./preview-overlay-source"; +export { useBookmarkDrag } from "./hooks/use-bookmark-drag"; +export type { BookmarkDragState } from "./hooks/use-bookmark-drag"; +export { TimelineBookmarksRow } from "./components/bookmarks"; diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx new file mode 100644 index 00000000..8e2b4bcf --- /dev/null +++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx @@ -0,0 +1,85 @@ +import type { Bookmark } from "@/timeline"; +import { + EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT, + type PreviewOverlayDefinition, + type PreviewOverlaySourceResult, +} from "@/preview/overlays"; +import { getBookmarksActiveAtTime } from "./utils"; + +export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { + id: "bookmark-notes", + label: "Show bookmark notes", + defaultVisible: true, +}; + +function BookmarkNotesOverlay({ + bookmarks, +}: { + bookmarks: Array<{ time: number; note: string; color?: string }>; +}) { + return ( + <div className="flex flex-col gap-1.5" aria-live="polite"> + {bookmarks.map((bookmark) => ( + <div + key={bookmark.time} + className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm" + style={{ + backgroundColor: "rgb(0 0 0 / 0.5)", + borderLeft: bookmark.color + ? `3px solid ${bookmark.color}` + : "3px solid var(--primary)", + }} + > + {bookmark.note} + </div> + ))} + </div> + ); +} + +export function getBookmarkPreviewOverlaySource({ + bookmarks, + time, + isVisible, +}: { + bookmarks: Bookmark[]; + time: number; + isVisible: boolean; +}): PreviewOverlaySourceResult { + const bookmarksWithNotes = getBookmarksActiveAtTime({ + bookmarks, + time, + }).flatMap((bookmark) => { + if (bookmark.note == null || bookmark.note.trim() === "") { + return []; + } + + return [ + { + time: bookmark.time, + note: bookmark.note, + color: bookmark.color, + }, + ]; + }); + + if (!isVisible || bookmarksWithNotes.length === 0) { + return { + ...EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT, + definitions: [bookmarkNotesPreviewOverlay], + }; + } + + return { + definitions: [bookmarkNotesPreviewOverlay], + instances: [ + { + id: bookmarkNotesPreviewOverlay.id, + mount: { kind: "hud", anchor: "top-left", order: 0 }, + plane: "over-interaction", + pointerEvents: "none", + render: () => <BookmarkNotesOverlay bookmarks={bookmarksWithNotes} />, + }, + ], + }; +} diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts new file mode 100644 index 00000000..32a84baa --- /dev/null +++ b/apps/web/src/timeline/bookmarks/snap-source.ts @@ -0,0 +1,20 @@ +import type { Bookmark } from "@/timeline"; +import type { SnapPoint } from "@/timeline/snapping"; + +export function getBookmarkSnapPoints({ + bookmarks, + excludeBookmarkTime, +}: { + bookmarks: Bookmark[]; + excludeBookmarkTime?: number; +}): SnapPoint[] { + return bookmarks.flatMap((bookmark) => { + if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) { + return []; + } + + return [ + { time: bookmark.time, type: "bookmark" satisfies SnapPoint["type"] }, + ]; + }); +} diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts new file mode 100644 index 00000000..d05fe3a9 --- /dev/null +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -0,0 +1,145 @@ +import type { Bookmark } from "@/timeline"; +import type { FrameRate } from "opencut-wasm"; +import { roundToFrame } from "opencut-wasm"; + +function bookmarkTimeEqual({ + bookmarkTime, + frameTime, +}: { + bookmarkTime: number; + frameTime: number; +}): boolean { + return bookmarkTime === frameTime; +} + +export function findBookmarkIndex({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): number { + return bookmarks.findIndex((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); +} + +export function isBookmarkAtTime({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): boolean { + return bookmarks.some((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); +} + +export function toggleBookmarkInArray({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark[] { + const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); + + if (bookmarkIndex !== -1) { + return bookmarks.filter((_, index) => index !== bookmarkIndex); + } + + const newBookmarks = [...bookmarks, { time: frameTime }]; + return newBookmarks.slice().sort((a, b) => a.time - b.time); +} + +export function removeBookmarkFromArray({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark[] { + return bookmarks.filter( + (bookmark) => + !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); +} + +export function updateBookmarkInArray({ + bookmarks, + frameTime, + updates, +}: { + bookmarks: Bookmark[]; + frameTime: number; + updates: Partial<Omit<Bookmark, "time">>; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], ...updates }; + const result = [...bookmarks]; + result[index] = updated; + return result; +} + +export function moveBookmarkInArray({ + bookmarks, + fromTime, + toTime, +}: { + bookmarks: Bookmark[]; + fromTime: number; + toTime: number; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], time: toTime }; + const result = [...bookmarks]; + result[index] = updated; + return result.slice().sort((a, b) => a.time - b.time); +} + +export function getFrameTime({ + time, + fps, +}: { + time: number; + fps: FrameRate; +}): number { + return roundToFrame({ time, rate: fps }) ?? time; +} + +export function getBookmarkAtTime({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark | null { + const index = findBookmarkIndex({ bookmarks, frameTime }); + return index === -1 ? null : bookmarks[index]; +} + +export function getBookmarksActiveAtTime({ + bookmarks, + time, +}: { + bookmarks: Bookmark[]; + time: number; +}): Bookmark[] { + return bookmarks.filter((bookmark) => { + const start = bookmark.time; + const end = + bookmark.duration != null && bookmark.duration > 0 + ? start + bookmark.duration + : start; + return time >= start && time <= end; + }); +} diff --git a/apps/web/src/timeline/components/audio-tab.tsx b/apps/web/src/timeline/components/audio-tab.tsx new file mode 100644 index 00000000..1dbb3d1a --- /dev/null +++ b/apps/web/src/timeline/components/audio-tab.tsx @@ -0,0 +1,151 @@ +import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { isSourceAudioSeparated } from "@/timeline/audio-separation";
+import { DEFAULTS } from "@/timeline/defaults";
+import {
+ clamp,
+ formatNumberForDisplay,
+ getFractionDigitsForStep,
+ isNearlyEqual,
+ snapToStep,
+} from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/timeline";
+import { resolveNumberAtTime } from "@/animation";
+import { useEditor } from "@/editor/use-editor";
+import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+const VOLUME_STEP = 0.1;
+const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP });
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: formatNumberForDisplay({
+ value: resolvedVolume,
+ fractionDigits: VOLUME_FRACTION_DIGITS,
+ }),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({
+ value: snapToStep({ value: parsed, step: VOLUME_STEP }),
+ min: VOLUME_DB_MIN,
+ max: VOLUME_DB_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: VOLUME_STEP,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+ const isSeparated =
+ element.type === "video" && isSourceAudioSeparated({ element });
+
+ return (
+ <>
+ {isSeparated && (
+ <div className="mx-4 mt-4 rounded-md border bg-muted/30 p-3">
+ <p className="text-sm">Audio has been separated.</p>
+ <Button
+ className="mt-3"
+ size="sm"
+ variant="secondary"
+ onClick={() =>
+ editor.timeline.toggleSourceAudioSeparation({
+ trackId,
+ elementId: element.id,
+ })
+ }
+ >
+ Recover audio
+ </Button>
+ </div>
+ )}
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ </>
+ );
+}
diff --git a/apps/web/src/timeline/components/audio-volume-line.tsx b/apps/web/src/timeline/components/audio-volume-line.tsx new file mode 100644 index 00000000..1baac2bd --- /dev/null +++ b/apps/web/src/timeline/components/audio-volume-line.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useEditor } from "@/editor/use-editor"; +import { DEFAULTS } from "@/timeline/defaults"; +import { + getDbFromLinePos, + getLinePosFromDb, +} from "@/timeline/audio-display"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants"; +import { hasAnimatedVolume } from "@/timeline/audio-state"; +import type { AudioElement } from "@/timeline/types"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + isNearlyEqual, + snapToStep, +} from "@/utils/math"; +import { cn } from "@/utils/ui"; + +const HIT_AREA_HEIGHT_PX = 14; +const TOOLTIP_OFFSET_PX = 10; +const VOLUME_STEP = 0.1; +const VOLUME_FRACTION_DIGITS = getFractionDigitsForStep({ step: VOLUME_STEP }); + +function clampVolume({ value }: { value: number }): number { + return clamp({ + value: snapToStep({ value, step: VOLUME_STEP }), + min: VOLUME_DB_MIN, + max: VOLUME_DB_MAX, + }); +} + +function getVolumeFromPointer({ + clientY, + rect, +}: { + clientY: number; + rect: DOMRect; +}): number { + const clampedOffset = clamp({ + value: clientY - rect.top, + min: 0, + max: rect.height, + }); + const progressPercent = + rect.height <= 0 ? 0 : (clampedOffset / rect.height) * 100; + return clampVolume({ value: getDbFromLinePos({ percent: progressPercent }) }); +} + +export function AudioVolumeLine({ + element, + trackId, +}: { + element: AudioElement; + trackId: string; +}) { + const editor = useEditor(); + const surfaceRef = useRef<HTMLDivElement>(null); + const activePointerIdRef = useRef<number | null>(null); + const startVolumeRef = useRef(element.volume ?? DEFAULTS.element.volume); + const lastPreviewVolumeRef = useRef( + element.volume ?? DEFAULTS.element.volume, + ); + const hasChangedRef = useRef(false); + const [isDragging, setIsDragging] = useState(false); + const [tooltipClientPos, setTooltipClientPos] = useState<{ + x: number; + y: number; + } | null>(null); + + const hasAnimatedEnvelope = hasAnimatedVolume({ element }); + const currentVolume = element.volume ?? DEFAULTS.element.volume; + const lineTop = `${getLinePosFromDb({ db: currentVolume })}%`; + + const volumeLabel = `${formatNumberForDisplay({ + value: currentVolume, + fractionDigits: VOLUME_FRACTION_DIGITS, + })} dB`; + + const previewVolume = useCallback( + (nextVolume: number) => { + if ( + isNearlyEqual({ + leftValue: nextVolume, + rightValue: lastPreviewVolumeRef.current, + }) + ) { + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { volume: nextVolume }, + }, + ], + }); + lastPreviewVolumeRef.current = nextVolume; + hasChangedRef.current = !isNearlyEqual({ + leftValue: startVolumeRef.current, + rightValue: nextVolume, + }); + }, + [editor, element.id, trackId], + ); + + const finishDrag = useCallback( + ({ shouldCommit }: { shouldCommit: boolean }) => { + activePointerIdRef.current = null; + setIsDragging(false); + + if (shouldCommit && hasChangedRef.current) { + editor.timeline.commitPreview(); + } else { + editor.timeline.discardPreview(); + } + + hasChangedRef.current = false; + lastPreviewVolumeRef.current = startVolumeRef.current; + setTooltipClientPos(null); + }, + [editor], + ); + + const updateFromPointer = useCallback( + ({ clientX, clientY }: { clientX: number; clientY: number }) => { + const rect = surfaceRef.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + setTooltipClientPos({ + x: clientX + TOOLTIP_OFFSET_PX, + y: clientY - TOOLTIP_OFFSET_PX, + }); + previewVolume(getVolumeFromPointer({ clientY, rect })); + }, + [previewVolume], + ); + + const handleClick = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + const handleMouseDown = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + + const handlePointerDown = useCallback( + (event: React.PointerEvent<HTMLDivElement>) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + editor.selection.setSelectedElements({ + elements: [{ trackId, elementId: element.id }], + }); + activePointerIdRef.current = event.pointerId; + startVolumeRef.current = currentVolume; + lastPreviewVolumeRef.current = currentVolume; + hasChangedRef.current = false; + setIsDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + updateFromPointer({ + clientX: event.clientX, + clientY: event.clientY, + }); + }, + [currentVolume, editor.selection, element.id, trackId, updateFromPointer], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + updateFromPointer({ + clientX: event.clientX, + clientY: event.clientY, + }); + }, + [updateFromPointer], + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + finishDrag({ shouldCommit: true }); + }, + [finishDrag], + ); + + const handlePointerCancel = useCallback( + (event: React.PointerEvent) => { + if (activePointerIdRef.current !== event.pointerId) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + finishDrag({ shouldCommit: false }); + }, + [finishDrag], + ); + + const handleLostPointerCapture = useCallback(() => { + if (activePointerIdRef.current === null) { + return; + } + + finishDrag({ shouldCommit: hasChangedRef.current }); + }, [finishDrag]); + + if (hasAnimatedEnvelope) { + return null; + } + + return ( + <div className="pointer-events-none absolute inset-0"> + <div ref={surfaceRef} className="absolute inset-0"> + <div + className={cn( + "pointer-events-none absolute inset-x-0 -translate-y-1/2 border-t transition-colors", + isDragging + ? "border-white" + : "border-white/50 group-hover/audio:border-white/80", + )} + style={{ top: lineTop }} + /> + {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline volume line is a pointer-only editing surface */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: timeline volume line is a pointer-only editing surface */} + <div + className="absolute inset-x-0 -translate-y-1/2 touch-none cursor-ns-resize pointer-events-auto" + style={{ top: lineTop, height: `${HIT_AREA_HEIGHT_PX}px` }} + onClick={handleClick} + onMouseDown={handleMouseDown} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerCancel} + onLostPointerCapture={handleLostPointerCapture} + title="Drag to adjust clip volume" + /> + {isDragging && + tooltipClientPos && + createPortal( + <div + className="pointer-events-none fixed left-0 top-0 z-50 -translate-y-full rounded bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white whitespace-nowrap" + style={{ + transform: `translate(${tooltipClientPos.x}px, ${tooltipClientPos.y}px)`, + }} + > + {volumeLabel} + </div>, + document.body, + )} + </div> + </div> + ); +} diff --git a/apps/web/src/timeline/components/audio-waveform.tsx b/apps/web/src/timeline/components/audio-waveform.tsx new file mode 100644 index 00000000..7777bc09 --- /dev/null +++ b/apps/web/src/timeline/components/audio-waveform.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useResizeObserver } from "@/hooks/use-resize-observer"; +import { TIMELINE_AUDIO_WAVEFORM_COLOR } from "./theme"; +import { + buildWaveformSampleBuckets, + sampleSourceWaveformSummary, + type SourceWaveformSummary, +} from "@/media/waveform-summary"; +import type { RetimeConfig } from "@/timeline"; +import { getBarFractionFromOutputAmplitude } from "@/timeline/audio-display"; +import { waveformCache } from "@/services/waveform-cache/service"; +import { findScrollParent } from "@/utils/browser"; +import { cn } from "@/utils/ui"; + +const BAR_WIDTH = 1; +const BAR_GAP = 1; +const BAR_STEP = BAR_WIDTH + BAR_GAP; +const WAVEFORM_BURN_COLOR = "rgba(255, 110, 20, 0.9)"; +export const WAVEFORM_GAIN_SAMPLE_COUNT = 200; + +function sampleGainAtClipTime({ + samples, + clipTimeSec, + clipDurationSec, +}: { + samples: number[]; + clipTimeSec: number; + clipDurationSec: number; +}): number { + if (samples.length === 0 || clipDurationSec <= 0) { + return 1; + } + + const progress = Math.max(0, Math.min(1, clipTimeSec / clipDurationSec)); + const rawIndex = progress * (samples.length - 1); + const lo = Math.floor(rawIndex); + const hi = Math.min(samples.length - 1, lo + 1); + return samples[lo] + (samples[hi] - samples[lo]) * (rawIndex - lo); +} + +interface AudioWaveformProps { + sourceKey: string; + sourceFile?: File; + audioUrl?: string; + audioBuffer?: AudioBuffer; + gainSamples?: number[]; + pixelsPerSecond: number; + clipDurationSec: number; + retime?: RetimeConfig; + sourceStartSec: number; + color?: string; + burnColor?: string; + className?: string; +} + +export function AudioWaveform({ + sourceKey, + sourceFile, + audioUrl, + audioBuffer, + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + color = TIMELINE_AUDIO_WAVEFORM_COLOR, + burnColor = WAVEFORM_BURN_COLOR, + className = "", +}: AudioWaveformProps) { + const canvasRef = useRef<HTMLCanvasElement>(null); + const containerRef = useRef<HTMLDivElement>(null); + const summaryRef = useRef<SourceWaveformSummary | null>(null); + const gainSamplesRef = useRef<number[] | undefined>(gainSamples); + const pixelsPerSecondRef = useRef<number>(pixelsPerSecond); + const clipDurationSecRef = useRef<number>(clipDurationSec); + const retimeRef = useRef<RetimeConfig | undefined>(retime); + const sourceStartSecRef = useRef<number>(sourceStartSec); + const scrollParentRef = useRef<HTMLElement | null>(null); + const heightRef = useRef<number>(0); + const lastRenderSignatureRef = useRef<string | null>(null); + + gainSamplesRef.current = gainSamples; + pixelsPerSecondRef.current = pixelsPerSecond; + clipDurationSecRef.current = clipDurationSec; + retimeRef.current = retime; + sourceStartSecRef.current = sourceStartSec; + + const clearCanvas = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + lastRenderSignatureRef.current = null; + }, []); + + const drawVisible = useCallback(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + const summary = summaryRef.current; + const height = heightRef.current; + + if (!container || !canvas || !summary || height <= 0) { + clearCanvas(); + return; + } + + const containerRect = container.getBoundingClientRect(); + const elementWidth = containerRect.width; + if (elementWidth <= 0) { + clearCanvas(); + return; + } + + const scrollParent = scrollParentRef.current; + + let clipLeft: number; + let clipRight: number; + + if (scrollParent) { + const parentRect = scrollParent.getBoundingClientRect(); + clipLeft = Math.max(0, parentRect.left - containerRect.left); + clipRight = Math.min(elementWidth, parentRect.right - containerRect.left); + } else { + clipLeft = Math.max(0, -containerRect.left); + clipRight = Math.min( + elementWidth, + window.innerWidth - containerRect.left, + ); + } + + const visibleWidth = clipRight - clipLeft; + if (visibleWidth <= 0) { + clearCanvas(); + return; + } + + const dpr = window.devicePixelRatio || 1; + const canvasW = Math.max(1, Math.ceil(visibleWidth * dpr)); + const canvasH = Math.max(1, Math.round(height * dpr)); + const barCount = Math.max(1, Math.floor(visibleWidth / BAR_STEP)); + const pixelsPerSecondValue = pixelsPerSecondRef.current; + const clipDurationSecValue = clipDurationSecRef.current; + const samples = gainSamplesRef.current; + const renderSignature = JSON.stringify({ + elementWidth, + clipLeft, + clipRight, + visibleWidth, + canvasW, + canvasH, + barCount, + dpr, + clipDurationSec: clipDurationSecValue, + sourceStartSec: sourceStartSecRef.current, + pixelsPerSecond: pixelsPerSecondValue, + retime: retimeRef.current ?? null, + summarySourceKey: summary.sourceKey, + summarySampleRate: summary.sampleRate, + summaryTotalSamples: summary.totalSamples, + summaryBucketSize: summary.bucketSize, + gainSamples: samples ?? null, + color, + burnColor, + }); + if (lastRenderSignatureRef.current === renderSignature) { + return; + } + lastRenderSignatureRef.current = renderSignature; + + canvas.width = canvasW; + canvas.height = canvasH; + canvas.style.width = `${visibleWidth}px`; + canvas.style.height = `${height}px`; + canvas.style.left = `${clipLeft}px`; + + const backingScaleX = dpr; + const backingScaleY = canvasH / height; + + const sampleBuckets = buildWaveformSampleBuckets({ + clipLeftPx: clipLeft, + clipRightPx: clipRight, + barCount, + pixelsPerSecond: pixelsPerSecondValue, + clipDurationSec: clipDurationSecValue, + sourceStartSec: sourceStartSecRef.current, + retime: retimeRef.current, + sampleRate: summary.sampleRate, + maxSampleExclusive: summary.totalSamples, + barStepPx: BAR_STEP, + }); + const amplitudes = sampleSourceWaveformSummary({ + summary, + buckets: sampleBuckets, + }); + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvasW, canvasH); + + const clipBottom = canvasH; + + for (let i = 0; i < barCount; i++) { + const barCenterPx = clipLeft + i * BAR_STEP + BAR_WIDTH * 0.5; + const clipCenterSec = Math.max( + 0, + Math.min(clipDurationSecValue, barCenterPx / pixelsPerSecondValue), + ); + const gain = + samples != null + ? sampleGainAtClipTime({ + samples, + clipTimeSec: clipCenterSec, + clipDurationSec: clipDurationSecValue, + }) + : 1; + const amplitude = Math.max(0, amplitudes[i] ?? 0); + const outputAmplitude = amplitude * Math.max(0, gain); + const fraction = getBarFractionFromOutputAmplitude({ outputAmplitude }); + const barH = fraction > 0 ? Math.max(1, fraction * height) : 0; + if (barH <= 0) { + continue; + } + + const barLeft = i * BAR_STEP; + const barRight = barLeft + BAR_WIDTH; + const deviceLeft = Math.round(barLeft * backingScaleX); + const deviceRight = Math.max( + deviceLeft + 1, + Math.round(barRight * backingScaleX), + ); + const deviceTop = Math.round((height - barH) * backingScaleY); + const deviceHeight = Math.max(1, clipBottom - deviceTop); + + ctx.fillStyle = color; + ctx.fillRect( + deviceLeft, + deviceTop, + deviceRight - deviceLeft, + deviceHeight, + ); + + if (outputAmplitude > 1) { + const burnHeight = Math.max(1, Math.round(BAR_WIDTH * backingScaleY)); + ctx.fillStyle = burnColor; + ctx.fillRect( + deviceLeft, + deviceTop, + deviceRight - deviceLeft, + burnHeight, + ); + } + } + }, [burnColor, clearCanvas, color]); + + useEffect(() => { + let isCancelled = false; + summaryRef.current = null; + clearCanvas(); + + void waveformCache + .getSourceSummary({ + sourceKey, + audioBuffer, + sourceFile, + audioUrl, + }) + .then((summary) => { + if (isCancelled) { + return; + } + summaryRef.current = summary; + drawVisible(); + }) + .catch(() => { + // Waveform loading failed (e.g. corrupt file, unsupported format). + // Fail silently — a missing waveform is preferable to an error state. + if (!isCancelled) { + clearCanvas(); + } + }); + + return () => { + isCancelled = true; + }; + }, [audioBuffer, audioUrl, clearCanvas, drawVisible, sourceFile, sourceKey]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: these props are mirrored into refs during render, but the effect must still re-run to redraw when they change. + useLayoutEffect(() => { + drawVisible(); + }, [ + drawVisible, + gainSamples, + pixelsPerSecond, + clipDurationSec, + retime, + sourceStartSec, + ]); + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + scrollParentRef.current = findScrollParent({ element: container }); + const scrollParent = scrollParentRef.current; + if (!scrollParent) { + return; + } + + const handleScroll = () => { + drawVisible(); + }; + scrollParent.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollParent.removeEventListener("scroll", handleScroll); + }, [drawVisible]); + + const onResize = useCallback( + (entry: ResizeObserverEntry) => { + heightRef.current = entry.contentRect.height; + drawVisible(); + }, + [drawVisible], + ); + + useResizeObserver({ ref: containerRef, onResize }); + + return ( + <div ref={containerRef} className={cn("relative size-full", className)}> + <canvas ref={canvasRef} className="absolute bottom-0" /> + </div> + ); +} diff --git a/apps/web/src/timeline/components/drag-line.tsx b/apps/web/src/timeline/components/drag-line.tsx new file mode 100644 index 00000000..49621f5c --- /dev/null +++ b/apps/web/src/timeline/components/drag-line.tsx @@ -0,0 +1,29 @@ +import { getDropLineY } from "./drop-target";
+import type { TimelineTrack, DropTarget } from "@/timeline";
+import { TIMELINE_LAYERS } from "./layers";
+
+interface DragLineProps {
+ dropTarget: DropTarget | null;
+ tracks: TimelineTrack[];
+ isVisible: boolean;
+ headerHeight?: number;
+}
+
+export function DragLine({
+ dropTarget,
+ tracks,
+ isVisible,
+ headerHeight = 0,
+}: DragLineProps) {
+ if (!isVisible || !dropTarget) return null;
+
+ const y = getDropLineY({ dropTarget, tracks });
+ const lineTop = y + headerHeight;
+
+ return (
+ <div
+ className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
+ style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
+ />
+ );
+}
diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts new file mode 100644 index 00000000..be2495e4 --- /dev/null +++ b/apps/web/src/timeline/components/drop-target.ts @@ -0,0 +1,262 @@ +import type { TimelineTrack, TimelineElement } from "@/timeline"; +import type { ComputeDropTargetParams, DropTarget } from "@/timeline"; +import { resolveTrackPlacement } from "@/timeline/placement"; +import { TIMELINE_TRACK_GAP_PX } from "./layout"; +import { getTrackHeight } from "./track-layout"; +import { TICKS_PER_SECOND } from "@/wasm"; + +function findElementAtPosition({ + mouseX, + tracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, +}: { + mouseX: number; + tracks: TimelineTrack[]; + trackIndex: number; + targetElementTypes: string[]; + pixelsPerSecond: number; + zoomLevel: number; +}): { elementId: string; trackId: string } | null { + const time = Math.round( + (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, + ); + const track = tracks[trackIndex]; + if (!track || !("elements" in track)) return null; + + const hit = track.elements.find( + (element: TimelineElement) => + targetElementTypes.includes(element.type) && + element.startTime <= time && + time < element.startTime + element.duration, + ); + if (!hit) return null; + return { elementId: hit.id, trackId: track.id }; +} + +function getTrackAtY({ + mouseY, + tracks, + verticalDragDirection, +}: { + mouseY: number; + tracks: TimelineTrack[]; + verticalDragDirection?: "up" | "down" | null; +}): { trackIndex: number; relativeY: number } | null { + let cumulativeHeight = 0; + + for (let i = 0; i < tracks.length; i++) { + const trackHeight = getTrackHeight({ type: tracks[i].type }); + const trackTop = cumulativeHeight; + const trackBottom = trackTop + trackHeight; + + if (mouseY >= trackTop && mouseY < trackBottom) { + return { + trackIndex: i, + relativeY: mouseY - trackTop, + }; + } + + if (i < tracks.length - 1 && verticalDragDirection) { + const gapTop = trackBottom; + const gapBottom = gapTop + TIMELINE_TRACK_GAP_PX; + if (mouseY >= gapTop && mouseY < gapBottom) { + const isDraggingUp = verticalDragDirection === "up"; + return { + trackIndex: isDraggingUp ? i : i + 1, + relativeY: isDraggingUp ? trackHeight - 1 : 0, + }; + } + } + + cumulativeHeight += trackHeight + TIMELINE_TRACK_GAP_PX; + } + + return null; +} + +const EMPTY_TARGET_ELEMENT = null; + +function fallbackNewTrackDropTarget({ + xPosition, +}: { + xPosition: number; +}): DropTarget { + return { + trackIndex: 0, + isNewTrack: true, + insertPosition: null, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; +} + +export function computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime, + isExternalDrop, + elementDuration, + pixelsPerSecond, + zoomLevel, + verticalDragDirection, + startTimeOverride, + excludeElementId, + targetElementTypes, +}: ComputeDropTargetParams): DropTarget { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + const mainTrackIndex = tracks.overlay.length; + const xPosition = + typeof startTimeOverride === "number" + ? startTimeOverride + : isExternalDrop + ? playheadTime + : Math.round( + Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, + ); + + if (orderedTracks.length === 0) { + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + createNewTrackOnly: true, + }, + }); + const emptyTimelineResult = + placementResult?.kind === "newTrack" ? placementResult : null; + if (!emptyTimelineResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } + + return { + trackIndex: emptyTimelineResult.insertIndex, + isNewTrack: true, + insertPosition: emptyTimelineResult.insertPosition, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; + } + + const trackAtMouse = getTrackAtY({ + mouseY, + tracks: orderedTracks, + verticalDragDirection, + }); + + if (!trackAtMouse) { + const isAboveAllTracks = mouseY < 0; + + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1, + hoverDirection: isAboveAllTracks ? "above" : "below", + createNewTrackOnly: true, + }, + }); + const outOfBoundsResult = + placementResult?.kind === "newTrack" ? placementResult : null; + if (!outOfBoundsResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } + + return { + trackIndex: outOfBoundsResult.insertIndex, + isNewTrack: true, + insertPosition: outOfBoundsResult.insertPosition, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; + } + + const { trackIndex, relativeY } = trackAtMouse; + const track = orderedTracks[trackIndex]; + + if (targetElementTypes && targetElementTypes.length > 0) { + const targetElement = findElementAtPosition({ + mouseX, + tracks: orderedTracks, + trackIndex, + targetElementTypes, + pixelsPerSecond, + zoomLevel, + }); + if (targetElement) { + return { + trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition, + targetElement, + }; + } + } + + const trackHeight = getTrackHeight({ type: track.type }); + const placementResult = resolveTrackPlacement({ + tracks, + elementType, + timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], + strategy: { + type: "preferIndex", + trackIndex, + hoverDirection: relativeY < trackHeight / 2 ? "above" : "below", + verticalDragDirection, + }, + }); + if (!placementResult) { + return fallbackNewTrackDropTarget({ xPosition }); + } + + if (placementResult.kind === "existingTrack") { + const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; + + return { + trackIndex: placementResult.trackIndex, + isNewTrack: false, + insertPosition: null, + xPosition: adjustedXPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; + } + + return { + trackIndex: placementResult.insertIndex, + isNewTrack: true, + insertPosition: placementResult.insertPosition, + xPosition, + targetElement: EMPTY_TARGET_ELEMENT, + }; +} + +export function getDropLineY({ + dropTarget, + tracks, +}: { + dropTarget: DropTarget; + tracks: TimelineTrack[]; +}): number { + const safeTrackIndex = Math.min( + Math.max(dropTarget.trackIndex, 0), + tracks.length, + ); + let y = 0; + + for (let i = 0; i < safeTrackIndex; i++) { + y += getTrackHeight({ type: tracks[i].type }) + TIMELINE_TRACK_GAP_PX; + } + + return y; +} diff --git a/apps/web/src/timeline/components/expanded-layout.ts b/apps/web/src/timeline/components/expanded-layout.ts new file mode 100644 index 00000000..fae0e50c --- /dev/null +++ b/apps/web/src/timeline/components/expanded-layout.ts @@ -0,0 +1,117 @@ +import type { + AnimationPath, + ElementAnimations, +} from "@/animation/types"; +import type { TimelineTrack } from "@/timeline"; +import { getElementKeyframes } from "@/animation"; +import { KEYFRAME_LANE_HEIGHT_PX } from "./layout"; + +export interface ExpandedRow { + propertyPath: AnimationPath; + label: string; +} + +interface PropertyGroupDefinition { + matchesPath: (path: AnimationPath) => boolean; +} + +const PROPERTY_GROUPS: PropertyGroupDefinition[] = [ + { matchesPath: (path) => path.startsWith("transform.") || path === "opacity" }, + { matchesPath: (path) => path === "volume" || path === "color" }, + { matchesPath: (path) => path.startsWith("background.") }, + { matchesPath: (path) => path.startsWith("params.") }, + { matchesPath: (path) => path.startsWith("effects.") }, +]; + +const PROPERTY_LABELS: Partial<Record<string, string>> = { + "transform.positionX": "Position X", + "transform.positionY": "Position Y", + "transform.scaleX": "Scale X", + "transform.scaleY": "Scale Y", + "transform.rotate": "Rotation", + opacity: "Opacity", + volume: "Volume", + color: "Color", + "background.color": "BG Color", + "background.paddingX": "BG Pad X", + "background.paddingY": "BG Pad Y", + "background.offsetX": "BG Offset X", + "background.offsetY": "BG Offset Y", + "background.cornerRadius": "Corner Radius", +}; + +export function getPropertyLabel(path: AnimationPath): string { + if (PROPERTY_LABELS[path]) return PROPERTY_LABELS[path]; + if (path.startsWith("params.")) return path.slice("params.".length); + if (path.startsWith("effects.")) { + const parts = path.split("."); + return parts[parts.length - 1]; + } + return path; +} + +export function getExpandedRows({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ExpandedRow[] { + const keyframes = getElementKeyframes({ animations }); + const propertyPaths = [...new Set(keyframes.map((kf) => kf.propertyPath))]; + if (propertyPaths.length === 0) return []; + + const rows: ExpandedRow[] = []; + + for (const group of PROPERTY_GROUPS) { + const groupPaths = propertyPaths.filter((path) => + group.matchesPath(path), + ); + for (const path of groupPaths) { + rows.push({ propertyPath: path, label: getPropertyLabel(path) }); + } + } + + return rows; +} + +export function getExpansionHeight({ rows }: { rows: ExpandedRow[] }): number { + return rows.length * KEYFRAME_LANE_HEIGHT_PX; +} + +export function computeTrackExpansionHeight({ + track, + expandedElementIds, +}: { + track: TimelineTrack; + expandedElementIds: Set<string>; +}): number { + let maxHeight = 0; + for (const element of track.elements) { + if (!expandedElementIds.has(element.id)) continue; + const rows = getExpandedRows({ animations: element.animations }); + maxHeight = Math.max(maxHeight, getExpansionHeight({ rows })); + } + return maxHeight; +} + +export function getTrackExpandedRows({ + track, + expandedElementIds, +}: { + track: TimelineTrack; + expandedElementIds: Set<string>; +}): ExpandedRow[] { + let maxHeight = 0; + let maxRows: ExpandedRow[] = []; + + for (const element of track.elements) { + if (!expandedElementIds.has(element.id)) continue; + const rows = getExpandedRows({ animations: element.animations }); + const height = getExpansionHeight({ rows }); + if (height > maxHeight) { + maxHeight = height; + maxRows = rows; + } + } + + return maxRows; +} diff --git a/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx new file mode 100644 index 00000000..7d77b985 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/bezier-graph.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useRef, useState, type PointerEvent } from "react"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { getBezierPoint } from "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import { cn } from "@/utils/ui"; + +const GRAPH_WIDTH = 140; +const GRAPH_HEIGHT = 94; +const GRAPH_PADDING = 12; +const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2; +const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2; +const HANDLE_RADIUS = 3.5; +const ENDPOINT_RADIUS = 2; +const SNAP_THRESHOLD = 0.06; +const SNAP_TARGETS = [0, 1]; +const CURVE_SEGMENTS = 64; +const Y_CLAMP_MIN = -0.5; +const Y_CLAMP_MAX = 1.5; + +type BezierHandle = "c1" | "c2"; + +export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT; + +function snap({ + value, + targets, + isEnabled, +}: { + value: number; + targets: number[]; + isEnabled: boolean; +}) { + if (!isEnabled) return value; + for (const target of targets) { + if (Math.abs(value - target) < SNAP_THRESHOLD) return target; + } + return value; +} + +function toSvgX({ value }: { value: number }) { + return GRAPH_PADDING + value * GRAPH_WIDTH; +} + +function toSvgY({ value }: { value: number }) { + return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT; +} + +function fromSvgX({ svgX }: { svgX: number }) { + return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH)); +} + +function fromSvgY({ svgY }: { svgY: number }) { + return Math.max( + Y_CLAMP_MIN, + Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT), + ); +} + +function curvePath({ curve }: { curve: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= CURVE_SEGMENTS; i++) { + const progress = i / CURVE_SEGMENTS; + const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) }); + const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) }); + points.push(`${x},${y}`); + } + return `M${points.join("L")}`; +} + +function clampHandleY({ svgY }: { svgY: number }) { + return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY)); +} + +export function BezierGraph({ + value, + onChange, + onChangeEnd, + onCancel, +}: { + value: NormalizedCubicBezier; + onChange?: (value: NormalizedCubicBezier) => void; + onChangeEnd?: (value: NormalizedCubicBezier) => void; + onCancel?: () => void; +}) { + const svgRef = useRef<SVGSVGElement>(null); + const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null); + const isShiftPressedRef = useShiftKey(); + const latestValueRef = useRef(value); + + latestValueRef.current = value; + + function getPointerPosition({ + event, + }: { + event: PointerEvent; + }): { x: number; y: number } { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + const scale = SVG_WIDTH / rect.width; + return { + x: (event.clientX - rect.left) * scale, + y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height), + }; + } + + function onHandlePointerDown({ handle }: { handle: BezierHandle }) { + return (event: PointerEvent<SVGCircleElement>) => { + event.preventDefault(); + event.stopPropagation(); + setActiveHandle(handle); + event.currentTarget.setPointerCapture(event.pointerId); + }; + } + + function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) { + if (!activeHandle) return; + const pointerPos = getPointerPosition({ event }); + const x = fromSvgX({ svgX: pointerPos.x }); + const y = snap({ + value: fromSvgY({ svgY: pointerPos.y }), + targets: SNAP_TARGETS, + isEnabled: !isShiftPressedRef.current, + }); + const next: NormalizedCubicBezier = [...value]; + if (activeHandle === "c1") { + next[0] = x; + next[1] = y; + } else { + next[2] = x; + next[3] = y; + } + latestValueRef.current = next; + onChange?.(next); + } + + function onPointerUp() { + if (!activeHandle) return; + setActiveHandle(null); + onChangeEnd?.(latestValueRef.current); + } + + function onPointerCancel() { + if (!activeHandle) return; + setActiveHandle(null); + onCancel?.(); + } + + const path = curvePath({ curve: value }); + const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) }; + const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) }; + const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) }; + const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) }; + const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) }; + const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) }; + + return ( + <svg + ref={svgRef} + viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`} + className="bg-foreground/3 w-full cursor-crosshair select-none" + onPointerMove={(event) => onPointerMove({ event })} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + > + <title>Bezier curve editor</title> + <line + x1={p0.x} + y1={p0.y} + x2={p1.x} + y2={p1.y} + className="stroke-foreground/8" + strokeWidth={1} + strokeDasharray="3 3" + /> + <line + x1={p0.x} + y1={p0.y} + x2={c1Clamped.x} + y2={c1Clamped.y} + className="stroke-primary/30" + strokeWidth={1} + /> + <line + x1={p1.x} + y1={p1.y} + x2={c2Clamped.x} + y2={c2Clamped.y} + className="stroke-primary/30" + strokeWidth={1} + /> + <path + d={path} + fill="none" + className="stroke-primary" + strokeWidth={2} + strokeLinecap="round" + /> + <circle + cx={p0.x} + cy={p0.y} + r={ENDPOINT_RADIUS} + className="fill-foreground/20" + /> + <circle + cx={p1.x} + cy={p1.y} + r={ENDPOINT_RADIUS} + className="fill-foreground/20" + /> + <circle + cx={c1Clamped.x} + cy={c1Clamped.y} + r={HANDLE_RADIUS} + className={cn( + "fill-primary cursor-grab", + activeHandle === "c1" && "cursor-grabbing", + )} + onPointerDown={onHandlePointerDown({ handle: "c1" })} + /> + <circle + cx={c2Clamped.x} + cy={c2Clamped.y} + r={HANDLE_RADIUS} + className={cn( + "fill-primary cursor-grab", + activeHandle === "c2" && "cursor-grabbing", + )} + onPointerDown={onHandlePointerDown({ handle: "c2" })} + /> + </svg> + ); +} diff --git a/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts new file mode 100644 index 00000000..4be2adfd --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/custom-presets-store.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { generateUUID } from "@/utils/id"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import type { EasingPreset } from "./easing-presets"; + +const STORAGE_KEY = "graph-editor-presets"; + +let cachedPresets: EasingPreset[] | null = null; +const listeners = new Set<() => void>(); + +function isValidPresetArray(value: unknown): value is EasingPreset[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === "object" && + item !== null && + typeof item.id === "string" && + typeof item.label === "string" && + Array.isArray(item.value) && + item.value.length === 4 && + item.value.every((number: unknown) => typeof number === "number"), + ) + ); +} + +function readFromStorage(): EasingPreset[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return isValidPresetArray(parsed) ? parsed : []; + } catch { + // Silently recover — corrupted localStorage shouldn't crash the editor + return []; + } +} + +function writeToStorage({ presets }: { presets: EasingPreset[] }): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +function getSnapshot(): EasingPreset[] { + cachedPresets ??= readFromStorage(); + return cachedPresets; +} + +function getServerSnapshot(): EasingPreset[] { + return []; +} + +function notify(): void { + cachedPresets = null; + for (const listener of listeners) { + listener(); + } +} + +function onStorageChange(event: StorageEvent): void { + if (event.key === STORAGE_KEY) notify(); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0 && typeof window !== "undefined") { + window.addEventListener("storage", onStorageChange); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && typeof window !== "undefined") { + window.removeEventListener("storage", onStorageChange); + } + }; +} + +export function useCustomPresets(): EasingPreset[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function savePreset({ value }: { value: NormalizedCubicBezier }): void { + const current = getSnapshot(); + writeToStorage({ + presets: [ + ...current, + { + id: generateUUID(), + label: `Custom ${current.length + 1}`, + value, + isCustom: true, + }, + ], + }); + notify(); +} + +export function removePreset({ id }: { id: string }): void { + writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) }); + notify(); +} diff --git a/apps/web/src/timeline/components/graph-editor/easing-presets.ts b/apps/web/src/timeline/components/graph-editor/easing-presets.ts new file mode 100644 index 00000000..78a605d4 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/easing-presets.ts @@ -0,0 +1,19 @@ +import type { NormalizedCubicBezier } from "@/animation/types"; + +export const PRESET_MATCH_TOLERANCE = 0.02; + +export interface EasingPreset { + id: string; + label: string; + value: NormalizedCubicBezier; + isCustom?: boolean; +} + +export const BUILTIN_PRESETS: EasingPreset[] = [ + { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] }, + { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] }, + { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] }, + { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] }, + { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] }, + { id: "linear", label: "Linear", value: [0, 0, 1, 1] }, +]; diff --git a/apps/web/src/timeline/components/graph-editor/popover.tsx b/apps/web/src/timeline/components/graph-editor/popover.tsx new file mode 100644 index 00000000..1b8194c0 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/popover.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useState } from "react"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowDown01Icon, + Delete02Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { getBezierPoint } from "@/animation/bezier"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import type { GraphEditorComponentOption } from "./session"; +import { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, + type EasingPreset, +} from "./easing-presets"; +import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store"; +import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; + +const COLLAPSED_MAX = 6; +const THUMB_SEGMENTS = 24; +const THUMB_WIDTH = 40; +const THUMB_HEIGHT = 22; +const THUMB_PADDING_X = 4; +const THUMB_PADDING_Y = 3; +const COLLAPSED_GRID_MAX_HEIGHT = 120; +const EXPANDED_GRID_MAX_HEIGHT = 240; + +export function GraphEditorPopover({ + children, + side, + open, + onOpenChange, + value, + message, + componentOptions, + activeComponentKey, + onActiveComponentKeyChange, + onPreviewValue, + onCommitValue, + onCancelPreview, +}: { + children: React.ReactNode; + side?: "top" | "bottom" | "left" | "right"; + open?: boolean; + onOpenChange?: (open: boolean) => void; + value: NormalizedCubicBezier | null; + message: string; + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + onActiveComponentKeyChange?: (componentKey: string) => void; + onPreviewValue?: (value: NormalizedCubicBezier) => void; + onCommitValue?: (value: NormalizedCubicBezier) => void; + onCancelPreview?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const custom = useCustomPresets(); + const allPresets = [...BUILTIN_PRESETS, ...custom]; + const canEdit = value !== null; + const activePresetId = + value == null + ? null + : (allPresets.find((preset) => + preset.value.every( + (presetValue, index) => + Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE, + ), + )?.id ?? null); + + return ( + <Popover + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + onCancelPreview?.(); + } + onOpenChange?.(nextOpen); + }} + > + {children} + <PopoverContent + side={side} + sideOffset={8} + className="w-60 overflow-hidden px-0" + > + {componentOptions.length > 1 && ( + <div className="border-b px-3 py-2"> + <div className="bg-muted/40 inline-flex rounded-md p-0.5"> + {componentOptions.map((component) => ( + <button + key={component.key} + type="button" + onClick={() => onActiveComponentKeyChange?.(component.key)} + className={cn( + "cursor-pointer rounded-sm px-2 py-1 text-xs font-medium", + activeComponentKey === component.key + ? "bg-background text-foreground shadow-xs" + : "text-muted-foreground hover:text-foreground", + )} + > + {component.label} + </button> + ))} + </div> + </div> + )} + + <div className="px-3 py-3"> + {value ? ( + <BezierGraph + value={value} + onChange={onPreviewValue} + onChangeEnd={onCommitValue} + onCancel={onCancelPreview} + /> + ) : ( + <GraphEditorEmptyState message={message} /> + )} + </div> + + <Tabs variant="underline" defaultValue="presets" className="flex flex-col gap-2"> + <TabsList className="px-3"> + <TabsTrigger value="presets" className="text-xs"> + Presets + </TabsTrigger> + <TabsTrigger value="saved" className="text-xs"> + Saved + </TabsTrigger> + </TabsList> + <TabsContent value="presets" className="px-3 pb-0"> + <ExpandableGrid + isExpanded={isExpanded} + shouldExpand={BUILTIN_PRESETS.length > COLLAPSED_MAX} + onExpand={() => setIsExpanded(true)} + > + {BUILTIN_PRESETS.map((preset) => ( + <PresetItem + key={preset.id} + preset={preset} + isActive={activePresetId === preset.id} + disabled={!canEdit} + onSelect={() => onCommitValue?.(preset.value)} + /> + ))} + </ExpandableGrid> + </TabsContent> + <TabsContent value="saved" className="px-3"> + <div className="grid grid-cols-3 gap-1"> + {custom.map((preset) => ( + <PresetItem + key={preset.id} + preset={preset} + isActive={activePresetId === preset.id} + disabled={!canEdit} + onSelect={() => onCommitValue?.(preset.value)} + onDelete={() => removePreset({ id: preset.id })} + /> + ))} + <button + type="button" + onClick={() => value && savePreset({ value })} + disabled={!canEdit} + className={cn( + "text-muted-foreground flex flex-col items-center justify-center gap-1 rounded-sm px-1 py-1", + canEdit + ? "hover:bg-foreground/5 cursor-pointer" + : "cursor-not-allowed opacity-50", + )} + > + <div className="border-foreground/10 flex aspect-video w-full items-center justify-center rounded-sm border border-dashed"> + <HugeiconsIcon + icon={PlusSignIcon} + className="size-3.5 opacity-40" + /> + </div> + <span className="text-[10px] leading-tight">Save</span> + </button> + </div> + </TabsContent> + </Tabs> + </PopoverContent> + </Popover> + ); +} + +function GraphEditorEmptyState({ message }: { message: string }) { + return ( + <div + style={{ minHeight: BEZIER_GRAPH_MIN_HEIGHT }} + className="bg-muted/20 text-muted-foreground flex items-center justify-center rounded-sm border border-dashed px-3 text-center text-xs leading-relaxed" + > + {message} + </div> + ); +} + +function ExpandableGrid({ + children, + isExpanded, + shouldExpand, + onExpand, +}: { + children: React.ReactNode; + isExpanded: boolean; + shouldExpand: boolean; + onExpand: () => void; +}) { + const gridStyle = shouldExpand + ? isExpanded + ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const } + : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const } + : undefined; + + return ( + <div className="relative"> + <div className="grid grid-cols-3 gap-1" style={gridStyle}> + {children} + </div> + {!isExpanded && shouldExpand && ( + <div className="from-popover/0 to-popover absolute inset-x-0 bottom-0 flex h-8 items-center justify-center bg-linear-to-b"> + <Button + variant="ghost" + size="icon" + className="size-5" + onClick={onExpand} + > + <HugeiconsIcon + icon={ArrowDown01Icon} + className="text-muted-foreground size-3" + /> + </Button> + </div> + )} + </div> + ); +} + +function PresetItem({ + preset, + isActive, + onSelect, + onDelete, + disabled, +}: { + preset: EasingPreset; + isActive: boolean; + onSelect: () => void; + onDelete?: () => void; + disabled?: boolean; +}) { + return ( + <button + type="button" + onClick={onSelect} + disabled={disabled} + className={cn( + "group relative flex flex-col items-center gap-1 rounded-sm px-1 py-1", + disabled + ? "cursor-not-allowed opacity-50" + : "hover:bg-foreground/5 cursor-pointer", + isActive && "bg-primary/5! text-primary", + )} + > + <div + className={cn( + "flex aspect-video w-full items-center justify-center rounded-sm bg-foreground/5", + isActive && "bg-primary/5!", + )} + > + <CurveThumb value={preset.value} /> + </div> + <span + className={cn( + "text-[10px] leading-tight", + isActive ? "text-primary" : "text-muted-foreground", + )} + > + {preset.label} + </span> + {onDelete && ( + <Button + variant="destructive" + size="icon" + className="absolute -right-0.5 -top-0.5 hidden size-4.5 rounded-full [&_svg]:size-3 group-hover:flex" + onClick={(event) => { + event.stopPropagation(); + onDelete(); + }} + > + <HugeiconsIcon icon={Delete02Icon} /> + </Button> + )} + </button> + ); +} + +function toThumbX({ value }: { value: number }) { + return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2); +} + +function toThumbY({ value }: { value: number }) { + return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2); +} + +function CurveThumb({ value }: { value: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= THUMB_SEGMENTS; i++) { + const progress = i / THUMB_SEGMENTS; + const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) }); + const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) }); + points.push(`${x},${y}`); + } + return ( + <svg + width={THUMB_WIDTH} + height={THUMB_HEIGHT} + viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`} + > + <title>Curve preset preview</title> + <path + d={`M${points.join("L")}`} + fill="none" + className="stroke-current" + strokeWidth={1.5} + strokeLinecap="round" + /> + </svg> + ); +} diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts new file mode 100644 index 00000000..d7c6b001 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/session.ts @@ -0,0 +1,704 @@ +import { + getCurveHandlesForNormalizedCubicBezier, + getEditableScalarChannels, + getEasingModeForKind, + getNormalizedCubicBezierForScalarSegment, + getScalarKeyframeContext, + updateScalarKeyframeCurve, +} from "@/animation"; +import type { + AnimationPath, + ElementAnimations, + NormalizedCubicBezier, + ScalarCurveKeyframePatch, + ScalarGraphKeyframeContext, + SelectedKeyframeRef, +} from "@/animation/types"; +import type { SceneTracks, TimelineElement } from "@/timeline"; + +const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1]; +const FLAT_VALUE_EPSILON = 1e-6; +const LINEAR_CURVE_EPSILON = 1e-6; + +export type GraphEditorUnavailableReason = + | "no-keyframe-selected" + | "multiple-keyframes-selected" + | "selected-keyframes-span-multiple-elements" + | "selected-keyframes-are-not-adjacent" + | "selected-properties-have-no-shared-component" + | "selected-element-missing" + | "selected-element-has-no-animations" + | "selected-keyframe-has-no-scalar-channel" + | "selected-keyframe-missing-on-channel" + | "selected-keyframe-has-no-next-segment" + | "selected-segment-is-hold" + | "selected-segment-is-flat"; + +export interface GraphEditorComponentOption { + key: string; + label: string; +} + +interface GraphEditorPropertyOption { + key: string; + label: string; + context: ScalarGraphKeyframeContext; + allContexts: ScalarGraphKeyframeContext[]; +} + +export interface GraphEditorResolvedSegment { + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + context: ScalarGraphKeyframeContext; + allContexts: ScalarGraphKeyframeContext[]; + cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; +} + +interface GraphEditorBaseSelectionState { + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + message: string; +} + +export interface GraphEditorUnavailableState + extends GraphEditorBaseSelectionState { + status: "unavailable"; + reason: GraphEditorUnavailableReason; +} + +export interface GraphEditorReadyState extends GraphEditorBaseSelectionState { + status: "ready"; + trackId: string; + elementId: string; + element: TimelineElement; + segments: GraphEditorResolvedSegment[]; + cubicBezier: NormalizedCubicBezier; +} + +export type GraphEditorSelectionState = + | GraphEditorUnavailableState + | GraphEditorReadyState; + +export interface GraphEditorCurvePatch { + keyframeId: string; + patch: ScalarCurveKeyframePatch; +} + +function createUnavailableState({ + reason, + message, + componentOptions = [], + activeComponentKey = null, +}: { + reason: GraphEditorUnavailableReason; + message: string; + componentOptions?: GraphEditorComponentOption[]; + activeComponentKey?: string | null; +}): GraphEditorUnavailableState { + return { + status: "unavailable", + reason, + message, + componentOptions, + activeComponentKey, + }; +} + +function findElementByKeyframe({ + tracks, + keyframe, +}: { + tracks: SceneTracks; + keyframe: SelectedKeyframeRef; +}): { element: TimelineElement; trackId: string; elementId: string } | null { + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { + if (track.id !== keyframe.trackId) { + continue; + } + + const element = track.elements.find( + (trackElement) => trackElement.id === keyframe.elementId, + ); + if (!element) { + return null; + } + + return { + element, + trackId: track.id, + elementId: element.id, + }; + } + + return null; +} + +function findKeyframeTime({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations; + propertyPath: AnimationPath; + keyframeId: string; +}): number | null { + const binding = animations.bindings[propertyPath]; + if (!binding) return null; + + for (const component of binding.components) { + const channel = animations.channels[component.channelId]; + if (channel?.kind !== "scalar") continue; + const key = channel.keys.find((k) => k.id === keyframeId); + if (key !== undefined) return key.time; + } + + return null; +} + +function groupSelectedKeyframesByProperty({ + selectedKeyframes, +}: { + selectedKeyframes: SelectedKeyframeRef[]; +}) { + const groups = new Map< + string, + { + trackId: string; + elementId: string; + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframes: SelectedKeyframeRef[]; + } + >(); + + for (const keyframe of selectedKeyframes) { + const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`; + const existingGroup = groups.get(groupKey); + if (existingGroup) { + existingGroup.keyframes.push(keyframe); + continue; + } + + groups.set(groupKey, { + trackId: keyframe.trackId, + elementId: keyframe.elementId, + propertyPath: keyframe.propertyPath, + keyframes: [keyframe], + }); + } + + return [...groups.values()]; +} + +function getComponentLabel({ componentKey }: { componentKey: string }): string { + switch (componentKey) { + case "value": + return "Value"; + default: + return componentKey.toUpperCase(); + } +} + +/** + * Returns the absolute value span of the nearest non-flat adjacent segment, + * used as the Y-axis scale when editing a flat segment in the graph editor. + * Falls back to 1.0 if all surrounding segments are also flat. + */ +function getReferenceSpanValue({ + context, +}: { + context: ScalarGraphKeyframeContext; +}): number { + const sorted = [...context.channel.keys].sort((a, b) => a.time - b.time); + const leftIndex = sorted.findIndex((k) => k.id === context.keyframe.id); + const rightIndex = context.nextKey + ? sorted.findIndex((k) => k.id === context.nextKey?.id) + : -1; + + for (let i = leftIndex - 1; i >= 0; i--) { + const span = Math.abs(sorted[i + 1].value - sorted[i].value); + if (span > FLAT_VALUE_EPSILON) return span; + } + + if (rightIndex !== -1) { + for (let i = rightIndex; i < sorted.length - 1; i++) { + const span = Math.abs(sorted[i + 1].value - sorted[i].value); + if (span > FLAT_VALUE_EPSILON) return span; + } + } + + return 1.0; +} + +interface GraphEditorPropertySelection { + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + secondaryKeyframeId: string | null; + options: GraphEditorPropertyOption[]; +} + +function resolvePropertySelection({ + element, + propertyKeyframes, +}: { + element: TimelineElement; + propertyKeyframes: ReturnType< + typeof groupSelectedKeyframesByProperty + >[number]; +}): + | GraphEditorPropertySelection + | { + reason: GraphEditorUnavailableReason; + message: string; + } { + if (propertyKeyframes.keyframes.length > 2) { + return { + reason: "multiple-keyframes-selected", + message: "Select at most two adjacent keyframes per property.", + }; + } + + if (!element.animations) { + return { + reason: "selected-element-has-no-animations", + message: "The selected keyframe has no editable graph.", + }; + } + + const scalarResult = getEditableScalarChannels({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + }); + if (!scalarResult || scalarResult.channels.length === 0) { + return { + reason: "selected-keyframe-has-no-scalar-channel", + message: "The selected keyframe has no editable graph channel.", + }; + } + + const primaryKeyframe = propertyKeyframes.keyframes[0]; + let resolvedKeyframeId = primaryKeyframe.keyframeId; + let secondaryKeyframeId = + propertyKeyframes.keyframes.length === 2 + ? propertyKeyframes.keyframes[1].keyframeId + : null; + + if (secondaryKeyframeId !== null) { + const time1 = findKeyframeTime({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + keyframeId: primaryKeyframe.keyframeId, + }); + const time2 = findKeyframeTime({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + keyframeId: secondaryKeyframeId, + }); + if (time2 !== null && (time1 === null || time2 < time1)) { + resolvedKeyframeId = secondaryKeyframeId; + secondaryKeyframeId = primaryKeyframe.keyframeId; + } + } + + const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; + const easingMode = getEasingModeForKind(resolvedBinding.kind); + const contexts = scalarChannels.flatMap((channel) => { + const context = getScalarKeyframeContext({ + animations: element.animations, + propertyPath: propertyKeyframes.propertyPath, + componentKey: channel.componentKey, + keyframeId: resolvedKeyframeId, + }); + if (!context) { + return []; + } + + return [ + { + context, + option: { + key: channel.componentKey, + label: getComponentLabel({ componentKey: channel.componentKey }), + }, + }, + ]; + }); + + if (contexts.length === 0) { + return { + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + }; + } + + // For shared-easing bindings (e.g. color), all components always use the same + // curve. Collapse them to a single "value" option so the key is compatible with + // single-component scalar bindings (e.g. opacity), enabling mixed selections. + const options = + easingMode === "shared" + ? [ + { + key: "value", + label: "Curve", + context: contexts[0].context, + allContexts: contexts.map(({ context }) => context), + }, + ] + : contexts.map(({ context, option }) => ({ + key: option.key, + label: option.label, + context, + allContexts: [context], + })); + + return { + propertyPath: propertyKeyframes.propertyPath, + keyframeId: resolvedKeyframeId, + secondaryKeyframeId, + options, + }; +} + +function isLinearCurve({ + cubicBezier, +}: { + cubicBezier: NormalizedCubicBezier; +}): boolean { + return ( + Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON + ); +} + +function resolveSegmentForOption({ + propertySelection, + componentKey, +}: { + propertySelection: GraphEditorPropertySelection; + componentKey: string; +}): + | { + segment: GraphEditorResolvedSegment; + } + | { + reason: GraphEditorUnavailableReason; + message: string; + } { + const option = propertySelection.options.find( + (propertyOption) => propertyOption.key === componentKey, + ); + if (!option) { + return { + reason: "selected-properties-have-no-shared-component", + message: "Selected properties do not share a graph-editable channel.", + }; + } + + if (!option.context.nextKey) { + return { + reason: "selected-keyframe-has-no-next-segment", + message: "Select a keyframe that has an outgoing segment.", + }; + } + + if ( + propertySelection.secondaryKeyframeId !== null && + option.context.nextKey.id !== propertySelection.secondaryKeyframeId + ) { + return { + reason: "selected-keyframes-are-not-adjacent", + message: "Selected keyframes must be adjacent on each property.", + }; + } + + if (option.context.keyframe.segmentToNext === "step") { + return { + reason: "selected-segment-is-hold", + message: "Hold segments have a fixed value - easing has no effect here.", + }; + } + + const referenceSpanValue = getReferenceSpanValue({ context: option.context }); + const cubicBezier = + option.context.keyframe.segmentToNext === "linear" + ? GRAPH_LINEAR_CURVE + : getNormalizedCubicBezierForScalarSegment({ + leftKey: option.context.keyframe, + rightKey: option.context.nextKey, + referenceSpanValue, + }); + if (!cubicBezier) { + return { + reason: "selected-segment-is-flat", + message: + "Cannot edit a segment where both keyframes are at the same time.", + }; + } + + return { + segment: { + propertyPath: propertySelection.propertyPath, + keyframeId: propertySelection.keyframeId, + context: option.context, + allContexts: option.allContexts, + cubicBezier, + referenceSpanValue, + }, + }; +} + +export function resolveGraphEditorSelectionState({ + tracks, + selectedKeyframes, + preferredComponentKey, +}: { + tracks: SceneTracks; + selectedKeyframes: SelectedKeyframeRef[]; + preferredComponentKey?: string | null; +}): GraphEditorSelectionState { + if (selectedKeyframes.length === 0) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + const propertyKeyframes = groupSelectedKeyframesByProperty({ + selectedKeyframes, + }); + const primaryKeyframe = propertyKeyframes[0]?.keyframes[0]; + if (!primaryKeyframe) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + const selectedElement = findElementByKeyframe({ + tracks, + keyframe: primaryKeyframe, + }); + if (!selectedElement) { + return createUnavailableState({ + reason: "selected-element-missing", + message: "The selected keyframe could not be resolved.", + }); + } + + const spansMultipleElements = propertyKeyframes.some( + (propertySelection) => + propertySelection.trackId !== selectedElement.trackId || + propertySelection.elementId !== selectedElement.elementId, + ); + if (spansMultipleElements) { + return createUnavailableState({ + reason: "selected-keyframes-span-multiple-elements", + message: "Selected keyframes must be on the same element.", + }); + } + + const propertySelections = propertyKeyframes.map((propertySelection) => + resolvePropertySelection({ + element: selectedElement.element, + propertyKeyframes: propertySelection, + }), + ); + const unavailablePropertySelection = propertySelections.find( + (propertySelection) => "reason" in propertySelection, + ); + if ( + unavailablePropertySelection && + "reason" in unavailablePropertySelection + ) { + return createUnavailableState({ + reason: unavailablePropertySelection.reason, + message: unavailablePropertySelection.message, + }); + } + + const resolvedPropertySelections = propertySelections.filter( + (propertySelection): propertySelection is GraphEditorPropertySelection => + !("reason" in propertySelection), + ); + const sharedComponentOptions = + resolvedPropertySelections[0]?.options.filter((componentOption) => + resolvedPropertySelections.every((propertySelection) => + propertySelection.options.some( + (option) => option.key === componentOption.key, + ), + ), + ) ?? []; + const componentOptions = sharedComponentOptions.map(({ key, label }) => ({ + key, + label, + })); + if (componentOptions.length === 0) { + return createUnavailableState({ + reason: "selected-properties-have-no-shared-component", + message: "Selected properties do not share a graph-editable channel.", + }); + } + + // Try each component option in preference order (preferred first, then the rest) + // and stop at the first key where every property resolves to a valid segment. + // This single pass both selects the active key and produces the segment list. + const candidateKeys = [ + ...(preferredComponentKey && + componentOptions.some((option) => option.key === preferredComponentKey) + ? [preferredComponentKey] + : []), + ...componentOptions + .filter((option) => option.key !== preferredComponentKey) + .map((option) => option.key), + ]; + + let activeComponentKey = componentOptions[0].key; + let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = []; + + for (const candidateKey of candidateKeys) { + const results = resolvedPropertySelections.map((propertySelection) => + resolveSegmentForOption({ + propertySelection, + componentKey: candidateKey, + }), + ); + activeComponentKey = candidateKey; + segmentResults = results; + if (results.every((result) => "segment" in result)) { + break; + } + } + + const unavailableSegment = segmentResults.find( + (result) => !("segment" in result), + ); + if (unavailableSegment && !("segment" in unavailableSegment)) { + return createUnavailableState({ + reason: unavailableSegment.reason, + message: unavailableSegment.message, + componentOptions, + activeComponentKey, + }); + } + + const segments = segmentResults.flatMap((result) => + "segment" in result ? [result.segment] : [], + ); + const primarySegment = segments[0]; + if (!primarySegment) { + return createUnavailableState({ + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + componentOptions, + activeComponentKey, + }); + } + + return { + status: "ready", + message: + segments.length === 1 + ? "Edit graph" + : `Edit graph for ${segments.length} properties`, + componentOptions, + activeComponentKey, + trackId: selectedElement.trackId, + elementId: selectedElement.elementId, + element: selectedElement.element, + segments, + cubicBezier: primarySegment.cubicBezier, + }; +} + +export function buildGraphEditorCurvePatches({ + context, + cubicBezier, + referenceSpanValue, +}: { + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; +}): GraphEditorCurvePatch[] | null { + if (!context.nextKey) { + return null; + } + + if (isLinearCurve({ cubicBezier })) { + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "linear", + rightHandle: null, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: null, + }, + }, + ]; + } + + const handles = getCurveHandlesForNormalizedCubicBezier({ + leftKey: context.keyframe, + rightKey: context.nextKey, + cubicBezier, + referenceSpanValue, + }); + if (!handles) { + return null; + } + + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "bezier", + rightHandle: handles.rightHandle, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: handles.leftHandle, + }, + }, + ]; +} + +export function applyGraphEditorCurvePreview({ + animations, + context, + cubicBezier, + referenceSpanValue, +}: { + animations: ElementAnimations | undefined; + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; + referenceSpanValue: number; +}): ElementAnimations | undefined { + const patches = buildGraphEditorCurvePatches({ + context, + cubicBezier, + referenceSpanValue, + }); + if (!patches) { + return animations; + } + + return patches.reduce<ElementAnimations | undefined>( + (nextAnimations, { keyframeId, patch }) => + updateScalarKeyframeCurve({ + animations: nextAnimations, + propertyPath: context.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + }), + animations, + ); +} diff --git a/apps/web/src/timeline/components/graph-editor/use-controller.ts b/apps/web/src/timeline/components/graph-editor/use-controller.ts new file mode 100644 index 00000000..ea091485 --- /dev/null +++ b/apps/web/src/timeline/components/graph-editor/use-controller.ts @@ -0,0 +1,178 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { registerCanceller } from "@/editor/cancel-interaction"; +import type { NormalizedCubicBezier } from "@/animation/types"; +import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; +import { + applyGraphEditorCurvePreview, + buildGraphEditorCurvePatches, + resolveGraphEditorSelectionState, + type GraphEditorSelectionState, +} from "./session"; + +export function useGraphEditorController() { + const editor = useEditor(); + const renderTracks = useEditor( + (currentEditor) => + currentEditor.timeline.getPreviewTracks() ?? + currentEditor.scenes.getActiveScene().tracks, + ); + const { selectedKeyframes } = useKeyframeSelection(); + const [open, setOpen] = useState(false); + const [activeComponentKey, setActiveComponentKey] = useState<string | null>( + null, + ); + const hasPreviewRef = useRef(false); + + const state = useMemo<GraphEditorSelectionState>( + () => + resolveGraphEditorSelectionState({ + tracks: renderTracks, + selectedKeyframes, + preferredComponentKey: activeComponentKey, + }), + [activeComponentKey, renderTracks, selectedKeyframes], + ); + + const stateKey = + state.status === "ready" + ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments + .map( + (segment) => + `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`, + ) + .join("|")}` + : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`; + const previousStateKeyRef = useRef(stateKey); + + const discardPreview = useCallback(() => { + if (!hasPreviewRef.current) { + return; + } + + editor.timeline.discardPreview(); + hasPreviewRef.current = false; + }, [editor]); + + useEffect(() => { + if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) { + discardPreview(); + } + + previousStateKeyRef.current = stateKey; + }, [discardPreview, stateKey]); + + useEffect(() => { + if (!open) { + return; + } + + return registerCanceller({ + fn: () => { + discardPreview(); + setOpen(false); + }, + }); + }, [discardPreview, open]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + discardPreview(); + } + + setOpen(nextOpen); + }, + [discardPreview], + ); + + const handleActiveComponentKeyChange = useCallback( + (nextComponentKey: string) => { + discardPreview(); + setActiveComponentKey(nextComponentKey); + }, + [discardPreview], + ); + + const handlePreviewValue = useCallback( + (nextValue: NormalizedCubicBezier) => { + if (state.status !== "ready") { + return; + } + + const nextAnimations = state.segments.reduce( + (animations, segment) => + segment.allContexts.reduce( + (nextAnimationsForSegment, context) => + applyGraphEditorCurvePreview({ + animations: nextAnimationsForSegment, + context, + cubicBezier: nextValue, + referenceSpanValue: segment.referenceSpanValue, + }), + animations, + ), + state.element.animations, + ); + editor.timeline.previewElements({ + updates: [ + { + trackId: state.trackId, + elementId: state.elementId, + updates: { animations: nextAnimations }, + }, + ], + }); + hasPreviewRef.current = true; + }, + [editor, state], + ); + + const handleCommitValue = useCallback( + (nextValue: NormalizedCubicBezier) => { + if (state.status !== "ready") { + return; + } + + editor.timeline.updateKeyframeCurves({ + keyframes: state.segments.flatMap((segment) => { + const patches = buildGraphEditorCurvePatches({ + context: segment.context, + cubicBezier: nextValue, + referenceSpanValue: segment.referenceSpanValue, + }); + if (!patches) { + return []; + } + + return segment.allContexts.flatMap((context) => + patches.map(({ keyframeId, patch }) => ({ + trackId: state.trackId, + elementId: state.elementId, + propertyPath: segment.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + })), + ); + }), + }); + hasPreviewRef.current = false; + }, + [editor, state], + ); + + return { + open, + onOpenChange: handleOpenChange, + canOpen: state.status === "ready", + tooltip: state.status === "ready" ? "Open graph editor" : state.message, + state, + onActiveComponentKeyChange: handleActiveComponentKeyChange, + onPreviewValue: handlePreviewValue, + onCommitValue: handleCommitValue, + onCancelPreview: discardPreview, + }; +} diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx new file mode 100644 index 00000000..e6a9df49 --- /dev/null +++ b/apps/web/src/timeline/components/index.tsx @@ -0,0 +1,946 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Delete02Icon, + MagicWand05Icon, + MusicNote03Icon, + TaskAdd02Icon, + TextIcon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react"; +import { OcShapesIcon, OcVideoIcon } from "@/components/icons"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { useTimelineZoom } from "@/timeline/hooks/use-timeline-zoom"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import type { ElementDragState, DropTarget } from "@/timeline"; +import { TimelineTrackContent } from "./timeline-track"; +import { TimelinePlayhead } from "./timeline-playhead"; +import { SelectionBox } from "@/selection/selection-box"; +import { useBoxSelect } from "@/selection/hooks/use-box-select"; +import { SnapIndicator } from "./snap-indicator"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineTrack } from "@/timeline"; +import { + TIMELINE_SCROLLBAR_SIZE_PX, + TIMELINE_CONTENT_TOP_PADDING_PX, + TIMELINE_TRACK_GAP_PX, + TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX, + KEYFRAME_LANE_HEIGHT_PX, +} from "./layout"; +import { useElementInteraction } from "@/timeline/hooks/element/use-element-interaction"; +import { + canTrackHaveAudio, + canTrackBeHidden, + getTimelineZoomMin, + getTimelinePaddingPx, +} from "@/timeline"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { + getTrackHeight, + getCumulativeHeightBefore, + getTotalTracksHeight, +} from "./track-layout"; +import { SELECTED_TRACK_ROW_CLASS } from "./theme"; +import { + computeTrackExpansionHeight, + getTrackExpandedRows, + getPropertyLabel, + type ExpandedRow, +} from "./expanded-layout"; +import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; +import { TimelineToolbar } from "./timeline-toolbar"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { useTimelineSeek } from "@/timeline/hooks/use-timeline-seek"; +import { useTimelineDragDrop } from "@/timeline/hooks/use-timeline-drag-drop"; +import { TimelineRuler } from "./timeline-ruler"; +import { + TimelineBookmarksRow, + useBookmarkDrag, +} from "@/timeline/bookmarks/index"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useInitialScrollBottom } from "@/timeline/hooks/use-initial-scroll-bottom"; +import { useTimelineResize } from "@/timeline/hooks/use-timeline-resize"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { useEditor } from "@/editor/use-editor"; +import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; +import { DragLine } from "./drag-line"; +import { invokeAction } from "@/actions"; +import { resolveTimelineElementIntersections } from "./selection-hit-testing"; +import { cn } from "@/utils/ui"; + +const TRACKS_CONTAINER_MAX_HEIGHT = 800; +const FALLBACK_CONTAINER_WIDTH = 1000; +const TRACKS_CONTAINER_HEIGHT = { min: 0, max: TRACKS_CONTAINER_MAX_HEIGHT }; +const TRACK_ICONS: Record<TimelineTrack["type"], ReactNode> = { + video: <OcVideoIcon className="text-muted-foreground size-4 shrink-0" />, + text: ( + <HugeiconsIcon + icon={TextIcon} + className="text-muted-foreground size-4 shrink-0" + /> + ), + audio: ( + <HugeiconsIcon + icon={MusicNote03Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), + graphic: <OcShapesIcon className="text-muted-foreground size-4 shrink-0" />, + effect: ( + <HugeiconsIcon + icon={MagicWand05Icon} + className="text-muted-foreground size-4 shrink-0" + /> + ), +}; + +export function Timeline() { + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const { + selectedElements, + clearElementSelection, + setElementSelection, + mergeElementsIntoSelection, + } = useElementSelection(); + const editor = useEditor(); + const timeline = editor.timeline; + const scene = useEditor((currentEditor) => + currentEditor.scenes.getActiveSceneOrNull(), + ); + const tracks = useMemo<TimelineTrack[]>( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const mainTrackId = scene?.tracks.main.id ?? null; + const seek = (time: number) => editor.playback.seek({ time }); + + const timelineRef = useRef<HTMLDivElement>(null); + const timelineHeaderRef = useRef<HTMLDivElement>(null); + const rulerRef = useRef<HTMLDivElement>(null); + const rulerScrollRef = useRef<HTMLDivElement>(null); + const tracksContainerRef = useRef<HTMLDivElement>(null); + const tracksScrollRef = useRef<HTMLDivElement>(null); + const trackLabelsRef = useRef<HTMLDivElement>(null); + const playheadRef = useRef<HTMLDivElement>(null); + const trackLabelsScrollRef = useRef<HTMLDivElement>(null); + + const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>( + null, + ); + + const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { + setCurrentSnapPoint(snapPoint); + }, []); + + const timelineDuration = timeline.getTotalDuration() || 0; + const minZoomLevel = getTimelineZoomMin({ + duration: timelineDuration, + containerWidth: tracksContainerRef.current?.clientWidth, + }); + + const savedViewState = editor.project.getTimelineViewState(); + + const { zoomLevel, setZoomLevel, handleWheel, saveScrollPosition } = + useTimelineZoom({ + containerRef: timelineRef, + minZoom: minZoomLevel, + initialZoom: savedViewState?.zoomLevel, + initialScrollLeft: savedViewState?.scrollLeft, + initialPlayheadTime: savedViewState?.playheadTime, + tracksScrollRef, + rulerScrollRef, + }); + const { isResizing, handleResizeStart } = useTimelineResize({ + zoomLevel, + onSnapPointChange: handleSnapPointChange, + }); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + + const getTrackExpansionHeight = useCallback( + (trackIndex: number) => { + const track = tracks[trackIndex]; + if (!track) return 0; + return computeTrackExpansionHeight({ track, expandedElementIds }); + }, + [tracks, expandedElementIds], + ); + + // Stable refs so the wheel listener never goes stale + const setZoomLevelRef = useRef(setZoomLevel); + useEffect(() => { + setZoomLevelRef.current = setZoomLevel; + }, [setZoomLevel]); + + const saveScrollPositionRef = useRef(saveScrollPosition); + useEffect(() => { + saveScrollPositionRef.current = saveScrollPosition; + }, [saveScrollPosition]); + + const minZoomLevelRef = useRef(minZoomLevel); + useEffect(() => { + minZoomLevelRef.current = minZoomLevel; + }, [minZoomLevel]); + + // Pushes tracks scroll position to the two overflow:hidden followers + // (ruler and track labels). Called from the wheel handler (before paint, + // zero lag) and from onScroll on the tracks area (covers scrollbar drag). + const syncFollowers = useCallback(() => { + const tracks = tracksScrollRef.current; + if (!tracks) return; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = tracks.scrollLeft; + } + if (trackLabelsScrollRef.current) { + trackLabelsScrollRef.current.scrollTop = tracks.scrollTop; + } + }, []); + + // Single non-passive capture listener owns all wheel input. Prevents any + // native scroll or browser zoom from firing inside the timeline. + useEffect(() => { + const container = timelineRef.current; + if (!container) return; + + let pendingZoomDelta = 0; + let zoomRafId: ReturnType<typeof requestAnimationFrame> | null = null; + + const onWheel = (e: WheelEvent) => { + const isZoom = e.ctrlKey || e.metaKey; + + if (isZoom) { + e.preventDefault(); + const normalizedDelta = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + pendingZoomDelta += normalizedDelta; + + if (zoomRafId === null) { + zoomRafId = requestAnimationFrame(() => { + const frameRawDelta = pendingZoomDelta; + const cappedDelta = + Math.sign(frameRawDelta) * Math.min(Math.abs(frameRawDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + setZoomLevelRef.current((prev) => prev * zoomFactor); + pendingZoomDelta = 0; + zoomRafId = null; + }); + } + return; + } + + const tracks = tracksScrollRef.current; + if (!tracks) return; + + const isHorizontal = + e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY); + + e.preventDefault(); + + if (isHorizontal) { + const raw = + Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + const clamped = + Math.sign(raw) * + Math.min(Math.abs(raw), TIMELINE_HORIZONTAL_WHEEL_STEP_PX); + tracks.scrollLeft = Math.max(0, tracks.scrollLeft + clamped); + } else { + tracks.scrollTop = Math.max(0, tracks.scrollTop + e.deltaY); + } + + syncFollowers(); + saveScrollPositionRef.current(); + }; + + container.addEventListener("wheel", onWheel, { + passive: false, + capture: true, + }); + return () => { + container.removeEventListener("wheel", onWheel, { capture: true }); + if (zoomRafId !== null) cancelAnimationFrame(zoomRafId); + }; + }, [syncFollowers]); + + useInitialScrollBottom({ + tracksScrollRef, + trackLabelsScrollRef, + onAfterScroll: () => saveScrollPositionRef.current(), + isReady: tracks.length > 0, + }); + + const { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + } = useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { + dragState: bookmarkDragState, + handleBookmarkMouseDown, + lastMouseXRef: bookmarkLastMouseXRef, + } = useBookmarkDrag({ + zoomLevel, + scrollRef: tracksScrollRef, + snappingEnabled, + onSnapPointChange: handleSnapPointChange, + }); + + const { handleRulerMouseDown: handlePlayheadRulerMouseDown } = + useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + const { isDragOver, dropTarget, dragProps } = useTimelineDragDrop({ + containerRef: tracksContainerRef, + tracksScrollRef, + zoomLevel, + }); + + const { + selectionBox, + handleMouseDown: handleSelectionMouseDown, + isSelecting, + shouldIgnoreClick, + } = useBoxSelect({ + containerRef: tracksContainerRef, + selectedIds: selectedElements, + anchorId: null, + getIsAdditiveSelection: (event) => + event.shiftKey || event.ctrlKey || event.metaKey, + resolveIntersections: ({ startPos, currentPos }) => { + if (!tracksContainerRef.current) { + return []; + } + + return resolveTimelineElementIntersections({ + container: tracksContainerRef.current, + scrollContainer: tracksScrollRef.current, + tracks, + zoomLevel, + startPos, + currentPos, + }); + }, + onSelectionChange: ({ intersectedIds, isAdditive }) => { + if (isAdditive) { + mergeElementsIntoSelection({ elements: intersectedIds }); + } else { + setElementSelection({ elements: intersectedIds }); + } + }, + }); + + const containerWidth = + tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; + const contentWidth = timelineTimeToPixels({ + time: timelineDuration, + zoomLevel, + }); + const paddingPx = getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom: minZoomLevel, + }); + const dynamicTimelineWidth = Math.max( + contentWidth + paddingPx, + containerWidth, + ); + const tracksViewportWidth = + tracksScrollRef.current?.clientWidth ?? + tracksContainerRef.current?.clientWidth ?? + containerWidth; + const hasHorizontalScrollbar = dynamicTimelineWidth > tracksViewportWidth; + + useEdgeAutoScroll({ + isActive: bookmarkDragState.isDragging, + getMouseClientX: () => bookmarkLastMouseXRef.current, + rulerScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + + const showSnapIndicator = + snappingEnabled && + currentSnapPoint !== null && + (dragState.isDragging || bookmarkDragState.isDragging || isResizing); + + const { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + } = useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration: timeline.getTotalDuration(), + isSelecting, + clearSelectedElements: clearElementSelection, + seek, + }); + + const timelineHeaderHeight = + (timelineHeaderRef.current?.getBoundingClientRect().height ?? 0) + + TIMELINE_CONTENT_TOP_PADDING_PX || 0; + + return ( + <section + className={ + "panel bg-background relative flex h-full flex-col overflow-hidden rounded-sm border" + } + {...dragProps} + aria-label="Timeline" + > + <TimelineToolbar + zoomLevel={zoomLevel} + minZoom={minZoomLevel} + setZoomLevel={({ zoom }) => setZoomLevel(zoom)} + /> + + <div className="relative flex flex-1 overflow-hidden" ref={timelineRef}> + <TrackLabelsPanel + trackLabelsRef={trackLabelsRef} + trackLabelsScrollRef={trackLabelsScrollRef} + timelineHeaderHeight={timelineHeaderHeight} + hasHorizontalScrollbar={hasHorizontalScrollbar} + getTrackExpansionHeight={getTrackExpansionHeight} + /> + + <div + className="relative isolate flex flex-1 flex-col overflow-hidden" + ref={tracksContainerRef} + > + <SelectionBox + startPos={selectionBox?.startPos || null} + currentPos={selectionBox?.currentPos || null} + containerRef={tracksContainerRef} + isActive={selectionBox?.isActive || false} + /> + <DragLine + dropTarget={dropTarget} + tracks={tracks} + isVisible={isDragOver && !dropTarget?.targetElement} + headerHeight={timelineHeaderHeight} + /> + <DragLine + dropTarget={dragDropTarget} + tracks={tracks} + isVisible={dragState.isDragging} + headerHeight={timelineHeaderHeight} + /> + + <div ref={rulerScrollRef} className="shrink-0 overflow-hidden"> + <div + ref={timelineHeaderRef} + className="flex flex-col" + style={{ width: `${dynamicTimelineWidth}px` }} + > + <TimelineRuler + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + rulerRef={rulerRef} + tracksScrollRef={rulerScrollRef} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + <TimelineBookmarksRow + zoomLevel={zoomLevel} + dynamicTimelineWidth={dynamicTimelineWidth} + dragState={bookmarkDragState} + onBookmarkMouseDown={handleBookmarkMouseDown} + handleWheel={handleWheel} + handleTimelineContentClick={handleRulerClick} + handleRulerTrackingMouseDown={handleRulerMouseDown} + handleRulerMouseDown={handlePlayheadRulerMouseDown} + /> + </div> + </div> + + <ScrollArea + className="flex-1" + ref={tracksScrollRef} + onScroll={() => { + syncFollowers(); + saveScrollPosition(); + }} + > + <div + className="flex min-h-full flex-col" + style={{ width: `${dynamicTimelineWidth}px` }} + > + {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system */} + <div + className="relative shrink-0" + style={{ + height: `${ + Math.max( + TRACKS_CONTAINER_HEIGHT.min, + Math.min( + TRACKS_CONTAINER_HEIGHT.max, + getTotalTracksHeight({ + tracks, + getExtraHeight: getTrackExpansionHeight, + }), + ), + ) + TIMELINE_CONTENT_TOP_PADDING_PX + }px`, + }} + onMouseDown={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={(event) => { + const isDirectTarget = event.target === event.currentTarget; + if (!isDirectTarget) return; + event.stopPropagation(); + handleTracksClick(event); + }} + > + {tracks.length > 0 && ( + <TimelineTrackRows + mainTrackId={mainTrackId} + zoomLevel={zoomLevel} + dragState={dragState} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onResizeStart={handleResizeStart} + onElementMouseDown={handleElementMouseDown} + onElementClick={handleElementClick} + onTrackMouseDown={(event) => { + handleSelectionMouseDown(event); + handleTracksMouseDown(event); + }} + onTrackMouseUp={handleTracksClick} + shouldIgnoreClick={shouldIgnoreClick} + isDragOver={isDragOver} + dropTarget={dropTarget} + /> + )} + </div> + <TimelineGutter + onMouseDown={(event) => { + handleTracksMouseDown(event); + handleSelectionMouseDown(event); + }} + onClick={handleTracksClick} + /> + </div> + </ScrollArea> + + <TimelinePlayhead + zoomLevel={zoomLevel} + hasHorizontalScrollbar={hasHorizontalScrollbar} + rulerRef={rulerRef} + rulerScrollRef={rulerScrollRef} + tracksScrollRef={tracksScrollRef} + timelineRef={timelineRef} + playheadRef={playheadRef} + isSnappingToPlayhead={ + showSnapIndicator && currentSnapPoint?.type === "playhead" + } + /> + </div> + <SnapIndicator + snapPoint={currentSnapPoint} + zoomLevel={zoomLevel} + timelineRef={timelineRef} + tracksScrollRef={tracksScrollRef} + isVisible={showSnapIndicator} + /> + </div> + </section> + ); +} + +function TrackLabelsPanel({ + trackLabelsRef, + trackLabelsScrollRef, + timelineHeaderHeight, + hasHorizontalScrollbar, + getTrackExpansionHeight, +}: { + trackLabelsRef: React.RefObject<HTMLDivElement | null>; + trackLabelsScrollRef: React.RefObject<HTMLDivElement | null>; + timelineHeaderHeight: number; + hasHorizontalScrollbar: boolean; + getTrackExpansionHeight: (trackIndex: number) => number; +}) { + const editor = useEditor(); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo<TimelineTrack[]>( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + const trackExpandedRowsMap = useMemo( + () => + tracks.map((track) => + getTrackExpandedRows({ track, expandedElementIds }), + ), + [tracks, expandedElementIds], + ); + + return ( + <div + className="flex shrink-0 flex-col border-r" + style={{ width: `${TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX}px` }} + > + <div + className="shrink-0" + style={{ height: timelineHeaderHeight || 48 }} + /> + <div ref={trackLabelsRef} className="flex-1 overflow-hidden"> + <div ref={trackLabelsScrollRef} className="size-full overflow-hidden"> + {tracks.length > 0 && ( + <div + className="flex flex-col" + style={{ gap: `${TIMELINE_TRACK_GAP_PX}px` }} + > + {tracks.map((track, index) => { + const expandedRows = trackExpandedRowsMap[index]; + const baseHeight = getTrackHeight({ type: track.type }); + + return ( + <div + key={track.id} + className={cn( + "group flex flex-col", + tracksWithSelection.has(track.id) && + SELECTED_TRACK_ROW_CLASS, + )} + style={{ + height: `${baseHeight + getTrackExpansionHeight(index)}px`, + }} + > + <div + className="flex shrink-0 items-center justify-end gap-2 px-3" + style={{ height: `${baseHeight}px` }} + > + {canTrackHaveAudio(track) && ( + <TrackToggleIcon + isOff={track.muted} + icons={{ + on: VolumeHighIcon, + off: VolumeOffIcon, + }} + onClick={() => + editor.timeline.toggleTrackMute({ + trackId: track.id, + }) + } + /> + )} + {canTrackBeHidden(track) && ( + <TrackToggleIcon + isOff={track.hidden} + icons={{ + on: ViewIcon, + off: ViewOffSlashIcon, + }} + onClick={() => + editor.timeline.toggleTrackVisibility({ + trackId: track.id, + }) + } + /> + )} + <TrackIcon track={track} /> + </div> + {expandedRows.length > 0 && ( + <PropertyTree rows={expandedRows} /> + )} + </div> + ); + })} + </div> + )} + </div> + </div> + <div + className="bg-background shrink-0" + style={{ + height: hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX : 0, + }} + /> + </div> + ); +} + +function TimelineTrackRows({ + mainTrackId, + zoomLevel, + dragState, + tracksScrollRef, + lastMouseXRef, + onResizeStart, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + isDragOver, + dropTarget, +}: { + mainTrackId: string | null; + zoomLevel: number; + dragState: ElementDragState; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + lastMouseXRef: React.RefObject<number>; + onResizeStart: React.ComponentProps< + typeof TimelineTrackContent + >["onResizeStart"]; + onElementMouseDown: React.ComponentProps< + typeof TimelineTrackContent + >["onElementMouseDown"]; + onElementClick: React.ComponentProps< + typeof TimelineTrackContent + >["onElementClick"]; + onTrackMouseDown: (event: React.MouseEvent) => void; + onTrackMouseUp: (event: React.MouseEvent) => void; + shouldIgnoreClick: () => boolean; + isDragOver: boolean; + dropTarget: DropTarget | null; +}) { + const timeline = useEditor((e) => e.timeline); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo<TimelineTrack[]>( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const { selectedElements } = useElementSelection(); + const tracksWithSelection = useMemo( + () => new Set(selectedElements.map((el) => el.trackId)), + [selectedElements], + ); + + const expandedElementIds = useTimelineStore((s) => s.expandedElementIds); + + const getTrackExpansionHeight = useCallback( + (trackIndex: number) => { + const track = tracks[trackIndex]; + if (!track) return 0; + return computeTrackExpansionHeight({ track, expandedElementIds }); + }, + [tracks, expandedElementIds], + ); + + const sortedTracks = useMemo(() => { + const draggingElementIds = new Set(dragState.dragElementIds); + return [...tracks] + .map((track, index) => ({ track, index })) + .sort((a, b) => { + const aHasDragged = a.track.elements.some((element) => + draggingElementIds.has(element.id), + ); + const bHasDragged = b.track.elements.some((element) => + draggingElementIds.has(element.id), + ); + if (aHasDragged) return 1; + if (bHasDragged) return -1; + return 0; + }); + }, [tracks, dragState.dragElementIds]); + + return ( + <> + {sortedTracks.map(({ track, index }) => ( + <ContextMenu key={track.id}> + <ContextMenuTrigger asChild> + <div + className={cn( + "absolute right-0 left-0 transition-colors", + tracksWithSelection.has(track.id) && SELECTED_TRACK_ROW_CLASS, + )} + style={{ + top: `${TIMELINE_CONTENT_TOP_PADDING_PX + getCumulativeHeightBefore({ tracks, trackIndex: index, getExtraHeight: getTrackExpansionHeight })}px`, + height: `${getTrackHeight({ type: track.type }) + getTrackExpansionHeight(index)}px`, + }} + > + <TimelineTrackContent + track={track} + zoomLevel={zoomLevel} + dragState={dragState} + rulerScrollRef={tracksScrollRef} + tracksScrollRef={tracksScrollRef} + lastMouseXRef={lastMouseXRef} + onResizeStart={onResizeStart} + onElementMouseDown={onElementMouseDown} + onElementClick={onElementClick} + onTrackMouseDown={onTrackMouseDown} + onTrackMouseUp={onTrackMouseUp} + shouldIgnoreClick={shouldIgnoreClick} + targetElementId={ + isDragOver + ? (dropTarget?.targetElement?.elementId ?? null) + : null + } + /> + </div> + </ContextMenuTrigger> + <ContextMenuContent className="w-40"> + <ContextMenuItem + icon={<HugeiconsIcon icon={TaskAdd02Icon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("paste-copied"); + }} + > + Paste elements + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={VolumeHighIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackMute({ trackId: track.id }); + }} + > + {canTrackHaveAudio(track) && track.muted + ? "Unmute track" + : "Mute track"} + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={ViewIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.toggleTrackVisibility({ trackId: track.id }); + }} + > + {canTrackBeHidden(track) && track.hidden + ? "Show track" + : "Hide track"} + </ContextMenuItem> + {track.id !== mainTrackId && ( + <ContextMenuItem + icon={<HugeiconsIcon icon={Delete02Icon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + timeline.removeTrack({ trackId: track.id }); + }} + variant="destructive" + > + Delete track + </ContextMenuItem> + )} + </ContextMenuContent> + </ContextMenu> + ))} + </> + ); +} + +function TimelineGutter({ + onMouseDown, + onClick, +}: { + onMouseDown: (event: React.MouseEvent) => void; + onClick: (event: React.MouseEvent) => void; +}) { + // biome-ignore lint/a11y/noStaticElementInteractions: canvas seek surface; keyboard seeking is handled by the global keybindings system + // biome-ignore lint/a11y/useKeyWithClickEvents: canvas seek surface; keyboard seeking is handled by the global keybindings system + return <div className="flex-1" onMouseDown={onMouseDown} onClick={onClick} />; +} + +function TrackIcon({ track }: { track: TimelineTrack }) { + return <>{TRACK_ICONS[track.type]}</>; +} + +function TrackToggleIcon({ + isOff, + icons, + onClick, +}: { + isOff: boolean; + icons: { + on: IconSvgElement; + off: IconSvgElement; + }; + onClick: () => void; +}) { + return ( + <> + {isOff ? ( + <HugeiconsIcon + icon={icons.off} + className="text-destructive size-4 cursor-pointer" + onClick={onClick} + /> + ) : ( + <HugeiconsIcon + icon={icons.on} + className="text-muted-foreground size-4 cursor-pointer" + onClick={onClick} + /> + )} + </> + ); +} + +function PropertyTree({ rows }: { rows: ExpandedRow[] }) { + return ( + <div className="flex flex-col overflow-hidden"> + {rows.map((row) => ( + <div + key={row.propertyPath} + className={cn("flex shrink-0 items-center px-3 bg-muted/50")} + style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} + > + <span className="text-muted-foreground truncate text-xs leading-none"> + {getPropertyLabel(row.propertyPath)} + </span> + </div> + ))} + </div> + ); +} diff --git a/apps/web/src/timeline/components/interaction.ts b/apps/web/src/timeline/components/interaction.ts new file mode 100644 index 00000000..fe0210a4 --- /dev/null +++ b/apps/web/src/timeline/components/interaction.ts @@ -0,0 +1,4 @@ +export const TIMELINE_DRAG_THRESHOLD_PX = 5; +export const TIMELINE_HORIZONTAL_WHEEL_STEP_PX = 40; +export const TIMELINE_ZOOM_BUTTON_FACTOR = 1.7; +export const TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD = 0.15; diff --git a/apps/web/src/timeline/components/layers.ts b/apps/web/src/timeline/components/layers.ts new file mode 100644 index 00000000..d59db412 --- /dev/null +++ b/apps/web/src/timeline/components/layers.ts @@ -0,0 +1,6 @@ +export const TIMELINE_LAYERS = { + trackContent: 10, + dragLine: 20, + playhead: 30, + snapIndicator: 40, +} as const; diff --git a/apps/web/src/timeline/components/layout.ts b/apps/web/src/timeline/components/layout.ts new file mode 100644 index 00000000..57a7f5df --- /dev/null +++ b/apps/web/src/timeline/components/layout.ts @@ -0,0 +1,20 @@ +import type { TrackType } from "@/timeline"; + +export const TIMELINE_TRACK_HEIGHTS_PX: Record<TrackType, number> = { + video: 65, + text: 25, + audio: 50, + graphic: 25, + effect: 25, +} as const; + +export const KEYFRAME_LANE_HEIGHT_PX = 20; +export const KEYFRAME_DIAMOND_SIZE_PX = 14; +export const EXPANDED_GROUP_HEADER_HEIGHT_PX = 18; + +export const TIMELINE_TRACK_GAP_PX = 6; +export const TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX = 112; +export const TIMELINE_RULER_HEIGHT_PX = 22; +export const TIMELINE_BOOKMARK_ROW_HEIGHT_PX = 16; +export const TIMELINE_SCROLLBAR_SIZE_PX = 12; +export const TIMELINE_CONTENT_TOP_PADDING_PX = 2; diff --git a/apps/web/src/timeline/components/selection-hit-testing.ts b/apps/web/src/timeline/components/selection-hit-testing.ts new file mode 100644 index 00000000..da5617c6 --- /dev/null +++ b/apps/web/src/timeline/components/selection-hit-testing.ts @@ -0,0 +1,135 @@ +import type { TimelineTrack } from "@/timeline"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { + TIMELINE_CONTENT_TOP_PADDING_PX, +} from "./layout"; +import { getCumulativeHeightBefore, getTrackHeight } from "./track-layout"; + +type TimelineElementRef = { trackId: string; elementId: string }; + +interface SelectionRectangle { + left: number; + top: number; + right: number; + bottom: number; +} + +function getNormalizedRectangle({ + startPos, + endPos, +}: { + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + return { + left: Math.min(startPos.x, endPos.x), + top: Math.min(startPos.y, endPos.y), + right: Math.max(startPos.x, endPos.x), + bottom: Math.max(startPos.y, endPos.y), + }; +} + +function getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + startPos: { x: number; y: number }; + endPos: { x: number; y: number }; +}): SelectionRectangle { + const containerRect = container.getBoundingClientRect(); + const scrollRect = scrollContainer?.getBoundingClientRect() ?? containerRect; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + + const adjustedStart = { + x: startPos.x - containerRect.left + scrollLeft, + y: startPos.y - scrollRect.top + scrollTop, + }; + const adjustedEnd = { + x: endPos.x - containerRect.left + scrollLeft, + y: endPos.y - scrollRect.top + scrollTop, + }; + + return getNormalizedRectangle({ + startPos: adjustedStart, + endPos: adjustedEnd, + }); +} + +function isRectangleIntersecting({ + elementRectangle, + selectionRectangle, +}: { + elementRectangle: SelectionRectangle; + selectionRectangle: SelectionRectangle; +}): boolean { + return !( + elementRectangle.right < selectionRectangle.left || + elementRectangle.left > selectionRectangle.right || + elementRectangle.bottom < selectionRectangle.top || + elementRectangle.top > selectionRectangle.bottom + ); +} + +export function resolveTimelineElementIntersections({ + container, + scrollContainer, + tracks, + zoomLevel, + startPos, + currentPos, +}: { + container: HTMLElement; + scrollContainer: HTMLDivElement | null; + tracks: TimelineTrack[]; + zoomLevel: number; + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; +}): TimelineElementRef[] { + const selectionRectangle = getSelectionRectangleInContent({ + container, + scrollContainer, + startPos, + endPos: currentPos, + }); + const selectedElements: TimelineElementRef[] = []; + + for (const [trackIndex, track] of tracks.entries()) { + const trackTop = getCumulativeHeightBefore({ + tracks, + trackIndex, + }); + const trackHeight = getTrackHeight({ type: track.type }); + const elementTop = TIMELINE_CONTENT_TOP_PADDING_PX + trackTop; + const elementBottom = elementTop + trackHeight; + + for (const element of track.elements) { + const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel }); + const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel }); + const elementRectangle = { + left: elementLeft, + top: elementTop, + right: elementRight, + bottom: elementBottom, + }; + + if ( + isRectangleIntersecting({ + elementRectangle, + selectionRectangle, + }) + ) { + selectedElements.push({ + trackId: track.id, + elementId: element.id, + }); + } + } + } + + return selectedElements; +} diff --git a/apps/web/src/timeline/components/snap-indicator.tsx b/apps/web/src/timeline/components/snap-indicator.tsx new file mode 100644 index 00000000..6c1dbf1a --- /dev/null +++ b/apps/web/src/timeline/components/snap-indicator.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useSnapIndicatorPosition } from "@/timeline/hooks/use-snap-indicator-position"; +import type { SnapPoint } from "@/timeline/snapping"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, +} from "@/timeline"; +import { TIMELINE_LAYERS } from "./layers"; +interface SnapIndicatorProps { + snapPoint: SnapPoint | null; + zoomLevel: number; + isVisible: boolean; + timelineRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; +} + +export function SnapIndicator({ + snapPoint, + zoomLevel, + isVisible, + timelineRef, + tracksScrollRef, +}: SnapIndicatorProps) { + const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + timelineRef, + tracksScrollRef, + }); + + if (!isVisible || !snapPoint) { + return null; + } + + return ( + <div + className="pointer-events-none absolute" + style={{ + left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`, + top: topPosition, + height: `${height}px`, + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.snapIndicator, + }} + > + <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> + </div> + ); +} diff --git a/apps/web/src/timeline/components/theme.ts b/apps/web/src/timeline/components/theme.ts new file mode 100644 index 00000000..9f93dc16 --- /dev/null +++ b/apps/web/src/timeline/components/theme.ts @@ -0,0 +1,31 @@ +import type { TrackType } from "@/timeline"; + +export const TIMELINE_AUDIO_WAVEFORM_COLOR = "rgba(255, 255, 255, 0.7)"; + +export const TIMELINE_TRACK_THEME: Record< + TrackType, + { + elementClassName: string; + waveformColor?: string; + } +> = { + video: { elementClassName: "transparent" }, + text: { elementClassName: "bg-[#5DBAA0]" }, + audio: { + elementClassName: "bg-[#8F5DBA]", + waveformColor: TIMELINE_AUDIO_WAVEFORM_COLOR, + }, + graphic: { elementClassName: "bg-[#BA5D7A]" }, + effect: { elementClassName: "bg-[#5d93ba]" }, +} as const; + +export const SELECTED_TRACK_ROW_CLASS = "bg-accent/50"; +export const DEFAULT_TIMELINE_BOOKMARK_COLOR = "#009dff"; + +export function getTimelineElementClassName({ + type, +}: { + type: TrackType; +}): string { + return TIMELINE_TRACK_THEME[type].elementClassName.trim(); +} diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx new file mode 100644 index 00000000..74c34607 --- /dev/null +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -0,0 +1,1302 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { useAssetsPanelStore } from "@/components/editor/panels/assets/assets-panel-store"; +import { AudioWaveform, WAVEFORM_GAIN_SAMPLE_COUNT } from "./audio-waveform"; +import { AudioVolumeLine } from "./audio-volume-line"; +import { useElementPreview } from "@/timeline/hooks/use-element-preview"; +import { + useKeyframeDrag, + type KeyframeDragState, +} from "@/timeline/hooks/element/use-keyframe-drag"; +import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; +import { useKeyframeBoxSelect } from "@/timeline/hooks/element/use-keyframe-box-select"; +import { SelectionBox } from "@/selection/selection-box"; +import { getElementKeyframes } from "@/animation"; +import { + canElementHaveAudio, + canElementBeHidden, + hasElementEffects, + hasMediaId, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { getTrackHeight } from "./track-layout"; +import { getTimelineElementClassName, TIMELINE_TRACK_THEME } from "./theme"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import type { + TimelineElement as TimelineElementType, + TimelineTrack, + ElementDragState, + VideoElement, + ImageElement, + AudioElement, +} from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { mediaSupportsAudio } from "@/media/media-utils"; +import { + canToggleSourceAudio, + getSourceAudioActionLabel, + isSourceAudioSeparated, +} from "@/timeline/audio-separation"; +import { buildWaveformGainSamples } from "@/timeline/audio-state"; +import { getTimelinePixelsPerSecond } from "@/timeline"; +import { buildWaveformSourceKey } from "@/media/waveform-summary"; +import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { + getActionDefinition, + type TAction, + type TActionWithOptionalArgs, + invokeAction, +} from "@/actions"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { resolveStickerId } from "@/stickers"; +import { buildGraphicPreviewUrl } from "@/graphics"; +import Image from "next/image"; +import { + ScissorIcon, + Delete02Icon, + Copy01Icon, + ViewIcon, + ViewOffSlashIcon, + VolumeHighIcon, + VolumeOffIcon, + VolumeMute02Icon, + Search01Icon, + Exchange01Icon, + KeyframeIcon, + MagicWand05Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { uppercase } from "@/utils/string"; +import { useMemo, type ComponentProps, type ReactNode } from "react"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types"; +import { cn } from "@/utils/ui"; +import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; +import { getTrackTypeForElementType } from "@/timeline/placement/compatibility"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { KEYFRAME_LANE_HEIGHT_PX } from "./layout"; +import { + getExpandedRows, + getExpansionHeight, + type ExpandedRow, +} from "./expanded-layout"; + +const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; +const ELEMENT_RING_WIDTH_PX = 1.5; + +const PixelsPerSecondContext = createContext<number | null>(null); +const THUMBNAIL_ASPECT_RATIO = 16 / 9; + +interface KeyframeIndicator { + time: number; + offsetPx: number; + keyframes: SelectedKeyframeRef[]; +} + +export function buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, +}: { + keyframe: ElementKeyframe; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; +}): { + time: number; + offsetPx: number; + keyframeRef: SelectedKeyframeRef; +} { + const keyframeRef = { + trackId, + elementId, + propertyPath: keyframe.propertyPath, + keyframeId: keyframe.id, + }; + const keyframeLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + keyframe.time, + zoomLevel, + }); + return { + time: keyframe.time, + offsetPx: keyframeLeft - elementLeft, + keyframeRef, + }; +} + +export function getKeyframeIndicators({ + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, +}: { + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + elementWidth: number; +}): KeyframeIndicator[] { + if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { + return []; + } + + const keyframesByTime = new Map<number, KeyframeIndicator>(); + for (const keyframe of keyframes) { + const indicator = buildKeyframeIndicator({ + keyframe, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + }); + const existingIndicator = keyframesByTime.get(indicator.time); + if (!existingIndicator) { + keyframesByTime.set(indicator.time, { + time: indicator.time, + offsetPx: indicator.offsetPx, + keyframes: [indicator.keyframeRef], + }); + continue; + } + + existingIndicator.keyframes.push(indicator.keyframeRef); + } + + return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); +} + +export function getDisplayShortcut({ action }: { action: TAction }) { + const { defaultShortcuts } = getActionDefinition({ action }); + if (!defaultShortcuts?.length) { + return ""; + } + + return uppercase({ + string: defaultShortcuts[0].replace("+", " "), + }); +} + +interface TimelineElementProps { + element: TimelineElementType; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; + onElementMouseDown: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + dragState: ElementDragState; + isDropTarget?: boolean; +} + +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onResizeStart, + onElementMouseDown, + onElementClick, + dragState, + isDropTarget = false, +}: TimelineElementProps) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + const { selectedElements } = useElementSelection(); + const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); + const { renderElement } = useElementPreview({ + trackId: track.id, + elementId: element.id, + fallback: element, + }); + + let mediaAsset: MediaAsset | null = null; + + if (hasMediaId(element)) { + mediaAsset = + mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + } + + const hasAudio = mediaSupportsAudio({ media: mediaAsset }); + + const isCurrentElementSelected = selectedElements.some( + (selected) => + selected.elementId === element.id && selected.trackId === track.id, + ); + + const isBeingDragged = dragState.dragElementIds.includes(element.id); + const dragOffsetY = + isBeingDragged && dragState.isDragging + ? dragState.currentMouseY - dragState.startMouseY + : 0; + const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + dragTimeOffset + : renderElement.startTime; + const displayedStartTime = elementStartTime; + const displayedDuration = renderElement.duration; + const elementWidth = timelineTimeToPixels({ + time: displayedDuration, + zoomLevel, + }); + const timelinePixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel }); + const elementLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime, + zoomLevel, + }); + const keyframeIndicators = isSelected + ? getKeyframeIndicators({ + keyframes: getElementKeyframes({ animations: element.animations }), + trackId: track.id, + elementId: element.id, + displayedStartTime, + zoomLevel, + elementLeft, + elementWidth, + }) + : []; + + const { + keyframeDragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); + + const elementKeyframes = getElementKeyframes({ + animations: element.animations, + }); + + const isExpanded = useTimelineStore((s) => + s.expandedElementIds.has(element.id), + ); + const toggleElementExpanded = useTimelineStore( + (s) => s.toggleElementExpanded, + ); + const expandedRows = useMemo( + () => + isExpanded ? getExpandedRows({ animations: element.animations }) : [], + [isExpanded, element.animations], + ); + + const { + containerRef: expandedLanesRef, + selectionBox: keyframeSelectionBox, + isBoxSelecting: isKeyframeBoxSelecting, + handleExpandedAreaMouseDown, + handleExpandedAreaClick, + } = useKeyframeBoxSelect({ + trackId: track.id, + elementId: element.id, + rows: expandedRows, + keyframes: elementKeyframes, + displayedStartTime, + zoomLevel, + elementLeft, + }); + + const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { + event.stopPropagation(); + if (hasMediaId(element)) { + requestRevealMedia(element.mediaId); + } + }; + + const isMuted = canElementHaveAudio(element) && element.muted === true; + const canToggleCurrentSourceAudio = + selectedElements.length === 1 && + isCurrentElementSelected && + canToggleSourceAudio(element, mediaAsset); + const sourceAudioLabel = + element.type === "video" + ? getSourceAudioActionLabel({ element }) + : "Extract audio"; + const isElementSourceAudioSeparated = + element.type === "video" && isSourceAudioSeparated({ element }); + const hasKeyframes = elementKeyframes.length > 0; + const expansionHeight = getExpansionHeight({ rows: expandedRows }); + const baseTrackHeight = getTrackHeight({ type: track.type }); + + const expandedContent = + isExpanded && expandedRows.length > 0 ? ( + <ExpandedKeyframeLanes + rows={expandedRows} + keyframes={elementKeyframes} + trackId={track.id} + elementId={element.id} + displayedStartTime={displayedStartTime} + zoomLevel={zoomLevel} + elementLeft={elementLeft} + keyframeDragState={keyframeDragState} + onKeyframeMouseDown={handleKeyframeMouseDown} + onKeyframeClick={handleKeyframeClick} + getVisualOffsetPx={getVisualOffsetPx} + containerRef={expandedLanesRef} + onLaneMouseDown={handleExpandedAreaMouseDown} + onLaneClick={handleExpandedAreaClick} + selectionBox={keyframeSelectionBox} + isBoxSelecting={isKeyframeBoxSelecting} + /> + ) : null; + + return ( + <PixelsPerSecondContext.Provider value={timelinePixelsPerSecond}> + <ContextMenu> + <ContextMenuTrigger asChild> + <div + className="absolute top-0 select-none" + style={{ + left: `${elementLeft}px`, + width: `${elementWidth}px`, + height: + expandedRows.length > 0 + ? `${baseTrackHeight + expansionHeight}px` + : "100%", + transform: + isBeingDragged && dragState.isDragging + ? `translate3d(0, ${dragOffsetY}px, 0)` + : undefined, + }} + > + <ElementInner + element={element} + displayElement={renderElement} + track={track} + isSelected={isSelected} + isExpanded={expandedRows.length > 0} + baseTrackHeight={baseTrackHeight} + expandedContent={expandedContent} + onElementClick={onElementClick} + onElementMouseDown={onElementMouseDown} + onResizeStart={onResizeStart} + isDropTarget={isDropTarget} + /> + {isSelected && ( + <div + className="pointer-events-none absolute inset-x-0 top-0 overflow-hidden" + style={{ height: `${baseTrackHeight}px` }} + > + <KeyframeIndicators + indicators={keyframeIndicators} + dragState={keyframeDragState} + displayedStartTime={displayedStartTime} + elementLeft={elementLeft} + onKeyframeMouseDown={handleKeyframeMouseDown} + onKeyframeClick={handleKeyframeClick} + getVisualOffsetPx={getVisualOffsetPx} + /> + </div> + )} + </div> + </ContextMenuTrigger> + <ContextMenuContent className="w-64"> + <ActionMenuItem + action="split" + icon={<HugeiconsIcon icon={ScissorIcon} />} + > + Split + </ActionMenuItem> + <CopyMenuItem /> + {selectedElements.length === 1 && ( + <ActionMenuItem + action="duplicate-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > + Duplicate + </ActionMenuItem> + )} + {canElementHaveAudio(element) && hasAudio && ( + <MuteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + isMuted={isMuted} + /> + )} + {canToggleCurrentSourceAudio && ( + <ContextMenuItem + icon={ + <HugeiconsIcon + icon={ + isElementSourceAudioSeparated ? ScissorIcon : ScissorIcon + } + /> + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction("toggle-source-audio"); + }} + > + {sourceAudioLabel} + </ContextMenuItem> + )} + {canElementBeHidden(element) && ( + <VisibilityMenuItem + element={element} + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + /> + )} + {hasKeyframes && ( + <ContextMenuItem + icon={<HugeiconsIcon icon={KeyframeIcon} />} + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + toggleElementExpanded(element.id); + }} + > + {isExpanded ? "Collapse keyframes" : "Expand keyframes"} + </ContextMenuItem> + )} + {selectedElements.length === 1 && hasMediaId(element) && ( + <> + <ContextMenuItem + icon={<HugeiconsIcon icon={Search01Icon} />} + onClick={(event: React.MouseEvent) => + handleRevealInMedia({ event }) + } + > + Reveal media + </ContextMenuItem> + <ContextMenuItem + icon={<HugeiconsIcon icon={Exchange01Icon} />} + disabled + > + Replace media + </ContextMenuItem> + </> + )} + <ContextMenuSeparator /> + <DeleteMenuItem + isMultipleSelected={selectedElements.length > 1} + isCurrentElementSelected={isCurrentElementSelected} + elementType={element.type} + selectedCount={selectedElements.length} + /> + </ContextMenuContent> + </ContextMenu> + </PixelsPerSecondContext.Provider> + ); +} + +function ElementInner({ + element, + displayElement, + track, + isSelected, + isExpanded, + baseTrackHeight, + expandedContent, + onElementClick, + onElementMouseDown, + onResizeStart, + isDropTarget = false, +}: { + element: TimelineElementType; + displayElement?: TimelineElementType; + track: TimelineTrack; + isSelected: boolean; + isExpanded: boolean; + baseTrackHeight: number; + expandedContent: React.ReactNode; + onElementClick: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onElementMouseDown: ( + event: React.MouseEvent, + element: TimelineElementType, + ) => void; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; + isDropTarget?: boolean; +}) { + const visibleElement = displayElement ?? element; + const isReducedOpacity = + (canElementBeHidden(visibleElement) && visibleElement.hidden) || + isDropTarget; + return ( + <div + className="absolute top-0 bottom-0" + style={{ + left: `${ELEMENT_RING_WIDTH_PX}px`, + right: `${ELEMENT_RING_WIDTH_PX}px`, + }} + > + <div + className="absolute inset-0 rounded-sm" + style={ + isSelected + ? { + boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, + } + : undefined + } + > + <div + className={cn( + "absolute inset-0 overflow-hidden rounded-sm", + isExpanded && "bg-background", + )} + > + <button + type="button" + tabIndex={-1} + className="absolute inset-0 size-full flex flex-col" + onClick={(event) => onElementClick(event, element)} + onMouseDown={(event) => onElementMouseDown(event, element)} + > + <div + className={cn( + "flex shrink-0 items-center overflow-hidden", + getTimelineElementClassName({ + type: getTrackTypeForElementType({ + elementType: element.type, + }), + }), + isReducedOpacity && "opacity-50", + )} + style={{ height: `${baseTrackHeight}px` }} + > + <div className="flex flex-1 min-h-0 h-full items-center overflow-hidden"> + <ElementContent element={visibleElement} track={track} /> + </div> + </div> + {expandedContent} + </button> + </div> + </div> + + {isSelected && ( + <> + <ResizeHandle + side="left" + element={element} + track={track} + onResizeStart={onResizeStart} + /> + <ResizeHandle + side="right" + element={element} + track={track} + onResizeStart={onResizeStart} + /> + </> + )} + </div> + ); +} + +function ResizeHandle({ + side, + element, + track, + onResizeStart, +}: { + side: "left" | "right"; + element: TimelineElementType; + track: TimelineTrack; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; +}) { + const isLeft = side === "left"; + return ( + <button + type="button" + className={cn( + "absolute top-0 bottom-0 w-2", + isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", + )} + onMouseDown={(event) => onResizeStart({ event, element, track, side })} + onClick={(event) => event.stopPropagation()} + aria-label={`${isLeft ? "Left" : "Right"} resize handle`} + ></button> + ); +} + +function KeyframeIndicators({ + indicators, + dragState, + displayedStartTime, + elementLeft, + onKeyframeMouseDown, + onKeyframeClick, + getVisualOffsetPx, +}: { + indicators: KeyframeIndicator[]; + dragState: KeyframeDragState; + displayedStartTime: number; + elementLeft: number; + onKeyframeMouseDown: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => void; + onKeyframeClick: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => void; + getVisualOffsetPx: (params: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }) => number; +}) { + const { isKeyframeSelected } = useKeyframeSelection(); + const orderedKeyframes = indicators.flatMap( + (indicator) => indicator.keyframes, + ); + + return indicators.map((indicator) => { + const isIndicatorSelected = indicator.keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + const isBeingDragged = indicator.keyframes.some((keyframe) => + dragState.draggingKeyframeIds.has(keyframe.keyframeId), + ); + const visualOffsetPx = getVisualOffsetPx({ + indicatorTime: indicator.time, + indicatorOffsetPx: indicator.offsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }); + + return ( + <button + key={indicator.time} + type="button" + className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab mr-0.5" + style={{ left: visualOffsetPx }} + onMouseDown={(event) => + onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) + } + onClick={(event) => + onKeyframeClick({ + event, + keyframes: indicator.keyframes, + orderedKeyframes, + indicatorTime: indicator.time, + }) + } + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 text-black", + isIndicatorSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + }); +} + +function ExpandedKeyframeLanes({ + rows, + keyframes, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + keyframeDragState, + onKeyframeMouseDown, + onKeyframeClick, + getVisualOffsetPx, + containerRef, + onLaneMouseDown, + onLaneClick, + selectionBox, + isBoxSelecting, +}: { + rows: ExpandedRow[]; + keyframes: ElementKeyframe[]; + trackId: string; + elementId: string; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; + keyframeDragState: KeyframeDragState; + onKeyframeMouseDown: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => void; + containerRef: React.RefObject<HTMLDivElement | null>; + onLaneMouseDown: (event: React.MouseEvent) => void; + onLaneClick: (event: React.MouseEvent) => void; + selectionBox: { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + isActive: boolean; + } | null; + isBoxSelecting: boolean; + onKeyframeClick: (params: { + event: React.MouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => void; + getVisualOffsetPx: (params: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }) => number; +}) { + const { isKeyframeSelected } = useKeyframeSelection(); + + const orderedKeyframes = useMemo( + () => + [...keyframes] + .sort( + (a, b) => + a.time - b.time || a.propertyPath.localeCompare(b.propertyPath), + ) + .map((kf) => ({ + trackId, + elementId, + propertyPath: kf.propertyPath, + keyframeId: kf.id, + })), + [keyframes, trackId, elementId], + ); + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: expanded keyframe lanes are a pointer-only editing surface + // biome-ignore lint/a11y/useKeyWithClickEvents: expanded keyframe lanes are a pointer-only editing surface + <div + ref={containerRef} + className="relative flex flex-col" + onMouseDown={onLaneMouseDown} + onClick={onLaneClick} + > + {rows.map((row) => { + const laneKeyframes = keyframes.filter( + (kf) => kf.propertyPath === row.propertyPath, + ); + return ( + <div + key={row.propertyPath} + className={cn("relative flex items-center bg-muted/50")} + style={{ height: `${KEYFRAME_LANE_HEIGHT_PX}px` }} + > + {laneKeyframes.map((kf) => { + const keyframeRef: SelectedKeyframeRef = { + trackId, + elementId, + propertyPath: row.propertyPath, + keyframeId: kf.id, + }; + const isBeingDragged = keyframeDragState.draggingKeyframeIds.has( + kf.id, + ); + const kfLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + kf.time, + zoomLevel, + }); + const offsetPx = kfLeft - elementLeft; + const visualOffset = getVisualOffsetPx({ + indicatorTime: kf.time, + indicatorOffsetPx: offsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }); + const isSelected = isKeyframeSelected({ + keyframe: keyframeRef, + }); + + return ( + <button + key={kf.id} + type="button" + className={cn( + "pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab", + isBoxSelecting && "pointer-events-none", + )} + style={{ left: visualOffset }} + onMouseDown={(event) => { + event.stopPropagation(); + onKeyframeMouseDown({ + event, + keyframes: [keyframeRef], + }); + }} + onClick={(event) => { + event.stopPropagation(); + onKeyframeClick({ + event, + keyframes: [keyframeRef], + orderedKeyframes, + indicatorTime: kf.time, + }); + }} + aria-label="Select keyframe" + > + <HugeiconsIcon + icon={KeyframeIcon} + className={cn( + "size-3.5 text-black mr-1", + isSelected ? "fill-primary" : "fill-white", + )} + strokeWidth={1.5} + /> + </button> + ); + })} + </div> + ); + })} + {selectionBox && ( + <SelectionBox + startPos={selectionBox.startPos} + currentPos={selectionBox.currentPos} + containerRef={containerRef} + isActive={selectionBox.isActive} + /> + )} + </div> + ); +} + +interface ElementContentProps { + element: TimelineElementType; + track: TimelineTrack; +} + +function TextElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "text" }>; +}) { + return ( + <div className="flex size-full items-center justify-start pl-2"> + <span className="truncate text-xs text-white">{element.content}</span> + </div> + ); +} + +function EffectElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "effect" }>; +}) { + return ( + <div className="flex size-full items-center justify-start gap-1 pl-2"> + <HugeiconsIcon + icon={MagicWand05Icon} + className="size-4 shrink-0 text-white" + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +function StickerElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "sticker" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={resolveStickerId({ + stickerId: element.stickerId, + options: { width: 20, height: 20 }, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +function GraphicElementContent({ + element, +}: { + element: Extract<TimelineElementType, { type: "graphic" }>; +}) { + return ( + <div className="flex size-full items-center gap-2 pl-2"> + <Image + src={buildGraphicPreviewUrl({ + definitionId: element.definitionId, + params: element.params, + size: 20, + })} + alt={element.name} + className="size-4 shrink-0" + width={20} + height={20} + unoptimized + /> + <span className="truncate text-xs text-white">{element.name}</span> + </div> + ); +} + +function AudioElementContent({ + element, + trackId, +}: { + element: AudioElement; + trackId: string; +}) { + const pixelsPerSecond = useContext(PixelsPerSecondContext); + if (pixelsPerSecond === null) { + throw new Error( + "AudioElementContent must be rendered inside PixelsPerSecondContext.Provider", + ); + } + const mediaAssets = useEditor((e) => e.media.getAssets()); + const mediaAsset = + element.sourceType === "upload" + ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) + : null; + + const audioBuffer = + element.sourceType === "library" ? element.buffer : undefined; + const audioUrl = + element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; + const sourceFile = + element.sourceType === "upload" ? mediaAsset?.file : undefined; + const sourceKey = + element.sourceType === "upload" + ? buildWaveformSourceKey({ kind: "media", id: element.mediaId }) + : buildWaveformSourceKey({ kind: "library", id: element.sourceUrl }); + const mediaLabel = mediaAsset?.name ?? element.name; + const gainSamples = useMemo( + () => + buildWaveformGainSamples({ + element, + count: WAVEFORM_GAIN_SAMPLE_COUNT, + }), + [element], + ); + if (audioBuffer || audioUrl || sourceFile) { + return ( + <div className="group/audio relative size-full"> + <MediaElementHeader name={mediaLabel} hasFade={false} /> + <div className="absolute inset-x-0 top-5 bottom-0 overflow-hidden"> + <AudioWaveform + sourceKey={sourceKey} + sourceFile={sourceFile} + audioBuffer={audioBuffer} + audioUrl={audioUrl} + gainSamples={gainSamples} + pixelsPerSecond={pixelsPerSecond} + clipDurationSec={element.duration / TICKS_PER_SECOND} + retime={element.retime} + sourceStartSec={element.trimStart / TICKS_PER_SECOND} + color={TIMELINE_TRACK_THEME.audio.waveformColor} + /> + <AudioVolumeLine element={element} trackId={trackId} /> + </div> + </div> + ); + } + + return ( + <div className="group/audio relative size-full"> + <div className="flex size-full items-center pl-2"> + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + </div> + <AudioVolumeLine element={element} trackId={trackId} /> + </div> + ); +} + +function EffectsButton({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const editor = useEditor(); + const setActiveTab = usePropertiesStore((s) => s.setActiveTab); + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + editor.selection.setSelectedElements({ + elements: [{ trackId: track.id, elementId: element.id }], + }); + setActiveTab(element.type, "effects"); + }; + + return ( + <button + type="button" + className="flex shrink-0 justify-center text-white cursor-pointer" + onMouseDown={(event) => event.stopPropagation()} + onClick={handleClick} + > + <HugeiconsIcon icon={MagicWand05Icon} size={12} /> + </button> + ); +} + +function TiledMediaContent({ + element, + track, +}: { + element: VideoElement | ImageElement; + track: TimelineTrack; +}) { + const mediaAssets = useEditor((e) => e.media.getAssets()); + + const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); + const imageUrl = + element.type === "video" + ? mediaAsset?.thumbnailUrl + : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); + + if (!imageUrl) { + return ( + <span className="text-foreground/80 truncate text-xs"> + {element.name} + </span> + ); + } + + const trackHeight = getTrackHeight({ type: track.type }); + const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; + + return ( + <> + <div + className="absolute inset-0" + style={{ + backgroundColor: "var(--muted)", + backgroundImage: `url(${imageUrl})`, + backgroundRepeat: "repeat-x", + backgroundSize: `${tileWidth}px ${trackHeight}px`, + backgroundPosition: "left center", + pointerEvents: "none", + }} + /> + <MediaElementHeader + name={mediaAsset?.name} + leading={ + hasElementEffects({ element }) ? ( + <EffectsButton element={element} track={track} /> + ) : null + } + hasFade={true} + /> + </> + ); +} + +function MediaElementHeader({ + name, + leading, + hasFade, +}: { + name?: string | null; + leading?: ReactNode; + hasFade?: boolean; +}) { + if (!name && !leading) { + return null; + } + + return ( + <div + className={cn( + "absolute top-0 left-0 flex h-5 w-full bg-linear-to-b pt-1", + hasFade && "from-black/30 to-transparent", + )} + > + {leading && <div className="pl-1">{leading}</div>} + {name && ( + <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> + {name} + </span> + )} + </div> + ); +} + +function ElementContent({ element, track }: ElementContentProps) { + switch (element.type) { + case "text": + return <TextElementContent element={element} />; + case "effect": + return <EffectElementContent element={element} />; + case "sticker": + return <StickerElementContent element={element} />; + case "graphic": + return <GraphicElementContent element={element} />; + case "audio": + return <AudioElementContent element={element} trackId={track.id} />; + case "video": + case "image": + return <TiledMediaContent element={element} track={track} />; + } +} + +function CopyMenuItem() { + return ( + <ActionMenuItem + action="copy-selected" + icon={<HugeiconsIcon icon={Copy01Icon} />} + > + Copy + </ActionMenuItem> + ); +} + +function MuteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + isMuted, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + isMuted: boolean; +}) { + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={VolumeMute02Icon} />; + } + return isMuted ? ( + <HugeiconsIcon icon={VolumeOffIcon} /> + ) : ( + <HugeiconsIcon icon={VolumeHighIcon} /> + ); + }; + + return ( + <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> + {isMuted ? "Unmute" : "Mute"} + </ActionMenuItem> + ); +} + +function VisibilityMenuItem({ + element, + isMultipleSelected, + isCurrentElementSelected, +}: { + element: TimelineElementType; + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; +}) { + const isHidden = canElementBeHidden(element) && element.hidden; + + const getIcon = () => { + if (isMultipleSelected && isCurrentElementSelected) { + return <HugeiconsIcon icon={ViewOffSlashIcon} />; + } + return isHidden ? ( + <HugeiconsIcon icon={ViewIcon} /> + ) : ( + <HugeiconsIcon icon={ViewOffSlashIcon} /> + ); + }; + + return ( + <ActionMenuItem + action="toggle-elements-visibility-selected" + icon={getIcon()} + > + {isHidden ? "Show" : "Hide"} + </ActionMenuItem> + ); +} + +function DeleteMenuItem({ + isMultipleSelected, + isCurrentElementSelected, + elementType, + selectedCount, +}: { + isMultipleSelected: boolean; + isCurrentElementSelected: boolean; + elementType: TimelineElementType["type"]; + selectedCount: number; +}) { + return ( + <ActionMenuItem + action="delete-selected" + variant="destructive" + icon={<HugeiconsIcon icon={Delete02Icon} />} + > + {isMultipleSelected && isCurrentElementSelected + ? `Delete ${selectedCount} elements` + : `Delete ${elementType === "text" ? "text" : "clip"}`} + </ActionMenuItem> + ); +} + +function ActionMenuItem({ + action, + children, + ...props +}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { + action: TActionWithOptionalArgs; + children: ReactNode; +}) { + return ( + <ContextMenuItem + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + invokeAction(action); + }} + textRight={getDisplayShortcut({ action })} + {...props} + > + {children} + </ContextMenuItem> + ); +} diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx new file mode 100644 index 00000000..4dd3329f --- /dev/null +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useRef } from "react"; +import { + getCenteredLineLeft, + TIMELINE_INDICATOR_LINE_WIDTH_PX, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; +import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; +import { TIMELINE_LAYERS } from "./layers"; + +interface TimelinePlayheadProps { + zoomLevel: number; + hasHorizontalScrollbar: boolean; + rulerRef: React.RefObject<HTMLDivElement | null>; + rulerScrollRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + timelineRef: React.RefObject<HTMLDivElement | null>; + playheadRef?: React.RefObject<HTMLDivElement | null>; + isSnappingToPlayhead?: boolean; +} + +export function TimelinePlayhead({ + zoomLevel, + hasHorizontalScrollbar, + rulerRef, + rulerScrollRef, + tracksScrollRef, + timelineRef, + playheadRef: externalPlayheadRef, + isSnappingToPlayhead = false, +}: TimelinePlayheadProps) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const internalPlayheadRef = useRef<HTMLDivElement>(null); + const playheadRef = externalPlayheadRef || internalPlayheadRef; + + const { handlePlayheadMouseDown } = useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }); + + const timelineContainerHeight = + timelineRef.current?.clientHeight ?? + tracksScrollRef.current?.clientHeight ?? + 400; + const totalHeight = Math.max( + 0, + timelineContainerHeight - + (hasHorizontalScrollbar ? TIMELINE_SCROLLBAR_SIZE_PX - 5 : 0), + ); + + const currentTime = editor.playback.getCurrentTime(); + const centerPosition = timelineTimeToSnappedPixels({ + time: currentTime, + zoomLevel, + }); + const scrollLeft = tracksScrollRef.current?.scrollLeft ?? 0; + const leftPosition = + getCenteredLineLeft({ centerPixel: centerPosition }) - scrollLeft; + + const handlePlayheadKeyDown = ( + event: React.KeyboardEvent<HTMLDivElement>, + ) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + + event.preventDefault(); + const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + const direction = event.key === "ArrowRight" ? 1 : -1; + const now = editor.playback.getCurrentTime(); + const nextTime = Math.max( + 0, + Math.min(duration, now + direction * ticksPerFrame), + ); + + editor.playback.seek({ time: nextTime }); + }; + + return ( + <div + ref={playheadRef} + role="slider" + aria-label="Timeline playhead" + aria-valuemin={0} + aria-valuemax={duration} + aria-valuenow={currentTime} + tabIndex={0} + className="pointer-events-none absolute" + style={{ + left: `${leftPosition}px`, + top: 0, + height: `${totalHeight}px`, + width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, + zIndex: TIMELINE_LAYERS.playhead, + }} + onKeyDown={handlePlayheadKeyDown} + > + <div className="bg-primary pointer-events-none absolute left-0 h-full w-0.5" /> + + <button + type="button" + aria-label="Drag playhead" + className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-primary border-primary/50"}`} + onMouseDown={handlePlayheadMouseDown} + /> + </div> + ); +} diff --git a/apps/web/src/timeline/components/timeline-ruler.tsx b/apps/web/src/timeline/components/timeline-ruler.tsx new file mode 100644 index 00000000..e2a379ee --- /dev/null +++ b/apps/web/src/timeline/components/timeline-ruler.tsx @@ -0,0 +1,146 @@ +import { type JSX, useLayoutEffect, useRef } from "react"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { mediaTimeToSeconds } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_RULER_HEIGHT_PX } from "./layout"; +import { DEFAULT_FPS } from "@/fps/defaults"; +import { useEditor } from "@/editor/use-editor"; +import { getRulerConfig, shouldShowLabel } from "@/timeline/ruler-utils"; +import { useScrollPosition } from "@/timeline/hooks/use-scroll-position"; +import { TimelineTick } from "./timeline-tick"; + +interface TimelineRulerProps { + zoomLevel: number; + dynamicTimelineWidth: number; + rulerRef: React.Ref<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLElement | null>; + handleWheel: (e: React.WheelEvent) => void; + handleTimelineContentClick: (e: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; + handleRulerMouseDown: (e: React.MouseEvent) => void; +} + +export function TimelineRuler({ + zoomLevel, + dynamicTimelineWidth, + rulerRef, + tracksScrollRef, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineRulerProps) { + const durationTicks = useEditor((e) => e.timeline.getTotalDuration()); + const durationSeconds = mediaTimeToSeconds({ time: durationTicks }); + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond; + const effectiveDurationSeconds = Math.max( + durationSeconds, + visibleDurationSeconds, + ); + const fps = + useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS; + const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ + zoomLevel, + fps, + }); + const tickCount = + Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1; + + const { scrollLeft, viewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); + + /** + * widens the virtualization buffer during zoom transitions. + * useScrollPosition lags one frame behind the scroll adjustment + * that useLayoutEffect applies after a zoom change. + */ + const prevZoomRef = useRef(zoomLevel); + const isZoomTransition = zoomLevel !== prevZoomRef.current; + const bufferPx = isZoomTransition + ? Math.max(200, (scrollLeft + viewportWidth) * 0.15) + : 200; + + useLayoutEffect(() => { + prevZoomRef.current = zoomLevel; + }, [zoomLevel]); + + const visibleStartTimeSeconds = Math.max( + 0, + (scrollLeft - bufferPx) / pixelsPerSecond, + ); + const visibleEndTimeSeconds = + (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond; + + const startTickIndex = Math.max( + 0, + Math.floor(visibleStartTimeSeconds / tickIntervalSeconds), + ); + const endTickIndex = Math.min( + tickCount - 1, + Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds), + ); + + const timelineTicks: Array<JSX.Element> = []; + for ( + let tickIndex = startTickIndex; + tickIndex <= endTickIndex; + tickIndex += 1 + ) { + const timeSeconds = tickIndex * tickIntervalSeconds; + if (timeSeconds > effectiveDurationSeconds) break; + + const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND); + const showLabel = shouldShowLabel({ + time: timeSeconds, + labelIntervalSeconds, + }); + timelineTicks.push( + <TimelineTick + key={tickIndex} + time={timeTicks} + timeInSeconds={timeSeconds} + zoomLevel={zoomLevel} + fps={fps} + showLabel={showLabel} + />, + ); + } + + return ( + <div + role="slider" + tabIndex={0} + aria-label="Timeline ruler" + aria-valuemin={0} + aria-valuemax={effectiveDurationSeconds} + aria-valuenow={0} + className="relative flex-1 overflow-x-visible" + style={{ height: TIMELINE_RULER_HEIGHT_PX }} + onWheel={handleWheel} + onClick={(event) => { + // Ruler seek already happens on mousedown via playhead scrubbing. + // Forwarding the follow-up click re-enters the selection-clearing path. + if (event.target === event.currentTarget) { + handleTimelineContentClick(event); + } + }} + onMouseDown={handleRulerTrackingMouseDown} + onKeyDown={() => {}} + > + <div + role="none" + ref={rulerRef} + className="relative cursor-default select-none" + style={{ + height: TIMELINE_RULER_HEIGHT_PX, + width: `${dynamicTimelineWidth}px`, + }} + onMouseDown={handleRulerMouseDown} + > + {timelineTicks} + </div> + </div> + ); +} diff --git a/apps/web/src/timeline/components/timeline-tick.tsx b/apps/web/src/timeline/components/timeline-tick.tsx new file mode 100644 index 00000000..1b1e46d3 --- /dev/null +++ b/apps/web/src/timeline/components/timeline-tick.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { FrameRate } from "opencut-wasm"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { formatRulerLabel } from "@/timeline/ruler-utils"; + +interface TimelineTickProps { + time: number; + timeInSeconds: number; + zoomLevel: number; + fps: FrameRate; + showLabel: boolean; +} + +export function TimelineTick({ + time, + timeInSeconds, + zoomLevel, + fps, + showLabel, +}: TimelineTickProps) { + const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); + + if (showLabel) { + const label = formatRulerLabel({ timeInSeconds, fps }); + return ( + <span + className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none" + style={{ left: `${leftPosition}px` }} + > + {label} + </span> + ); + } + + return ( + <div + className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l" + style={{ left: `${leftPosition}px` }} + /> + ); +} diff --git a/apps/web/src/timeline/components/timeline-toolbar.tsx b/apps/web/src/timeline/components/timeline-toolbar.tsx new file mode 100644 index 00000000..22acd447 --- /dev/null +++ b/apps/web/src/timeline/components/timeline-toolbar.tsx @@ -0,0 +1,376 @@ +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { Button } from "@/components/ui/button"; +import { + SplitButton, + SplitButtonLeft, + SplitButtonRight, + SplitButtonSeparator, +} from "@/components/ui/split-button"; +import { Slider } from "@/components/ui/slider"; +import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction"; +import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; +import { sliderToZoom, zoomToSlider } from "@/timeline/zoom-utils"; +import { ScenesView } from "@/components/editor/scenes-view"; +import { type TActionWithOptionalArgs, invokeAction } from "@/actions"; +import { + canToggleSourceAudio, + getSourceAudioActionLabel, + isSourceAudioSeparated, +} from "@/timeline/audio-separation"; +import { hasMediaId } from "@/timeline"; +import { cn } from "@/utils/ui"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Bookmark02Icon, + Delete02Icon, + SnowIcon, + ScissorIcon, + MagnetIcon, + SearchAddIcon, + SearchMinusIcon, + Copy01Icon, + AlignLeftIcon, + AlignRightIcon, + Link02Icon, + Layers01Icon, + Chart03Icon, + Unlink02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { OcRippleIcon } from "@/components/icons"; +import { GraphEditorPopover } from "./graph-editor/popover"; +import { PopoverTrigger } from "@/components/ui/popover"; +import { useGraphEditorController } from "./graph-editor/use-controller"; + +export function TimelineToolbar({ + zoomLevel, + minZoom, + setZoomLevel, +}: { + zoomLevel: number; + minZoom: number; + setZoomLevel: ({ zoom }: { zoom: number }) => void; +}) { + const handleZoom = ({ direction }: { direction: "in" | "out" }) => { + const newZoomLevel = + direction === "in" + ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR) + : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); + setZoomLevel({ zoom: newZoomLevel }); + }; + + return ( + <ScrollArea className="scrollbar-hidden"> + <div className="flex h-10 items-center justify-between border-b px-2 py-1"> + <ToolbarLeftSection /> + + <SceneSelector /> + + <ToolbarRightSection + zoomLevel={zoomLevel} + minZoom={minZoom} + onZoomChange={(zoom) => setZoomLevel({ zoom })} + onZoom={handleZoom} + /> + </div> + </ScrollArea> + ); +} + +function ToolbarLeftSection() { + const editor = useEditor(); + const mediaAssets = useEditor((currentEditor) => + currentEditor.media.getAssets(), + ); + const { selectedElements } = useElementSelection(); + const graphEditor = useGraphEditorController(); + const isCurrentlyBookmarked = useEditor((e) => + e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), + ); + const selectedElement = + selectedElements.length === 1 + ? (editor.timeline.getElementsWithTracks({ + elements: selectedElements, + })[0] ?? null) + : null; + const selectedMediaAsset = (() => { + if (!selectedElement) { + return null; + } + + const { element } = selectedElement; + if (!hasMediaId(element)) { + return null; + } + + return mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; + })(); + const canToggleSelectedSourceAudio = + !!selectedElement && + canToggleSourceAudio(selectedElement.element, selectedMediaAsset); + const sourceAudioLabel = + selectedElement?.element.type === "video" + ? getSourceAudioActionLabel({ + element: selectedElement.element, + }) + : "Extract audio"; + const isSelectedSourceAudioSeparated = + selectedElement?.element.type === "video" && + isSourceAudioSeparated({ + element: selectedElement.element, + }); + + const handleAction = ({ + action, + event, + }: { + action: TActionWithOptionalArgs; + event: React.MouseEvent; + }) => { + event.stopPropagation(); + invokeAction(action); + }; + + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={<HugeiconsIcon icon={ScissorIcon} />} + tooltip="Split element" + onClick={({ event }) => handleAction({ action: "split", event })} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={AlignLeftIcon} />} + tooltip="Split left" + onClick={({ event }) => handleAction({ action: "split-left", event })} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={AlignRightIcon} />} + tooltip="Split right" + onClick={({ event }) => + handleAction({ action: "split-right", event }) + } + /> + + <ToolbarButton + icon={ + <HugeiconsIcon + icon={isSelectedSourceAudioSeparated ? Unlink02Icon : Link02Icon} + /> + } + tooltip={sourceAudioLabel} + disabled={!canToggleSelectedSourceAudio} + onClick={({ event }) => + handleAction({ action: "toggle-source-audio", event }) + } + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={Copy01Icon} />} + tooltip="Duplicate element" + onClick={({ event }) => + handleAction({ action: "duplicate-selected", event }) + } + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={SnowIcon} />} + tooltip="Freeze frame (coming soon)" + disabled={true} + onClick={({ event: _event }) => {}} + /> + + <ToolbarButton + icon={<HugeiconsIcon icon={Delete02Icon} />} + tooltip="Delete element" + onClick={({ event }) => + handleAction({ action: "delete-selected", event }) + } + /> + + <div className="bg-border mx-1 h-6 w-px" /> + + <Tooltip> + <ToolbarButton + icon={<HugeiconsIcon icon={Bookmark02Icon} />} + isActive={isCurrentlyBookmarked} + tooltip={isCurrentlyBookmarked ? "Remove bookmark" : "Add bookmark"} + onClick={({ event }) => + handleAction({ action: "toggle-bookmark", event }) + } + /> + </Tooltip> + + <GraphEditorPopover + open={graphEditor.open} + onOpenChange={graphEditor.onOpenChange} + value={ + graphEditor.state.status === "ready" + ? graphEditor.state.cubicBezier + : null + } + message={graphEditor.state.message} + componentOptions={graphEditor.state.componentOptions} + activeComponentKey={graphEditor.state.activeComponentKey} + onActiveComponentKeyChange={graphEditor.onActiveComponentKeyChange} + onPreviewValue={graphEditor.onPreviewValue} + onCommitValue={graphEditor.onCommitValue} + onCancelPreview={graphEditor.onCancelPreview} + > + <ToolbarButton + icon={<HugeiconsIcon icon={Chart03Icon} />} + tooltip={graphEditor.tooltip} + disabled={!graphEditor.canOpen} + buttonWrapper={(button) => + graphEditor.canOpen ? ( + <PopoverTrigger asChild>{button}</PopoverTrigger> + ) : ( + button + ) + } + /> + </GraphEditorPopover> + </TooltipProvider> + </div> + ); +} + +function SceneSelector() { + const editor = useEditor(); + const currentScene = editor.scenes.getActiveScene(); + + return ( + <div> + <SplitButton className="border-foreground/10 border"> + <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> + <SplitButtonSeparator /> + <ScenesView> + <SplitButtonRight onClick={() => {}}> + <HugeiconsIcon icon={Layers01Icon} className="size-4" /> + </SplitButtonRight> + </ScenesView> + </SplitButton> + </div> + ); +} + +function ToolbarRightSection({ + zoomLevel, + minZoom, + onZoomChange, + onZoom, +}: { + zoomLevel: number; + minZoom: number; + onZoomChange: (zoom: number) => void; + onZoom: (options: { direction: "in" | "out" }) => void; +}) { + const snappingEnabled = useTimelineStore((s) => s.snappingEnabled); + const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); + const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); + const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); + + return ( + <div className="flex items-center gap-1"> + <TooltipProvider delayDuration={500}> + <ToolbarButton + icon={<HugeiconsIcon icon={MagnetIcon} />} + isActive={snappingEnabled} + tooltip="Auto snapping" + onClick={() => toggleSnapping()} + /> + + <ToolbarButton + icon={<OcRippleIcon size={24} className="scale-110" />} + isActive={rippleEditingEnabled} + tooltip="Ripple editing" + onClick={() => toggleRippleEditing()} + /> + </TooltipProvider> + + <div className="bg-border mx-1 h-6 w-px" /> + + <div className="flex items-center gap-1"> + <Button + variant="text" + size="icon" + onClick={() => onZoom({ direction: "out" })} + > + <HugeiconsIcon icon={SearchMinusIcon} /> + </Button> + <Slider + className="w-28" + value={[zoomToSlider({ zoomLevel, minZoom })]} + onValueChange={(values) => + onZoomChange(sliderToZoom({ sliderPosition: values[0], minZoom })) + } + min={0} + max={1} + step={0.005} + /> + <Button + variant="text" + size="icon" + onClick={() => onZoom({ direction: "in" })} + > + <HugeiconsIcon icon={SearchAddIcon} /> + </Button> + </div> + </div> + ); +} + +function ToolbarButton({ + icon, + tooltip, + onClick, + disabled, + isActive, + buttonWrapper, +}: { + icon: React.ReactNode; + tooltip: string; + onClick?: ({ event }: { event: React.MouseEvent }) => void; + disabled?: boolean; + isActive?: boolean; + buttonWrapper?: (button: React.ReactElement) => React.ReactElement; +}) { + const button = ( + <Button + variant={isActive ? "secondary" : "text"} + size="icon" + disabled={disabled} + onClick={onClick ? (event) => onClick({ event }) : undefined} + className={cn( + "rounded-sm", + disabled ? "cursor-not-allowed opacity-50" : "", + )} + > + {icon} + </Button> + ); + const trigger = disabled ? ( + <span className="inline-flex">{button}</span> + ) : buttonWrapper ? ( + buttonWrapper(button) + ) : ( + button + ); + + return ( + <Tooltip delayDuration={200}> + <TooltipTrigger asChild>{trigger}</TooltipTrigger> + <TooltipContent>{tooltip}</TooltipContent> + </Tooltip> + ); +} diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx new file mode 100644 index 00000000..6c40637e --- /dev/null +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { TimelineElement } from "./timeline-element"; +import type { TimelineTrack } from "@/timeline"; +import type { TimelineElement as TimelineElementType } from "@/timeline"; +import { TIMELINE_LAYERS } from "./layers"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import type { ElementDragState } from "@/timeline"; +import { useEditor } from "@/editor/use-editor"; + +interface TimelineTrackContentProps { + track: TimelineTrack; + zoomLevel: number; + dragState: ElementDragState; + rulerScrollRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + lastMouseXRef: React.RefObject<number>; + onResizeStart: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + side: "left" | "right"; + }) => void; + onElementMouseDown: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onElementClick: (params: { + event: React.MouseEvent; + element: TimelineElementType; + track: TimelineTrack; + }) => void; + onTrackMouseDown?: (event: React.MouseEvent) => void; + onTrackMouseUp?: (event: React.MouseEvent) => void; + shouldIgnoreClick?: () => boolean; + targetElementId?: string | null; +} + +export function TimelineTrackContent({ + track, + zoomLevel, + dragState, + rulerScrollRef, + tracksScrollRef, + lastMouseXRef, + onResizeStart, + onElementMouseDown, + onElementClick, + onTrackMouseDown, + onTrackMouseUp, + shouldIgnoreClick, + targetElementId = null, +}: TimelineTrackContentProps) { + const { isElementSelected } = useElementSelection(); + const duration = useEditor((e) => e.timeline.getTotalDuration()); + + useEdgeAutoScroll({ + isActive: dragState.isDragging, + getMouseClientX: () => lastMouseXRef.current ?? 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, + }); + + return ( + <div className="relative size-full"> + <button + type="button" + className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" + aria-label={`Select ${track.name} track`} + onMouseUp={(event) => { + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + event.preventDefault(); + onTrackMouseDown?.(event); + }} + /> + {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} + <div + className="relative h-full min-w-full" + style={{ zIndex: TIMELINE_LAYERS.trackContent }} + onMouseUp={(event) => { + if (event.target !== event.currentTarget) return; + if (shouldIgnoreClick?.()) return; + onTrackMouseUp?.(event); + }} + onMouseDown={(event) => { + if (event.target !== event.currentTarget) return; + event.preventDefault(); + onTrackMouseDown?.(event); + }} + > + {track.elements.length === 0 ? ( + <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> + ) : ( + track.elements.map((element) => { + const isSelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + + return ( + <TimelineElement + key={element.id} + element={element} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onResizeStart={({ event, element, side }) => + onResizeStart({ event, element, track, side }) + } + onElementMouseDown={(event, element) => + onElementMouseDown({ event, element, track }) + } + onElementClick={(event, element) => + onElementClick({ event, element, track }) + } + dragState={dragState} + isDropTarget={element.id === targetElementId} + /> + ); + }) + )} + </div> + </div> + ); +} diff --git a/apps/web/src/timeline/components/track-layout.ts b/apps/web/src/timeline/components/track-layout.ts new file mode 100644 index 00000000..59f4a5f9 --- /dev/null +++ b/apps/web/src/timeline/components/track-layout.ts @@ -0,0 +1,60 @@ +import type { TrackType } from "@/timeline"; +import { + KEYFRAME_LANE_HEIGHT_PX, + TIMELINE_TRACK_GAP_PX, + TIMELINE_TRACK_HEIGHTS_PX, +} from "./layout"; + +export function getTrackHeight({ type }: { type: TrackType }): number { + return TIMELINE_TRACK_HEIGHTS_PX[type]; +} + +export function getExpandedTrackHeight({ + type, + expandedLaneCount, +}: { + type: TrackType; + expandedLaneCount: number; +}): number { + return ( + TIMELINE_TRACK_HEIGHTS_PX[type] + + expandedLaneCount * KEYFRAME_LANE_HEIGHT_PX + ); +} + +export function getCumulativeHeightBefore({ + tracks, + trackIndex, + getExtraHeight, +}: { + tracks: Array<{ type: TrackType }>; + trackIndex: number; + getExtraHeight?: (trackIndex: number) => number; +}): number { + return tracks + .slice(0, trackIndex) + .reduce( + (sum, track, i) => + sum + + getTrackHeight({ type: track.type }) + + (getExtraHeight?.(i) ?? 0) + + TIMELINE_TRACK_GAP_PX, + 0, + ); +} + +export function getTotalTracksHeight({ + tracks, + getExtraHeight, +}: { + tracks: Array<{ type: TrackType }>; + getExtraHeight?: (trackIndex: number) => number; +}): number { + const tracksHeight = tracks.reduce( + (sum, track, i) => + sum + getTrackHeight({ type: track.type }) + (getExtraHeight?.(i) ?? 0), + 0, + ); + const gapsHeight = Math.max(0, tracks.length - 1) * TIMELINE_TRACK_GAP_PX; + return tracksHeight + gapsHeight; +} diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts new file mode 100644 index 00000000..2ac7f246 --- /dev/null +++ b/apps/web/src/timeline/creation.ts @@ -0,0 +1,3 @@ +import { TICKS_PER_SECOND } from "@/wasm/ticks"; + +export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts new file mode 100644 index 00000000..06639e01 --- /dev/null +++ b/apps/web/src/timeline/defaults.ts @@ -0,0 +1,77 @@ +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import type { TTimelineViewState } from "@/project/types"; +import type { BlendMode, Transform } from "@/rendering"; +import type { TextElement } from "./types"; + +const defaultTransform: Transform = { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, +}; + +const defaultOpacity = 1; +const defaultBlendMode: BlendMode = "normal"; +const defaultVolume = 0; + +const defaultTextLetterSpacing = 0; +const defaultTextLineHeight = 1.2; + +const defaultTextBackground = { + enabled: false, + color: "#000000", + cornerRadius: 0, + paddingX: 30, + paddingY: 42, + offsetX: 0, + offsetY: 0, +}; + +const defaultTextElement: Omit<TextElement, "id"> = { + type: "text", + name: "Text", + content: "Default text", + fontSize: 15, + fontFamily: "Arial", + color: "#ffffff", + background: { ...defaultTextBackground }, + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + duration: DEFAULT_NEW_ELEMENT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + ...defaultTransform, + position: { ...defaultTransform.position }, + }, + opacity: defaultOpacity, +}; + +const defaultTimelineViewState: TTimelineViewState = { + zoomLevel: 1, + scrollLeft: 0, + playheadTime: 0, +}; + +export const DEFAULTS = { + element: { + transform: defaultTransform, + opacity: defaultOpacity, + blendMode: defaultBlendMode, + volume: defaultVolume, + }, + text: { + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + background: defaultTextBackground, + element: defaultTextElement, + }, + timeline: { + viewState: defaultTimelineViewState, + }, +}; diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts new file mode 100644 index 00000000..1fc9ce9a --- /dev/null +++ b/apps/web/src/timeline/drag-data.ts @@ -0,0 +1,48 @@ +import type { TimelineDragData } from "@/timeline/drag";
+
+const MIME_TYPE = "application/x-timeline-drag";
+let lastDragData: TimelineDragData | null = null;
+
+export function setDragData({
+ dataTransfer,
+ dragData,
+}: {
+ dataTransfer: DataTransfer;
+ dragData: TimelineDragData;
+}): void {
+ dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
+ dataTransfer.setData("text/plain", JSON.stringify(dragData));
+ lastDragData = dragData;
+}
+
+export function getDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): TimelineDragData | null {
+ const data = dataTransfer.getData(MIME_TYPE);
+ if (data) return JSON.parse(data) as TimelineDragData;
+
+ const textData = dataTransfer.getData("text/plain");
+ if (textData) {
+ try {
+ return JSON.parse(textData) as TimelineDragData;
+ } catch {
+ return lastDragData;
+ }
+ }
+
+ return lastDragData;
+}
+
+export function hasDragData({
+ dataTransfer,
+}: {
+ dataTransfer: DataTransfer;
+}): boolean {
+ return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
+}
+
+export function clearDragData(): void {
+ lastDragData = null;
+}
diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts new file mode 100644 index 00000000..a731d7a0 --- /dev/null +++ b/apps/web/src/timeline/drag-utils.ts @@ -0,0 +1,21 @@ +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; + +export function getMouseTimeFromClientX({ + clientX, + containerRect, + zoomLevel, + scrollLeft, +}: { + clientX: number; + containerRect: DOMRect; + zoomLevel: number; + scrollLeft: number; +}): number { + const mouseX = clientX - containerRect.left + scrollLeft; + const seconds = Math.max( + 0, + mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ); + return Math.round(seconds * TICKS_PER_SECOND); +} diff --git a/apps/web/src/timeline/drag.ts b/apps/web/src/timeline/drag.ts new file mode 100644 index 00000000..7bf99a6d --- /dev/null +++ b/apps/web/src/timeline/drag.ts @@ -0,0 +1,42 @@ +import type { MaskableElement, VisualElement } from "./types";
+import type { ParamValues } from "@/params";
+
+interface BaseDragData {
+ id: string;
+ name: string;
+}
+
+export interface MediaDragData extends BaseDragData {
+ type: "media";
+ mediaType: "image" | "video" | "audio";
+ targetElementTypes?: MaskableElement["type"][];
+}
+
+export interface TextDragData extends BaseDragData {
+ type: "text";
+ content: string;
+}
+
+export interface StickerDragData extends BaseDragData {
+ type: "sticker";
+ stickerId: string;
+}
+
+export interface GraphicDragData extends BaseDragData {
+ type: "graphic";
+ definitionId: string;
+ params: Partial<ParamValues>;
+}
+
+export interface EffectDragData extends BaseDragData {
+ type: "effect";
+ effectType: string;
+ targetElementTypes: VisualElement["type"][];
+}
+
+export type TimelineDragData =
+ | MediaDragData
+ | TextDragData
+ | StickerDragData
+ | GraphicDragData
+ | EffectDragData;
diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts new file mode 100644 index 00000000..ec1e5f65 --- /dev/null +++ b/apps/web/src/timeline/element-snap-source.ts @@ -0,0 +1,38 @@ +import type { SceneTracks } from "@/timeline"; +import type { SnapPoint } from "@/timeline/snapping"; + +export function getElementEdgeSnapPoints({ + tracks, + excludeElementIds, +}: { + tracks: SceneTracks; + excludeElementIds?: Set<string>; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + + for (const track of orderedTracks) { + for (const element of track.elements) { + if (excludeElementIds?.has(element.id)) { + continue; + } + + snapPoints.push( + { + time: element.startTime, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: element.startTime + element.duration, + type: "element-end", + elementId: element.id, + trackId: track.id, + }, + ); + } + } + + return snapPoints; +} diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts new file mode 100644 index 00000000..676bc823 --- /dev/null +++ b/apps/web/src/timeline/element-utils.ts @@ -0,0 +1,426 @@ +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { + MASKABLE_ELEMENT_TYPES, + RETIMABLE_ELEMENT_TYPES, + VISUAL_ELEMENT_TYPES, + type CreateEffectElement, + type CreateGraphicElement, + type CreateTimelineElement, + type CreateVideoElement, + type CreateImageElement, + type CreateStickerElement, + type CreateUploadAudioElement, + type CreateLibraryAudioElement, + type TextBackground, + type TextElement, + type SceneTracks, + type TimelineElement, + type AudioElement, + type VideoElement, + type ImageElement, + type MaskableElement, + type RetimableElement, + type VisualElement, + type UploadAudioElement, +} from "@/timeline"; +import { DEFAULTS } from "@/timeline/defaults"; +import type { MediaType } from "@/media/types"; +import { buildDefaultEffectInstance } from "@/effects"; +import { buildDefaultGraphicInstance } from "@/graphics"; +import type { ParamValues } from "@/params"; +import { capitalizeFirstLetter } from "@/utils/string"; + +export function canElementHaveAudio( + element: TimelineElement, +): element is AudioElement | VideoElement { + return element.type === "audio" || element.type === "video"; +} + +export function isVisualElement( + element: TimelineElement, +): element is VisualElement { + return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function isMaskableElement( + element: TimelineElement, +): element is MaskableElement { + return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function isRetimableElement( + element: TimelineElement, +): element is RetimableElement { + return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function canElementBeHidden( + element: TimelineElement, +): element is VisualElement { + return isVisualElement(element); +} + +export function hasElementEffects({ + element, +}: { + element: TimelineElement; +}): boolean { + return isVisualElement(element) && (element.effects?.length ?? 0) > 0; +} + +export function hasMediaId( + element: TimelineElement, +): element is UploadAudioElement | VideoElement | ImageElement { + return "mediaId" in element; +} + +export function requiresMediaId({ + element, +}: { + element: CreateTimelineElement; +}): boolean { + return ( + element.type === "video" || + element.type === "image" || + (element.type === "audio" && element.sourceType === "upload") + ); +} + +function buildTextBackground( + raw: Partial<TextBackground> | undefined, +): TextBackground { + const color = raw?.color ?? DEFAULTS.text.element.background.color; + const enabled = raw?.enabled ?? color !== "transparent"; + return { + enabled, + color, + cornerRadius: raw?.cornerRadius, + paddingX: raw?.paddingX, + paddingY: raw?.paddingY, + offsetX: raw?.offsetX, + offsetY: raw?.offsetY, + }; +} + +export function buildTextElement({ + raw, + startTime, +}: { + raw: Partial<Omit<TextElement, "type" | "id">>; + startTime: number; +}): CreateTimelineElement { + const t = raw as Partial<TextElement>; + + return { + type: "text", + name: t.name ?? DEFAULTS.text.element.name, + content: t.content ?? DEFAULTS.text.element.content, + duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, + fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, + color: t.color ?? DEFAULTS.text.element.color, + background: buildTextBackground(t.background), + textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, + fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, + fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, + textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, + letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, + lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, + transform: t.transform ?? DEFAULTS.text.element.transform, + opacity: t.opacity ?? DEFAULTS.text.element.opacity, + blendMode: t.blendMode ?? DEFAULTS.element.blendMode, + }; +} + +export function buildEffectElement({ + effectType, + startTime, + duration, +}: { + effectType: string; + startTime: number; + duration?: number; +}): CreateEffectElement { + const instance = buildDefaultEffectInstance({ effectType }); + return { + type: "effect", + name: capitalizeFirstLetter({ string: instance.type }), + effectType, + params: instance.params, + duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + }; +} + +export function buildStickerElement({ + stickerId, + name, + startTime, + intrinsicWidth, + intrinsicHeight, +}: { + stickerId: string; + name?: string; + startTime: number; + intrinsicWidth?: number; + intrinsicHeight?: number; +}): CreateStickerElement { + const stickerNameFromId = + stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; + return { + type: "sticker", + name: name ?? stickerNameFromId, + stickerId, + intrinsicWidth, + intrinsicHeight, + duration: DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + }; +} + +export function buildGraphicElement({ + definitionId, + name, + startTime, + params, +}: { + definitionId: string; + name?: string; + startTime: number; + params?: Partial<ParamValues>; +}): CreateGraphicElement { + const instance = buildDefaultGraphicInstance({ definitionId }); + return { + type: "graphic", + name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), + definitionId: instance.definitionId, + params: { ...instance.params, ...(params ?? {}) } as ParamValues, + duration: DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + }; +} + +function buildVideoElement({ + mediaId, + name, + duration, + startTime, +}: { + mediaId: string; + name: string; + duration: number; + startTime: number; +}): CreateVideoElement { + return { + type: "video", + mediaId, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + sourceDuration: duration, + muted: false, + isSourceAudioEnabled: true, + hidden: false, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + volume: DEFAULTS.element.volume, + }; +} + +function buildImageElement({ + mediaId, + name, + duration, + startTime, +}: { + mediaId: string; + name: string; + duration: number; + startTime: number; +}): CreateImageElement { + return { + type: "image", + mediaId, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + hidden: false, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + }; +} + +function buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, +}: { + mediaId: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateUploadAudioElement { + const element: CreateUploadAudioElement = { + type: "audio", + sourceType: "upload", + mediaId, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + sourceDuration: duration, + volume: DEFAULTS.element.volume, + muted: false, + }; + if (buffer) { + element.buffer = buffer; + } + return element; +} + +export function buildElementFromMedia({ + mediaId, + mediaType, + name, + duration, + startTime, + buffer, +}: { + mediaId: string; + mediaType: MediaType; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateTimelineElement { + switch (mediaType) { + case "audio": + return buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, + }); + case "video": + return buildVideoElement({ mediaId, name, duration, startTime }); + case "image": + return buildImageElement({ mediaId, name, duration, startTime }); + } +} + +export function buildLibraryAudioElement({ + sourceUrl, + name, + duration, + startTime, + buffer, +}: { + sourceUrl: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateLibraryAudioElement { + const element: CreateLibraryAudioElement = { + type: "audio", + sourceType: "library", + sourceUrl, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + sourceDuration: duration, + volume: DEFAULTS.element.volume, + muted: false, + }; + if (buffer) { + element.buffer = buffer; + } + return element; +} + +export function getElementsAtTime({ + tracks, + time, +}: { + tracks: SceneTracks; + time: number; +}): { trackId: string; elementId: string }[] { + const result: { trackId: string; elementId: string }[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + + for (const track of orderedTracks) { + for (const element of track.elements) { + const elementStart = element.startTime; + const elementEnd = element.startTime + element.duration; + + if (time > elementStart && time < elementEnd) { + result.push({ trackId: track.id, elementId: element.id }); + } + } + } + + return result; +} + +export function getElementFontFamilies({ + tracks, +}: { + tracks: SceneTracks; +}): string[] { + const families = new Set<string>(); + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { + for (const element of track.elements) { + if (element.type === "text" && element.fontFamily) { + families.add(element.fontFamily); + } + if ("masks" in element) { + for (const mask of element.masks ?? []) { + if (mask.type === "text" && mask.params.fontFamily) { + families.add(mask.params.fontFamily); + } + } + } + } + } + return [...families]; +} diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts new file mode 100644 index 00000000..00f39d55 --- /dev/null +++ b/apps/web/src/timeline/group-move/build-group.ts @@ -0,0 +1,87 @@ +import type { ElementRef, SceneTracks } from "@/timeline"; +import { findTrackInSceneTracks } from "@/timeline/track-element-update"; +import type { GroupMember, MoveGroup } from "./types"; +import { getTrackPlacementById } from "./track-placement"; + +export function buildMoveGroup({ + anchorRef, + selectedElements, + tracks, +}: { + anchorRef: ElementRef; + selectedElements: ElementRef[]; + tracks: SceneTracks; +}): MoveGroup | null { + const anchorTrack = findTrackInSceneTracks({ + tracks, + trackId: anchorRef.trackId, + }); + const anchorElement = anchorTrack?.elements.find( + (element) => element.id === anchorRef.elementId, + ); + const anchorPlacement = getTrackPlacementById({ + tracks, + trackId: anchorRef.trackId, + }); + if (!anchorTrack || !anchorElement || !anchorPlacement) { + return null; + } + + const seen = new Set<string>(); + const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => { + if (seen.has(elementRef.elementId)) { + return false; + } + + seen.add(elementRef.elementId); + return true; + }); + + const members = orderedRefs.flatMap((elementRef): GroupMember[] => { + const track = findTrackInSceneTracks({ + tracks, + trackId: elementRef.trackId, + }); + const element = track?.elements.find( + (trackElement) => trackElement.id === elementRef.elementId, + ); + const placement = getTrackPlacementById({ + tracks, + trackId: elementRef.trackId, + }); + if (!track || !element || !placement) { + return []; + } + + return [ + { + trackId: track.id, + elementId: element.id, + elementType: element.type, + duration: element.duration, + timeOffset: element.startTime - anchorElement.startTime, + trackSection: placement.section, + sectionIndex: placement.sectionIndex, + displayIndex: placement.displayIndex, + }, + ]; + }); + + if (members.length === 0) { + return null; + } + + const anchor = members.find( + (member) => + member.trackId === anchorRef.trackId && + member.elementId === anchorRef.elementId, + ); + if (!anchor) { + return null; + } + + return { + anchor, + members, + }; +} diff --git a/apps/web/src/timeline/group-move/index.ts b/apps/web/src/timeline/group-move/index.ts new file mode 100644 index 00000000..82bd3895 --- /dev/null +++ b/apps/web/src/timeline/group-move/index.ts @@ -0,0 +1,12 @@ +export { buildMoveGroup } from "./build-group"; +export { resolveGroupMove } from "./resolve-move"; +export { snapGroupEdges } from "./snap"; + +export type { + GroupMember, + GroupMoveResult, + GroupTrackSection, + MoveGroup, + PlannedElementMove, + PlannedTrackCreation, +} from "./types"; diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts new file mode 100644 index 00000000..60940315 --- /dev/null +++ b/apps/web/src/timeline/group-move/resolve-move.ts @@ -0,0 +1,470 @@ +import type { SceneTracks } from "@/timeline"; +import { getTrackTypeForElementType } from "@/timeline/placement/compatibility"; +import { canPlaceTimeSpansOnTrack } from "@/timeline/placement/overlap"; +import type { + GroupMoveResult, + MoveGroup, + PlannedElementMove, + PlannedTrackCreation, +} from "./types"; +import { + getDisplayTracks, + getTrackPlacementByDisplayIndex, + getTrackPlacementById, +} from "./track-placement"; + +type GroupMoveTarget = + | { + kind: "existingTrack"; + anchorTargetTrackId: string; + } + | { + kind: "newTracks"; + anchorInsertIndex: number; + newTrackIds: string[]; + }; + +export function resolveGroupMove({ + group, + tracks, + anchorStartTime, + target, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + target: GroupMoveTarget; +}): GroupMoveResult | null { + if (target.kind === "newTracks") { + return resolveNewTrackMove({ + group, + tracks, + anchorStartTime, + anchorInsertIndex: target.anchorInsertIndex, + newTrackIds: target.newTrackIds, + }); + } + + return resolveExistingTrackMove({ + group, + tracks, + anchorStartTime, + anchorTargetTrackId: target.anchorTargetTrackId, + }); +} + +function resolveExistingTrackMove({ + group, + tracks, + anchorStartTime, + anchorTargetTrackId, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + anchorTargetTrackId: string; +}): GroupMoveResult | null { + const anchorTargetPlacement = getTrackPlacementById({ + tracks, + trackId: anchorTargetTrackId, + }); + if (!anchorTargetPlacement) { + return null; + } + + const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({ + group, + tracks, + anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex, + }); + if (!targetTrackIdsByElementId) { + return null; + } + + const clampedAnchorStartTime = clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId, + }); + + const moves = group.members.map((member) => ({ + sourceTrackId: member.trackId, + targetTrackId: + targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, + elementId: member.elementId, + newStartTime: clampedAnchorStartTime + member.timeOffset, + })); + + if (!canApplyMovesToExistingTracks({ tracks, moves })) { + return null; + } + + return { + moves, + createTracks: [], + targetSelection: moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + }; +} + +function resolveNewTrackMove({ + group, + tracks, + anchorStartTime, + anchorInsertIndex, + newTrackIds, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + anchorInsertIndex: number; + newTrackIds: string[]; +}): GroupMoveResult | null { + const sortedMembers = [...group.members].sort( + (leftMember, rightMember) => + leftMember.displayIndex - rightMember.displayIndex, + ); + const anchorMemberIndex = sortedMembers.findIndex( + (member) => member.elementId === group.anchor.elementId, + ); + if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) { + return null; + } + + const hasAudioMember = sortedMembers.some( + (member) => member.trackSection === "audio", + ); + const hasNonAudioMember = sortedMembers.some( + (member) => member.trackSection !== "audio", + ); + if (hasAudioMember && hasNonAudioMember) { + return null; + } + + const clampedAnchorStartTime = clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId: new Map(), + }); + const blockStartIndex = hasAudioMember + ? clampAudioInsertIndex({ + tracks, + insertIndex: anchorInsertIndex - anchorMemberIndex, + }) + : Math.max( + 0, + Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length), + ); + + const createTracks: PlannedTrackCreation[] = sortedMembers.map( + (member, memberIndex) => ({ + id: newTrackIds[memberIndex], + type: getTrackTypeForElementType({ + elementType: member.elementType, + }), + index: blockStartIndex + memberIndex, + }), + ); + const moves = sortedMembers.map((member, memberIndex) => ({ + sourceTrackId: member.trackId, + targetTrackId: newTrackIds[memberIndex], + elementId: member.elementId, + newStartTime: clampedAnchorStartTime + member.timeOffset, + })); + + return { + moves, + createTracks, + targetSelection: moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + }; +} + +function clampAudioInsertIndex({ + tracks, + insertIndex, +}: { + tracks: SceneTracks; + insertIndex: number; +}): number { + const minimumAudioInsertIndex = tracks.overlay.length + 1; + return Math.max( + minimumAudioInsertIndex, + Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length), + ); +} + +function resolveExistingTrackIdsByElementId({ + group, + tracks, + anchorTargetDisplayIndex, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorTargetDisplayIndex: number; +}): Map<string, string> | null { + const sortedMembers = [...group.members].sort( + (leftMember, rightMember) => + leftMember.displayIndex - rightMember.displayIndex, + ); + const anchorMemberIndex = sortedMembers.findIndex( + (member) => member.elementId === group.anchor.elementId, + ); + if (anchorMemberIndex < 0) { + return null; + } + + const targetTrackIdsByElementId = new Map<string, string>(); + const usedTrackIds = new Set<string>(); + const anchorPlacement = getTrackPlacementByDisplayIndex({ + tracks, + displayIndex: anchorTargetDisplayIndex, + }); + if (!anchorPlacement) { + return null; + } + + targetTrackIdsByElementId.set( + group.anchor.elementId, + anchorPlacement.trackId, + ); + usedTrackIds.add(anchorPlacement.trackId); + + let upperBoundaryIndex = anchorTargetDisplayIndex; + for ( + let memberIndex = anchorMemberIndex - 1; + memberIndex >= 0; + memberIndex -= 1 + ) { + const member = sortedMembers[memberIndex]; + const targetPlacement = findCompatibleTrackPlacement({ + tracks, + requiredTrackType: getTrackTypeForElementType({ + elementType: member.elementType, + }), + startDisplayIndex: upperBoundaryIndex - 1, + step: -1, + usedTrackIds, + }); + if (!targetPlacement) { + return null; + } + + targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId); + usedTrackIds.add(targetPlacement.trackId); + upperBoundaryIndex = targetPlacement.displayIndex; + } + + let lowerBoundaryIndex = anchorTargetDisplayIndex; + for ( + let memberIndex = anchorMemberIndex + 1; + memberIndex < sortedMembers.length; + memberIndex += 1 + ) { + const member = sortedMembers[memberIndex]; + const targetPlacement = findCompatibleTrackPlacement({ + tracks, + requiredTrackType: getTrackTypeForElementType({ + elementType: member.elementType, + }), + startDisplayIndex: lowerBoundaryIndex + 1, + step: 1, + usedTrackIds, + }); + if (!targetPlacement) { + return null; + } + + targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId); + usedTrackIds.add(targetPlacement.trackId); + lowerBoundaryIndex = targetPlacement.displayIndex; + } + + return targetTrackIdsByElementId; +} + +function findCompatibleTrackPlacement({ + tracks, + requiredTrackType, + startDisplayIndex, + step, + usedTrackIds, +}: { + tracks: SceneTracks; + requiredTrackType: ReturnType<typeof getTrackTypeForElementType>; + startDisplayIndex: number; + step: -1 | 1; + usedTrackIds: Set<string>; +}) { + for ( + let displayIndex = startDisplayIndex; + displayIndex >= 0 && + displayIndex < tracks.overlay.length + 1 + tracks.audio.length; + displayIndex += step + ) { + const placement = getTrackPlacementByDisplayIndex({ + tracks, + displayIndex, + }); + if (!placement) { + continue; + } + + if ( + placement.trackType === requiredTrackType && + !usedTrackIds.has(placement.trackId) + ) { + return placement; + } + } + + return null; +} + +function clampAnchorStartTime({ + group, + tracks, + anchorStartTime, + targetTrackIdsByElementId, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: number; + targetTrackIdsByElementId: Map<string, string>; +}): number { + const minimumAnchorStartTime = Math.max( + 0, + ...group.members.map((member) => -member.timeOffset), + ); + let clampedAnchorStartTime = Math.max( + minimumAnchorStartTime, + anchorStartTime, + ); + + const memberOnMainTrack = group.members.find( + (member) => + targetTrackIdsByElementId.get(member.elementId) === tracks.main.id, + ); + if (!memberOnMainTrack) { + return clampedAnchorStartTime; + } + + const movingElementIds = new Set( + group.members.map((member) => member.elementId), + ); + const requestedMainStartTime = + clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const earliestStationaryMainStartTime = tracks.main.elements + .filter((element) => !movingElementIds.has(element.id)) + .reduce<number | null>((earliestStartTime, element) => { + if (earliestStartTime == null || element.startTime < earliestStartTime) { + return element.startTime; + } + + return earliestStartTime; + }, null); + if ( + earliestStationaryMainStartTime == null || + requestedMainStartTime <= earliestStationaryMainStartTime + ) { + clampedAnchorStartTime = Math.max( + minimumAnchorStartTime, + -memberOnMainTrack.timeOffset, + ); + } + + return clampedAnchorStartTime; +} + +function canApplyMovesToExistingTracks({ + tracks, + moves, +}: { + tracks: SceneTracks; + moves: PlannedElementMove[]; +}): boolean { + const movingElementIds = new Set(moves.map((move) => move.elementId)); + const sourceElements = new Map( + getDisplayTracks({ tracks }).flatMap((track) => + track.elements.map((element) => [element.id, element] as const), + ), + ); + const movesByTargetTrackId = new Map<string, PlannedElementMove[]>(); + for (const move of moves) { + const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? []; + targetMoves.push(move); + movesByTargetTrackId.set(move.targetTrackId, targetMoves); + } + + for (const [targetTrackId, targetMoves] of movesByTargetTrackId) { + const targetPlacement = getTrackPlacementById({ + tracks, + trackId: targetTrackId, + }); + if (!targetPlacement) { + return false; + } + + const targetTrack = getDisplayTracks({ tracks })[ + targetPlacement.displayIndex + ]; + if (!targetTrack) { + return false; + } + + const timeSpans = targetMoves.map((move) => { + const sourceElement = sourceElements.get(move.elementId); + return { + startTime: move.newStartTime, + duration: sourceElement?.duration ?? 0, + }; + }); + if (hasOverlappingTimeSpans({ timeSpans })) { + return false; + } + + if ( + !canPlaceTimeSpansOnTrack({ + track: { + elements: targetTrack.elements.filter( + (element) => !movingElementIds.has(element.id), + ), + }, + timeSpans, + }) + ) { + return false; + } + } + + return true; +} + +function hasOverlappingTimeSpans({ + timeSpans, +}: { + timeSpans: Array<{ startTime: number; duration: number }>; +}): boolean { + const sortedSpans = [...timeSpans].sort( + (leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime, + ); + + for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) { + const previousSpan = sortedSpans[spanIndex - 1]; + const currentSpan = sortedSpans[spanIndex]; + if ( + previousSpan.startTime + previousSpan.duration > + currentSpan.startTime + ) { + return true; + } + } + + return false; +} diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts new file mode 100644 index 00000000..32c0a2f1 --- /dev/null +++ b/apps/web/src/timeline/group-move/snap.ts @@ -0,0 +1,85 @@ +import type { SceneTracks } from "@/timeline"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import type { MoveGroup } from "./types"; + +export function snapGroupEdges({ + group, + anchorStartTime, + tracks, + playheadTime, + zoomLevel, +}: { + group: MoveGroup; + anchorStartTime: number; + tracks: SceneTracks; + playheadTime: number; + zoomLevel: number; +}): { + snappedAnchorStartTime: number; + snapPoint: SnapPoint | null; +} { + const excludeElementIds = new Set( + group.members.map((member) => member.elementId), + ); + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), + () => getPlayheadSnapPoints({ playheadTime }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, + }), + ], + }); + const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); + + let closestSnapDistance = Infinity; + let snappedAnchorStartTime = anchorStartTime; + let snapPoint: SnapPoint | null = null; + + for (const member of group.members) { + const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartSnap = resolveTimelineSnap({ + targetTime: memberStartTime, + snapPoints, + maxSnapDistance, + }); + if ( + memberStartSnap.snapPoint && + memberStartSnap.snapDistance < closestSnapDistance + ) { + closestSnapDistance = memberStartSnap.snapDistance; + snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snapPoint = memberStartSnap.snapPoint; + } + + const memberEndSnap = resolveTimelineSnap({ + targetTime: memberStartTime + member.duration, + snapPoints, + maxSnapDistance, + }); + if ( + memberEndSnap.snapPoint && + memberEndSnap.snapDistance < closestSnapDistance + ) { + closestSnapDistance = memberEndSnap.snapDistance; + snappedAnchorStartTime = + memberEndSnap.snappedTime - member.duration - member.timeOffset; + snapPoint = memberEndSnap.snapPoint; + } + } + + return { + snappedAnchorStartTime, + snapPoint, + }; +} diff --git a/apps/web/src/timeline/group-move/track-placement.ts b/apps/web/src/timeline/group-move/track-placement.ts new file mode 100644 index 00000000..c4c5efb5 --- /dev/null +++ b/apps/web/src/timeline/group-move/track-placement.ts @@ -0,0 +1,83 @@ +import type { SceneTracks, TimelineTrack } from "@/timeline"; +import type { GroupTrackSection } from "./types"; + +export interface TrackPlacement { + trackId: string; + trackType: TimelineTrack["type"]; + section: GroupTrackSection; + sectionIndex: number; + displayIndex: number; +} + +export function getDisplayTracks({ + tracks, +}: { + tracks: SceneTracks; +}): TimelineTrack[] { + return [...tracks.overlay, tracks.main, ...tracks.audio]; +} + +export function getTrackPlacementById({ + tracks, + trackId, +}: { + tracks: SceneTracks; + trackId: string; +}): TrackPlacement | null { + if (tracks.main.id === trackId) { + return { + trackId, + trackType: tracks.main.type, + section: "main", + sectionIndex: -1, + displayIndex: tracks.overlay.length, + }; + } + + const overlayTrackIndex = tracks.overlay.findIndex( + (track) => track.id === trackId, + ); + if (overlayTrackIndex >= 0) { + return { + trackId, + trackType: tracks.overlay[overlayTrackIndex].type, + section: "overlay", + sectionIndex: overlayTrackIndex, + displayIndex: overlayTrackIndex, + }; + } + + const audioTrackIndex = tracks.audio.findIndex( + (track) => track.id === trackId, + ); + if (audioTrackIndex >= 0) { + return { + trackId, + trackType: tracks.audio[audioTrackIndex].type, + section: "audio", + sectionIndex: audioTrackIndex, + displayIndex: tracks.overlay.length + 1 + audioTrackIndex, + }; + } + + return null; +} + +export function getTrackPlacementByDisplayIndex({ + tracks, + displayIndex, +}: { + tracks: SceneTracks; + displayIndex: number; +}): TrackPlacement | null { + const displayTracks = getDisplayTracks({ tracks }); + const track = displayTracks[displayIndex]; + if (!track) { + return null; + } + + return getTrackPlacementById({ + tracks, + trackId: track.id, + }); +} diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts new file mode 100644 index 00000000..daf9c6e9 --- /dev/null +++ b/apps/web/src/timeline/group-move/types.ts @@ -0,0 +1,36 @@ +import type { ElementRef, ElementType, TrackType } from "@/timeline"; + +export type GroupTrackSection = "overlay" | "main" | "audio"; + +export interface GroupMember extends ElementRef { + elementType: ElementType; + duration: number; + timeOffset: number; + trackSection: GroupTrackSection; + sectionIndex: number; + displayIndex: number; +} + +export interface MoveGroup { + anchor: GroupMember; + members: GroupMember[]; +} + +export interface PlannedTrackCreation { + id: string; + type: TrackType; + index: number; +} + +export interface PlannedElementMove { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; +} + +export interface GroupMoveResult { + moves: PlannedElementMove[]; + createTracks: PlannedTrackCreation[]; + targetSelection: ElementRef[]; +} diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts new file mode 100644 index 00000000..dd542967 --- /dev/null +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -0,0 +1,249 @@ +import { roundToFrame } from "opencut-wasm"; +import { + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/retime"; +import { TICKS_PER_SECOND } from "@/wasm"; +import type { + ComputeGroupResizeArgs, + GroupResizeMember, + GroupResizeResult, + GroupResizeUpdate, + ResizeSide, +} from "./types"; + +export function computeGroupResize({ + members, + side, + deltaTime, + fps, +}: ComputeGroupResizeArgs): GroupResizeResult { + const minDuration = Math.round( + (TICKS_PER_SECOND * fps.denominator) / fps.numerator, + ); + const minimumDeltaTime = Math.max( + ...members.map((member) => + getMinimumAllowedDeltaTime({ + member, + side, + minDuration, + }), + ), + ); + const maximumDeltaTime = Math.min( + ...members.map((member) => + getMaximumAllowedDeltaTime({ + member, + side, + minDuration, + }), + ), + ); + const clampedDeltaTime = + minimumDeltaTime > maximumDeltaTime + ? minimumDeltaTime + : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime)); + + // Snap the drag delta to a frame exactly once, then derive every patch + // field from that single snapped value. This keeps the invariant + // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same + // delta is added on one side of the element and removed from the other, + // so the rounding cancels by construction. Per-field rounding (the old + // approach) couldn't preserve this because the individual rounds don't + // compose when `sourceDuration` isn't frame-aligned. + const snappedDeltaTime = + roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime; + // Re-clamp after rounding. Bounds derived from other elements are + // frame-aligned, so this is normally a no-op; at the source-extent limit + // the bound may not be frame-aligned, and honouring the bound takes + // precedence over frame alignment (you can't extend past real content). + const finalDeltaTime = + minimumDeltaTime > maximumDeltaTime + ? minimumDeltaTime + : Math.min( + maximumDeltaTime, + Math.max(minimumDeltaTime, snappedDeltaTime), + ); + + return { + deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime, + updates: members.map((member) => + buildResizeUpdate({ + member, + side, + deltaTime: finalDeltaTime, + }), + ), + }; +} + +function buildResizeUpdate({ + member, + side, + deltaTime, +}: { + member: GroupResizeMember; + side: ResizeSide; + deltaTime: number; +}): GroupResizeUpdate { + const sourceDelta = getSourceDeltaForClipDelta({ + member, + clipDelta: deltaTime, + }); + + if (side === "left") { + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: Math.max(0, member.trimStart + sourceDelta), + trimEnd: member.trimEnd, + startTime: member.startTime + deltaTime, + duration: member.duration - deltaTime, + }, + }; + } + + return { + trackId: member.trackId, + elementId: member.elementId, + patch: { + trimStart: member.trimStart, + trimEnd: Math.max(0, member.trimEnd - sourceDelta), + startTime: member.startTime, + duration: member.duration + deltaTime, + }, + }; +} + +function getMinimumAllowedDeltaTime({ + member, + side, + minDuration, +}: { + member: GroupResizeMember; + side: ResizeSide; + minDuration: number; +}): number { + if (side === "right") { + return minDuration - member.duration; + } + + const leftNeighborFloor = Number.isFinite(member.leftNeighborBound) + ? member.leftNeighborBound - member.startTime + : -member.startTime; + if (member.sourceDuration == null) { + return leftNeighborFloor; + } + + const maximumSourceExtension = + getDurationForVisibleSourceSpan({ + member, + sourceSpan: + getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }) + member.trimStart, + }) - member.duration; + return Math.max(leftNeighborFloor, -maximumSourceExtension); +} + +function getMaximumAllowedDeltaTime({ + member, + side, + minDuration, +}: { + member: GroupResizeMember; + side: ResizeSide; + minDuration: number; +}): number { + if (side === "left") { + return member.duration - minDuration; + } + + const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound) + ? member.rightNeighborBound - (member.startTime + member.duration) + : Infinity; + if (member.sourceDuration == null) { + return rightNeighborCeiling; + } + + const maximumVisibleSourceSpan = + getSourceDuration({ member }) - member.trimStart; + const maximumDuration = getDurationForVisibleSourceSpan({ + member, + sourceSpan: maximumVisibleSourceSpan, + }); + return Math.min(rightNeighborCeiling, maximumDuration - member.duration); +} + +function getSourceDeltaForClipDelta({ + member, + clipDelta, +}: { + member: GroupResizeMember; + clipDelta: number; +}): number { + if (!member.retime) { + return clipDelta; + } + + return clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); +} + +function getVisibleSourceSpanForDuration({ + member, + duration, +}: { + member: GroupResizeMember; + duration: number; +}): number { + if (!member.retime) { + return duration; + } + + return getSourceSpanAtClipTime({ + clipTime: duration, + retime: member.retime, + }); +} + +function getDurationForVisibleSourceSpan({ + member, + sourceSpan, +}: { + member: GroupResizeMember; + sourceSpan: number; +}): number { + if (!member.retime) { + return sourceSpan; + } + + return getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }); +} + +function getSourceDuration({ member }: { member: GroupResizeMember }): number { + if (typeof member.sourceDuration === "number") { + return member.sourceDuration; + } + + return ( + member.trimStart + + getVisibleSourceSpanForDuration({ + member, + duration: member.duration, + }) + + member.trimEnd + ); +} diff --git a/apps/web/src/timeline/group-resize/index.ts b/apps/web/src/timeline/group-resize/index.ts new file mode 100644 index 00000000..bc0a7b35 --- /dev/null +++ b/apps/web/src/timeline/group-resize/index.ts @@ -0,0 +1,9 @@ +export { computeGroupResize } from "./compute-resize"; + +export type { + ComputeGroupResizeArgs, + GroupResizeMember, + GroupResizeResult, + GroupResizeUpdate, + ResizeSide, +} from "./types"; diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts new file mode 100644 index 00000000..b07e6fe6 --- /dev/null +++ b/apps/web/src/timeline/group-resize/types.ts @@ -0,0 +1,36 @@ +import type { FrameRate } from "opencut-wasm"; +import type { ElementRef, RetimeConfig } from "@/timeline/types"; + +export type ResizeSide = "left" | "right"; + +export interface GroupResizeMember extends ElementRef { + startTime: number; + duration: number; + trimStart: number; + trimEnd: number; + sourceDuration?: number; + retime?: RetimeConfig; + leftNeighborBound: number; + rightNeighborBound: number; +} + +export interface GroupResizeUpdate extends ElementRef { + patch: { + trimStart: number; + trimEnd: number; + startTime: number; + duration: number; + }; +} + +export interface GroupResizeResult { + deltaTime: number; + updates: GroupResizeUpdate[]; +} + +export interface ComputeGroupResizeArgs { + members: GroupResizeMember[]; + side: ResizeSide; + deltaTime: number; + fps: FrameRate; +} diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts new file mode 100644 index 00000000..621259b9 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -0,0 +1,760 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from "react"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/timeline/group-move"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { roundToFrame } from "opencut-wasm"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { generateUUID } from "@/utils/id"; +import type { SnapPoint } from "@/timeline/snapping"; +import { registerCanceller } from "@/editor/cancel-interaction"; +import type { + DropTarget, + ElementRef, + ElementDragState, + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/timeline"; + +interface UseElementInteractionProps { + zoomLevel: number; + timelineRef: RefObject<HTMLDivElement | null>; + tracksContainerRef: RefObject<HTMLDivElement | null>; + tracksScrollRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + snappingEnabled: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +const MOUSE_BUTTON_RIGHT = 2; + +const initialDragState: ElementDragState = { + isDragging: false, + elementId: null, + dragElementIds: [], + dragTimeOffsets: {}, + trackId: null, + startMouseX: 0, + startMouseY: 0, + startElementTime: 0, + clickOffsetTime: 0, + currentTime: 0, + currentMouseY: 0, +}; + +interface PendingDragState { + elementId: string; + trackId: string; + selectedElements: ElementRef[]; + startMouseX: number; + startMouseY: number; + startElementTime: number; + clickOffsetTime: number; +} + +function getClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): number { + const clickOffsetX = clientX - elementRect.left; + const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + return Math.round(seconds * TICKS_PER_SECOND); +} + +function getVerticalDragDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { + if (currentMouseY < startMouseY) return "up"; + if (currentMouseY > startMouseY) return "down"; + return null; +} + +function getDragDropTarget({ + clientX, + clientY, + elementId, + trackId, + tracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime, + verticalDragDirection, +}: { + clientX: number; + clientY: number; + elementId: string; + trackId: string; + tracks: SceneTracks; + tracksContainerRef: RefObject<HTMLDivElement | null>; + tracksScrollRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + zoomLevel: number; + snappedTime: number; + verticalDragDirection?: "up" | "down" | null; +}): DropTarget | null { + const containerRect = tracksContainerRef.current?.getBoundingClientRect(); + const scrollContainer = tracksScrollRef.current; + if (!containerRect || !scrollContainer) return null; + + const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( + ({ id }) => id === trackId, + ); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === elementId, + ); + if (!movingElement) return null; + + const elementDuration = movingElement.duration; + const scrollLeft = scrollContainer.scrollLeft; + const scrollTop = scrollContainer.scrollTop; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseX = clientX - scrollContainerRect.left + scrollLeft; + const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; + + return computeDropTarget({ + elementType: movingElement.type, + mouseX, + mouseY, + tracks, + playheadTime: snappedTime, + isExternalDrop: false, + elementDuration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + startTimeOverride: snappedTime, + excludeElementId: movingElement.id, + verticalDragDirection, + }); +} + +interface StartDragParams + extends Omit< + ElementDragState, + "isDragging" | "currentTime" | "currentMouseY" + > { + initialCurrentTime: number; + initialCurrentMouseY: number; +} + +export function useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef, + snappingEnabled, + onSnapPointChange, +}: UseElementInteractionProps) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const sceneTracks = editor.scenes.getActiveScene().tracks; + const { + selectedElements, + isElementSelected, + selectElement, + handleElementClick: handleSelectionClick, + } = useElementSelection(); + + const [dragState, setDragState] = + useState<ElementDragState>(initialDragState); + const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef<PendingDragState | null>(null); + const moveGroupRef = useRef<MoveGroup | null>(null); + const newTrackIdsRef = useRef<string[]>([]); + const groupMoveResultRef = useRef<GroupMoveResult | null>(null); + const lastMouseXRef = useRef(0); + const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); + + const startDrag = useCallback( + ({ + elementId, + dragElementIds, + dragTimeOffsets, + trackId, + startMouseX, + startMouseY, + startElementTime, + clickOffsetTime, + initialCurrentTime, + initialCurrentMouseY, + }: StartDragParams) => { + setDragState({ + isDragging: true, + elementId, + dragElementIds, + dragTimeOffsets, + trackId, + startMouseX, + startMouseY, + startElementTime, + clickOffsetTime, + currentTime: initialCurrentTime, + currentMouseY: initialCurrentMouseY, + }); + }, + [], + ); + + const endDrag = useCallback(() => { + moveGroupRef.current = null; + newTrackIdsRef.current = []; + groupMoveResultRef.current = null; + setDragState(initialDragState); + setDragDropTarget(null); + }, []); + + const cancelCurrentDrag = useCallback(() => { + pendingDragRef.current = null; + mouseDownLocationRef.current = null; + setIsPendingDrag(false); + endDrag(); + onSnapPointChange?.(null); + }, [endDrag, onSnapPointChange]); + + const resolveGroupDragMove = useCallback( + ({ + group, + snappedTime, + dropTarget, + }: { + group: MoveGroup; + snappedTime: number; + dropTarget: DropTarget | null; + }): GroupMoveResult | null => { + if (!dropTarget) { + return null; + } + + if (dropTarget.isNewTrack) { + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: newTrackIdsRef.current, + }, + }); + } + + const orderedTracks = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ]; + const targetTrack = orderedTracks[dropTarget.trackIndex]; + if (!targetTrack) { + return null; + } + + const existingTrackResult = resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "existingTrack", + anchorTargetTrackId: targetTrack.id, + }, + }); + if (existingTrackResult) { + return existingTrackResult; + } + + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: newTrackIdsRef.current, + }, + }); + }, + [sceneTracks], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + return registerCanceller({ fn: cancelCurrentDrag }); + }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); + + const getDragSnapResult = useCallback( + ({ + frameSnappedTime, + group, + }: { + frameSnappedTime: number; + group: MoveGroup | null; + }) => { + if (!group || !snappingEnabled || isShiftHeldRef.current) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const groupSnap = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, + tracks: sceneTracks, + playheadTime: editor.playback.getCurrentTime(), + zoomLevel, + }); + + return { + snappedTime: groupSnap.snappedAnchorStartTime, + snapPoint: groupSnap.snapPoint, + }; + }, + [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { + let startedDragThisEvent = false; + const timeline = timelineRef.current; + const scrollContainer = tracksScrollRef.current; + if (!timeline || !scrollContainer) return; + lastMouseXRef.current = clientX; + + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); + if ( + deltaX > TIMELINE_DRAG_THRESHOLD_PX || + deltaY > TIMELINE_DRAG_THRESHOLD_PX + ) { + const activeProject = editor.project.getActive(); + if (!activeProject) return; + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const adjustedTime = Math.max( + 0, + mouseTime - pendingDragRef.current.clickOffsetTime, + ); + const snappedTime = + roundToFrame({ + time: adjustedTime, + rate: activeProject.settings.fps, + }) ?? adjustedTime; + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: pendingDragRef.current.trackId, + elementId: pendingDragRef.current.elementId, + }, + selectedElements: pendingDragRef.current.selectedElements, + tracks: sceneTracks, + }); + if (!moveGroup) { + return; + } + + moveGroupRef.current = moveGroup; + newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); + const dragTimeOffsets: Record<string, number> = {}; + for (const member of moveGroup.members) { + dragTimeOffsets[member.elementId] = member.timeOffset; + } + const { + snappedTime: initialSnappedTime, + snapPoint: initialSnapPoint, + } = getDragSnapResult({ + frameSnappedTime: snappedTime, + group: moveGroup, + }); + const verticalDragDirection = getVerticalDragDirection({ + startMouseY: pendingDragRef.current.startMouseY, + currentMouseY: clientY, + }); + const anchorDropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: pendingDragRef.current.elementId, + trackId: pendingDragRef.current.trackId, + tracks: sceneTracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime: initialSnappedTime, + verticalDragDirection, + }); + const nextGroupMoveResult = + anchorDropTarget != null + ? resolveGroupDragMove({ + group: moveGroup, + snappedTime: initialSnappedTime, + dropTarget: anchorDropTarget, + }) + : null; + groupMoveResultRef.current = nextGroupMoveResult; + setDragDropTarget( + anchorDropTarget && + (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { + ...anchorDropTarget, + isNewTrack: true, + } + : null, + ); + startDrag({ + ...pendingDragRef.current, + dragElementIds: moveGroup.members.map((member) => member.elementId), + dragTimeOffsets, + initialCurrentTime: initialSnappedTime, + initialCurrentMouseY: clientY, + }); + onSnapPointChange?.(initialSnapPoint); + startedDragThisEvent = true; + pendingDragRef.current = null; + setIsPendingDrag(false); + } else { + return; + } + } + + if (startedDragThisEvent) { + return; + } + + if (dragState.elementId && dragState.trackId) { + const alreadySelected = isElementSelected({ + trackId: dragState.trackId, + elementId: dragState.elementId, + }); + if (!alreadySelected) { + selectElement({ + trackId: dragState.trackId, + elementId: dragState.elementId, + }); + } + } + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const fps = activeProject.settings.fps; + const frameSnappedTime = + roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; + + const moveGroup = moveGroupRef.current; + const { snappedTime, snapPoint } = getDragSnapResult({ + frameSnappedTime, + group: moveGroup, + }); + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snappedTime, + currentMouseY: clientY, + })); + onSnapPointChange?.(snapPoint); + + if (dragState.elementId && dragState.trackId) { + const verticalDragDirection = getVerticalDragDirection({ + startMouseY: dragState.startMouseY, + currentMouseY: clientY, + }); + const anchorDropTarget = getDragDropTarget({ + clientX, + clientY, + elementId: dragState.elementId, + trackId: dragState.trackId, + tracks: sceneTracks, + tracksContainerRef, + tracksScrollRef, + headerRef, + zoomLevel, + snappedTime, + verticalDragDirection, + }); + const nextGroupMoveResult = + moveGroup && anchorDropTarget + ? resolveGroupDragMove({ + group: moveGroup, + snappedTime, + dropTarget: anchorDropTarget, + }) + : null; + groupMoveResultRef.current = nextGroupMoveResult; + setDragDropTarget( + anchorDropTarget && + (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { + ...anchorDropTarget, + isNewTrack: true, + } + : null, + ); + } + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.startMouseY, + dragState.trackId, + zoomLevel, + isElementSelected, + selectElement, + editor.project, + timelineRef, + tracksScrollRef, + tracksContainerRef, + headerRef, + isPendingDrag, + startDrag, + getDragSnapResult, + resolveGroupDragMove, + sceneTracks, + onSnapPointChange, + ]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { + if (!dragState.elementId || !dragState.trackId) return; + + if (mouseDownLocationRef.current) { + const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); + const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); + if ( + deltaX <= TIMELINE_DRAG_THRESHOLD_PX && + deltaY <= TIMELINE_DRAG_THRESHOLD_PX + ) { + mouseDownLocationRef.current = null; + endDrag(); + onSnapPointChange?.(null); + return; + } + } + + const moveGroup = moveGroupRef.current; + if (!moveGroup) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const groupMoveResult = groupMoveResultRef.current; + if (!groupMoveResult) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const didMove = groupMoveResult.moves.some((move) => { + const currentMember = moveGroup.members.find( + (member) => member.elementId === move.elementId, + ); + const originalStartTime = + dragState.startElementTime + (currentMember?.timeOffset ?? 0); + return ( + currentMember?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + if (!didMove && groupMoveResult.createTracks.length === 0) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + editor.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.elementId, + dragState.startElementTime, + dragState.trackId, + endDrag, + onSnapPointChange, + editor.timeline, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag, onSnapPointChange]); + + const handleElementMouseDown = useCallback( + ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }) => { + const isRightClick = event.button === MOUSE_BUTTON_RIGHT; + + // right-click: don't stop propagation so ContextMenu can open + if (isRightClick) { + const alreadySelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + if (!alreadySelected) { + handleSelectionClick({ + trackId: track.id, + elementId: element.id, + isMultiKey: false, + }); + } + return; + } + + event.stopPropagation(); + mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; + + const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; + + if (isMultiSelect) { + handleSelectionClick({ + trackId: track.id, + elementId: element.id, + isMultiKey: true, + }); + } + + const clickOffsetTime = getClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel, + }); + const elementRef = { + trackId: track.id, + elementId: element.id, + }; + const pendingSelectedElements = isElementSelected(elementRef) + ? selectedElements + : [elementRef]; + pendingDragRef.current = { + elementId: element.id, + trackId: track.id, + selectedElements: pendingSelectedElements, + startMouseX: event.clientX, + startMouseY: event.clientY, + startElementTime: element.startTime, + clickOffsetTime, + }; + setIsPendingDrag(true); + }, + [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], + ); + + const handleElementClick = useCallback( + ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }) => { + event.stopPropagation(); + + if (mouseDownLocationRef.current) { + const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); + const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); + if ( + deltaX > TIMELINE_DRAG_THRESHOLD_PX || + deltaY > TIMELINE_DRAG_THRESHOLD_PX + ) { + mouseDownLocationRef.current = null; + return; + } + } + + // modifier keys already handled in mousedown + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + const alreadySelected = isElementSelected({ + trackId: track.id, + elementId: element.id, + }); + if (!alreadySelected || selectedElements.length > 1) { + selectElement({ trackId: track.id, elementId: element.id }); + return; + } + + editor.selection.clearKeyframeSelection(); + }, + [editor.selection, isElementSelected, selectElement, selectedElements], + ); + + return { + dragState, + dragDropTarget, + handleElementMouseDown, + handleElementClick, + lastMouseXRef, + }; +} diff --git a/apps/web/src/timeline/hooks/element/use-element-selection.ts b/apps/web/src/timeline/hooks/element/use-element-selection.ts new file mode 100644 index 00000000..19037e10 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-element-selection.ts @@ -0,0 +1,137 @@ +import { useCallback } from "react";
+import { useEditor } from "@/editor/use-editor";
+import type { ElementRef } from "@/timeline/types";
+
+export function useElementSelection() {
+ const editor = useEditor();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+
+ const isElementSelected = useCallback(
+ ({ trackId, elementId }: ElementRef) =>
+ selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ ),
+ [selectedElements],
+ );
+
+ const selectElement = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ editor.selection.setSelectedElements({
+ elements: [{ trackId, elementId }],
+ });
+ },
+ [editor],
+ );
+
+ const addElementToSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ const alreadySelected = selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ );
+ if (alreadySelected) return;
+
+ editor.selection.setSelectedElements({
+ elements: [...selectedElements, { trackId, elementId }],
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ const removeElementFromSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ editor.selection.setSelectedElements({
+ elements: selectedElements.filter(
+ (element) =>
+ !(element.trackId === trackId && element.elementId === elementId),
+ ),
+ });
+ },
+ [selectedElements, editor],
+ );
+
+ const toggleElementSelection = useCallback(
+ ({ trackId, elementId }: ElementRef) => {
+ const alreadySelected = selectedElements.some(
+ (element) =>
+ element.trackId === trackId && element.elementId === elementId,
+ );
+
+ if (alreadySelected) {
+ removeElementFromSelection({ trackId, elementId });
+ } else {
+ addElementToSelection({ trackId, elementId });
+ }
+ },
+ [selectedElements, addElementToSelection, removeElementFromSelection],
+ );
+
+ const clearElementSelection = useCallback(() => {
+ editor.selection.clearSelection();
+ }, [editor]);
+
+ const setElementSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ editor.selection.setSelectedElements({ elements });
+ },
+ [editor],
+ );
+
+
+ /**
+ * Merges elements into the current selection, deduplicating by identity.
+ * Used for additive box-select where the pre-drag selection is preserved.
+ */
+ const mergeElementsIntoSelection = useCallback(
+ ({ elements }: { elements: ElementRef[] }) => {
+ const merged = [
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !elements.some(
+ (element) =>
+ element.trackId === selectedElement.trackId &&
+ element.elementId === selectedElement.elementId,
+ ),
+ ),
+ ...elements,
+ ];
+ editor.selection.setSelectedElements({ elements: merged });
+ },
+ [selectedElements, editor],
+ );
+
+
+ /**
+ * Handles click interaction on an element.
+ * - Regular click: select only this element
+ * - Multi-key click (Ctrl/Cmd): toggle this element in selection
+ */
+ const handleElementClick = useCallback(
+ ({
+ trackId,
+ elementId,
+ isMultiKey,
+ }: ElementRef & { isMultiKey: boolean }) => {
+ if (isMultiKey) {
+ toggleElementSelection({ trackId, elementId });
+ } else {
+ selectElement({ trackId, elementId });
+ }
+ },
+ [toggleElementSelection, selectElement],
+ );
+
+ return {
+ selectedElements,
+ isElementSelected,
+ selectElement,
+ setElementSelection,
+ mergeElementsIntoSelection,
+ addElementToSelection,
+ removeElementFromSelection,
+ toggleElementSelection,
+ clearElementSelection,
+ handleElementClick,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts new file mode 100644 index 00000000..e471e2d4 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts @@ -0,0 +1,217 @@ +import { useCallback, useRef, useMemo } from "react"; +import { useBoxSelect } from "@/selection/hooks/use-box-select"; +import { + useKeyframeSelection, + getSelectedKeyframeId, +} from "./use-keyframe-selection"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types"; +import type { ExpandedRow } from "@/timeline/components/expanded-layout"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { + KEYFRAME_LANE_HEIGHT_PX, + KEYFRAME_DIAMOND_SIZE_PX, +} from "@/timeline/components/layout"; + +export function useKeyframeBoxSelect({ + trackId, + elementId, + rows, + keyframes, + displayedStartTime, + zoomLevel, + elementLeft, +}: { + trackId: string; + elementId: string; + rows: ExpandedRow[]; + keyframes: ElementKeyframe[]; + displayedStartTime: number; + zoomLevel: number; + elementLeft: number; +}) { + const { + selectedKeyframes, + keyframeSelectionAnchor, + setKeyframeSelection, + clearKeyframeSelection, + } = useKeyframeSelection(); + + const containerRef = useRef<HTMLDivElement>(null); + const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]); + + const keyframeEntries = useMemo(() => { + const entries: Array<{ + id: string; + ref: SelectedKeyframeRef; + rowIndex: number; + offsetPx: number; + }> = []; + + for (const kf of keyframes) { + const rowIndex = rows.findIndex( + (r) => r.propertyPath === kf.propertyPath, + ); + if (rowIndex === -1) continue; + + const ref: SelectedKeyframeRef = { + trackId, + elementId, + propertyPath: kf.propertyPath, + keyframeId: kf.id, + }; + + const kfLeft = timelineTimeToSnappedPixels({ + time: displayedStartTime + kf.time, + zoomLevel, + }); + + entries.push({ + id: getSelectedKeyframeId({ keyframe: ref }), + ref, + rowIndex, + offsetPx: kfLeft - elementLeft, + }); + } + + return entries; + }, [ + keyframes, + rows, + trackId, + elementId, + displayedStartTime, + zoomLevel, + elementLeft, + ]); + + const idToRefMap = useMemo(() => { + const map = new Map<string, SelectedKeyframeRef>(); + for (const entry of keyframeEntries) { + map.set(entry.id, entry.ref); + } + return map; + }, [keyframeEntries]); + + const selectedIds = useMemo( + () => + selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + [selectedKeyframes], + ); + + const anchorId = useMemo( + () => + keyframeSelectionAnchor + ? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor }) + : null, + [keyframeSelectionAnchor], + ); + + const resolveIntersections = useCallback( + ({ + startPos, + currentPos, + }: { + startPos: { x: number; y: number }; + currentPos: { x: number; y: number }; + }) => { + const container = containerRef.current; + if (!container) return []; + + const containerRect = container.getBoundingClientRect(); + + const sx = startPos.x - containerRect.left; + const sy = startPos.y - containerRect.top; + const cx = currentPos.x - containerRect.left; + const cy = currentPos.y - containerRect.top; + + const selLeft = Math.min(sx, cx); + const selTop = Math.min(sy, cy); + const selRight = Math.max(sx, cx); + const selBottom = Math.max(sy, cy); + + const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2; + + return keyframeEntries + .filter((entry) => { + const kfX = entry.offsetPx; + const kfY = + entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX + + KEYFRAME_LANE_HEIGHT_PX / 2; + + return !( + kfX + halfHit < selLeft || + kfX - halfHit > selRight || + kfY + halfHit < selTop || + kfY - halfHit > selBottom + ); + }) + .map((entry) => entry.id); + }, + [keyframeEntries], + ); + + const onSelectionChange = useCallback( + ({ + intersectedIds, + isAdditive, + }: { + intersectedIds: string[]; + initialSelectedIds: string[]; + initialAnchorId: string | null; + isAdditive: boolean; + }) => { + const intersectedRefs = intersectedIds + .map((id) => idToRefMap.get(id)) + .filter((ref): ref is SelectedKeyframeRef => ref != null); + + if (isAdditive) { + setKeyframeSelection({ + keyframes: [...initialKeyframesRef.current, ...intersectedRefs], + }); + } else { + setKeyframeSelection({ keyframes: intersectedRefs }); + } + }, + [idToRefMap, setKeyframeSelection], + ); + + const { + selectionBox, + handleMouseDown: boxSelectMouseDown, + isSelecting, + shouldIgnoreClick, + } = useBoxSelect<string>({ + containerRef, + resolveIntersections, + selectedIds, + anchorId, + onSelectionChange, + }); + + const handleExpandedAreaMouseDown = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + initialKeyframesRef.current = selectedKeyframes; + boxSelectMouseDown(event); + }, + [boxSelectMouseDown, selectedKeyframes], + ); + + const handleExpandedAreaClick = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + if (shouldIgnoreClick()) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + clearKeyframeSelection(); + }, + [shouldIgnoreClick, clearKeyframeSelection], + ); + + return { + containerRef, + selectionBox, + isBoxSelecting: isSelecting, + handleExpandedAreaMouseDown, + handleExpandedAreaClick, + }; +} diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts new file mode 100644 index 00000000..92b6f0d8 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -0,0 +1,328 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useEditor } from "@/editor/use-editor"; +import { getKeyframeById } from "@/animation"; +import { useKeyframeSelection } from "./use-keyframe-selection"; +import { roundToFrame, snappedSeekTime } from "opencut-wasm"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/commands"; +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import type { Command } from "@/commands/base-command"; +import { registerCanceller } from "@/editor/cancel-interaction"; +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set<string>; + deltaTime: number; +} + +const initialDragState: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTime: 0, +}; + +interface PendingKeyframeDrag { + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +export function useKeyframeDrag({ + zoomLevel, + element, + displayedStartTime, +}: { + zoomLevel: number; + element: TimelineElement; + displayedStartTime: number; +}) { + const editor = useEditor(); + const { + selectedKeyframes, + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + } = useKeyframeSelection(); + + const [dragState, setDragState] = + useState<KeyframeDragState>(initialDragState); + const [isPendingDrag, setIsPendingDrag] = useState(false); + + const pendingDragRef = useRef<PendingKeyframeDrag | null>(null); + const mouseDownXRef = useRef<number | null>(null); + + const activeProject = editor.project.getActive(); + const fps = activeProject.settings.fps; + + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + + const endDrag = useCallback(() => { + setDragState(initialDragState); + }, []); + + const cancelDrag = useCallback(() => { + pendingDragRef.current = null; + mouseDownXRef.current = null; + setIsPendingDrag(false); + endDrag(); + }, [endDrag]); + + const commitDrag = useCallback( + ({ + keyframeRefs, + deltaTime, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTime: number; + }) => { + const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + }); + if (!keyframe) return []; + const nextTime = Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTime), + ); + return [ + new RetimeKeyframeCommand({ + trackId: keyframeRef.trackId, + elementId: keyframeRef.elementId, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + nextTime, + }), + ]; + }); + + if (commands.length === 1) { + editor.command.execute({ command: commands[0] }); + } else if (commands.length > 1) { + editor.command.execute({ command: new BatchCommand(commands) }); + } + }, + [editor.command, element], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + return registerCanceller({ fn: cancelDrag }); + }, [dragState.isDragging, isPendingDrag, cancelDrag]); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = ({ clientX }: MouseEvent) => { + if (isPendingDrag && pendingDragRef.current) { + const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); + if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; + + const pending = pendingDragRef.current; + pendingDragRef.current = null; + setIsPendingDrag(false); + setDragState({ + isDragging: true, + draggingKeyframeIds: new Set( + pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), + ), + deltaTime: 0, + }); + return; + } + + if (!dragState.isDragging) return; + + const startX = mouseDownXRef.current ?? clientX; + const rawDelta = Math.round( + ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, + ); + const snappedDelta = + roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; + + setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + const draggingRefs = selectedKeyframes.filter( + (keyframe) => + keyframe.elementId === element.id && + dragState.draggingKeyframeIds.has(keyframe.keyframeId), + ); + + if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { + commitDrag({ + keyframeRefs: draggingRefs, + deltaTime: dragState.deltaTime, + }); + } + + endDrag(); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.draggingKeyframeIds, + dragState.deltaTime, + selectedKeyframes, + element.id, + commitDrag, + endDrag, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag]); + + const handleKeyframeMouseDown = useCallback( + ({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }) => { + event.preventDefault(); + event.stopPropagation(); + + mouseDownXRef.current = event.clientX; + + const anySelected = keyframes.some((keyframe) => + isKeyframeSelected({ keyframe }), + ); + + const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + if (!anySelected && !isModifierKey) { + setKeyframeSelection({ keyframes }); + } + + const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; + + pendingDragRef.current = { + keyframeRefs: keyframeRefsToTrack, + startMouseX: event.clientX, + }; + setIsPendingDrag(true); + }, + [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], + ); + + const handleKeyframeClick = useCallback( + ({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }) => { + event.stopPropagation(); + + const wasDrag = + mouseDownXRef.current !== null && + Math.abs(event.clientX - mouseDownXRef.current) > + TIMELINE_DRAG_THRESHOLD_PX; + mouseDownXRef.current = null; + + if (wasDrag) return; + + const duration = editor.timeline.getTotalDuration(); + const seekTime = + snappedSeekTime({ + time: displayedStartTime + indicatorTime, + duration, + rate: fps, + }) ?? displayedStartTime + indicatorTime; + editor.playback.seek({ time: seekTime }); + + if (event.shiftKey) { + selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + }, + [ + toggleKeyframeSelection, + selectKeyframeRange, + editor, + displayedStartTime, + fps, + ], + ); + + const getVisualOffsetPx = useCallback( + ({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }): number => { + if (!isBeingDragged) return indicatorOffsetPx; + const clampedTime = Math.max( + 0, + Math.min(element.duration, indicatorTime + dragState.deltaTime), + ); + return ( + timelineTimeToSnappedPixels({ + time: displayedStartTime + clampedTime, + zoomLevel, + }) - elementLeft + ); + }, + [dragState.deltaTime, element.duration, zoomLevel], + ); + + return { + keyframeDragState: dragState, + handleKeyframeMouseDown, + handleKeyframeClick, + getVisualOffsetPx, + }; +} diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts new file mode 100644 index 00000000..c5d3ffc9 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts @@ -0,0 +1,229 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/editor/use-editor"; +import type { SelectedKeyframeRef } from "@/animation/types"; + +export function getSelectedKeyframeId({ + keyframe, +}: { + keyframe: SelectedKeyframeRef; +}): string { + return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`; +} + +function mergeUniqueKeyframes({ + keyframes, +}: { + keyframes: SelectedKeyframeRef[]; +}): SelectedKeyframeRef[] { + const keyframesById = new Map<string, SelectedKeyframeRef>(); + for (const keyframe of keyframes) { + keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe); + } + return [...keyframesById.values()]; +} + +export function useKeyframeSelection() { + const editor = useEditor(); + const selectedKeyframes = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedKeyframes(), + ); + const keyframeSelectionAnchor = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getKeyframeSelectionAnchor(), + ); + + const isKeyframeSelected = useCallback( + ({ keyframe }: { keyframe: SelectedKeyframeRef }) => { + const keyframeId = getSelectedKeyframeId({ keyframe }); + return selectedKeyframes.some( + (selectedKeyframe) => + getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId, + ); + }, + [selectedKeyframes], + ); + + const setKeyframeSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const uniqueKeyframes = mergeUniqueKeyframes({ keyframes }); + editor.selection.setSelectedKeyframes({ + keyframes: uniqueKeyframes, + anchorKeyframe: + anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null, + }); + }, + [editor], + ); + + const addKeyframesToSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const mergedKeyframes = mergeUniqueKeyframes({ + keyframes: [...selectedKeyframes, ...keyframes], + }); + editor.selection.setSelectedKeyframes({ + keyframes: mergedKeyframes, + anchorKeyframe: + anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const removeKeyframesFromSelection = useCallback( + ({ + keyframes, + anchorKeyframe, + }: { + keyframes: SelectedKeyframeRef[]; + anchorKeyframe?: SelectedKeyframeRef; + }) => { + const keyframeIdsToRemove = new Set( + keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const nextKeyframes = selectedKeyframes.filter( + (selectedKeyframe) => + !keyframeIdsToRemove.has( + getSelectedKeyframeId({ keyframe: selectedKeyframe }), + ), + ); + editor.selection.setSelectedKeyframes({ + keyframes: nextKeyframes, + anchorKeyframe: + anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null, + }); + }, + [selectedKeyframes, editor], + ); + + const clearKeyframeSelection = useCallback(() => { + editor.selection.clearKeyframeSelection(); + }, [editor]); + + const toggleKeyframeSelection = useCallback( + ({ + keyframes, + isMultiKey, + }: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => { + const anchorKeyframe = keyframes[0]; + const areAllKeyframesSelected = keyframes.every((keyframe) => + isKeyframeSelected({ keyframe }), + ); + if (!isMultiKey) { + setKeyframeSelection({ keyframes, anchorKeyframe }); + return; + } + + if (areAllKeyframesSelected) { + removeKeyframesFromSelection({ keyframes, anchorKeyframe }); + return; + } + + addKeyframesToSelection({ keyframes, anchorKeyframe }); + }, + [ + setKeyframeSelection, + isKeyframeSelected, + removeKeyframesFromSelection, + addKeyframesToSelection, + ], + ); + + const selectKeyframeRange = useCallback( + ({ + orderedKeyframes, + targetKeyframes, + isAdditive, + }: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => { + if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) { + return; + } + + const anchorKeyframe = + keyframeSelectionAnchor ?? + selectedKeyframes[selectedKeyframes.length - 1] ?? + targetKeyframes[0]; + if (!anchorKeyframe) { + return; + } + + const targetKeyframeIds = new Set( + targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), + ); + const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe }); + const anchorIndex = orderedKeyframes.findIndex( + (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId, + ); + if (anchorIndex === -1) { + if (isAdditive) { + addKeyframesToSelection({ + keyframes: targetKeyframes, + anchorKeyframe, + }); + return; + } + setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe }); + return; + } + + const targetIndexes = orderedKeyframes + .map((keyframe, index) => ({ + keyframeId: getSelectedKeyframeId({ keyframe }), + index, + })) + .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId)) + .map(({ index }) => index); + if (targetIndexes.length === 0) { + return; + } + + const rangeStart = Math.min(anchorIndex, ...targetIndexes); + const rangeEnd = Math.max(anchorIndex, ...targetIndexes); + const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1); + + if (isAdditive) { + addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + return; + } + + setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe }); + }, + [ + keyframeSelectionAnchor, + selectedKeyframes, + addKeyframesToSelection, + setKeyframeSelection, + ], + ); + + return { + selectedKeyframes, + keyframeSelectionAnchor, + isKeyframeSelected, + setKeyframeSelection, + addKeyframesToSelection, + removeKeyframesFromSelection, + clearKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + }; +} diff --git a/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts new file mode 100644 index 00000000..509031bb --- /dev/null +++ b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from "react";
+
+interface UseEdgeAutoScrollParams {
+ isActive: boolean;
+ getMouseClientX: () => number;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ contentWidth: number;
+ edgeThreshold?: number;
+ maxScrollSpeed?: number;
+}
+
+export function useEdgeAutoScroll({
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold = 100,
+ maxScrollSpeed = 15,
+}: UseEdgeAutoScrollParams): void {
+ const rafRef = useRef<number | null>(null);
+
+ useEffect(() => {
+ if (!isActive) {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ return;
+ }
+
+ const step = () => {
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) {
+ rafRef.current = requestAnimationFrame(step);
+ return;
+ }
+
+ const viewportRect = rulerViewport.getBoundingClientRect();
+ const mouseX = getMouseClientX();
+ const mouseXRelative = mouseX - viewportRect.left;
+
+ const viewportWidth = rulerViewport.clientWidth;
+ const intrinsicContentWidth = rulerViewport.scrollWidth;
+ const effectiveContentWidth = Math.max(
+ contentWidth,
+ intrinsicContentWidth,
+ );
+ const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
+
+ let scrollSpeed = 0;
+
+ if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
+ const edgeDistance = Math.max(0, mouseXRelative);
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = -maxScrollSpeed * intensity;
+ } else if (
+ mouseXRelative > viewportWidth - edgeThreshold &&
+ rulerViewport.scrollLeft < scrollMax
+ ) {
+ const edgeDistance = Math.max(0, viewportWidth - mouseXRelative);
+ const intensity = 1 - edgeDistance / edgeThreshold;
+ scrollSpeed = maxScrollSpeed * intensity;
+ }
+
+ if (scrollSpeed !== 0) {
+ const newScrollLeft = Math.max(
+ 0,
+ Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed),
+ );
+ rulerViewport.scrollLeft = newScrollLeft;
+ tracksViewport.scrollLeft = newScrollLeft;
+ }
+
+ rafRef.current = requestAnimationFrame(step);
+ };
+
+ rafRef.current = requestAnimationFrame(step);
+
+ return () => {
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ };
+ }, [
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold,
+ maxScrollSpeed,
+ ]);
+}
diff --git a/apps/web/src/timeline/hooks/use-element-preview.ts b/apps/web/src/timeline/hooks/use-element-preview.ts new file mode 100644 index 00000000..67213094 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-element-preview.ts @@ -0,0 +1,39 @@ +import { useEditor } from "@/editor/use-editor";
+import { findTrackInSceneTracks, type TimelineElement } from "@/timeline";
+
+/**
+ * Subscribes to render tracks and returns the live (preview-aware) version of
+ * an element alongside helpers for previewing and committing updates.
+ *
+ * Use this wherever property fields need to reflect in-progress preview state
+ * (e.g. a slider being dragged) rather than the last committed value.
+ */
+export function useElementPreview<T extends TimelineElement>({
+ trackId,
+ elementId,
+ fallback,
+}: {
+ trackId: string;
+ elementId: string;
+ fallback: T;
+}) {
+ const editor = useEditor();
+ useEditor((e) => e.timeline.getPreviewTracks());
+
+ const previewTracks = editor.timeline.getPreviewTracks();
+ const renderElement =
+ (findTrackInSceneTracks({
+ tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
+ trackId,
+ })?.elements.find((element) => element.id === elementId) as T | undefined) ??
+ fallback;
+
+ const previewUpdates = (updates: Partial<TimelineElement>) =>
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates }],
+ });
+
+ const commit = () => editor.timeline.commitPreview();
+
+ return { renderElement, previewUpdates, commit };
+}
diff --git a/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts new file mode 100644 index 00000000..0b1cf92a --- /dev/null +++ b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts @@ -0,0 +1,42 @@ +import { type RefObject, useLayoutEffect, useRef } from "react";
+
+interface UseInitialScrollBottomProps {
+ tracksScrollRef: RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
+ onAfterScroll?: () => void;
+ /** Defers the scroll until there is at least one track to measure against. */
+ isReady: boolean;
+}
+
+/**
+ * Scrolls the timeline tracks viewport to the bottom once on mount.
+ * useLayoutEffect runs synchronously after React commits the DOM but before
+ * the browser paints, so the initial scroll position is never visible.
+ */
+export function useInitialScrollBottom({
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ onAfterScroll,
+ isReady,
+}: UseInitialScrollBottomProps): void {
+ const hasScrolledRef = useRef(false);
+
+ useLayoutEffect(() => {
+ if (!isReady || hasScrolledRef.current) return;
+
+ const viewport = tracksScrollRef.current;
+ if (!viewport) return;
+
+ const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
+ if (maxScrollTop <= 0) return;
+
+ viewport.scrollTop = maxScrollTop;
+
+ if (trackLabelsScrollRef.current) {
+ trackLabelsScrollRef.current.scrollTop = maxScrollTop;
+ }
+
+ onAfterScroll?.();
+ hasScrolledRef.current = true;
+ }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
+}
diff --git a/apps/web/src/timeline/hooks/use-scroll-position.ts b/apps/web/src/timeline/hooks/use-scroll-position.ts new file mode 100644 index 00000000..ad2c6374 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-scroll-position.ts @@ -0,0 +1,52 @@ +import { useEffect, useState, useRef } from "react"; + +interface UseScrollPositionReturn { + scrollLeft: number; + viewportWidth: number; +} + +export function useScrollPosition({ + scrollRef, +}: { + scrollRef: React.RefObject<HTMLElement | null>; +}): UseScrollPositionReturn { + const [scrollLeft, setScrollLeft] = useState(0); + const [viewportWidth, setViewportWidth] = useState(0); + const rafIdRef = useRef<number | null>(null); + + useEffect(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + const updatePosition = () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + + rafIdRef.current = requestAnimationFrame(() => { + setScrollLeft(scrollElement.scrollLeft); + setViewportWidth(scrollElement.clientWidth); + rafIdRef.current = null; + }); + }; + + const resizeObserver = new ResizeObserver(() => { + updatePosition(); + }); + + updatePosition(); + + scrollElement.addEventListener("scroll", updatePosition, { passive: true }); + resizeObserver.observe(scrollElement); + + return () => { + scrollElement.removeEventListener("scroll", updatePosition); + resizeObserver.disconnect(); + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + }; + }, [scrollRef]); + + return { scrollLeft, viewportWidth }; +} diff --git a/apps/web/src/timeline/hooks/use-scroll-sync.ts b/apps/web/src/timeline/hooks/use-scroll-sync.ts new file mode 100644 index 00000000..e147c106 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-scroll-sync.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef } from "react";
+
+interface UseScrollSyncProps {
+ rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
+ bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
+}
+
+export function useScrollSync({
+ tracksScrollRef,
+ rulerScrollRef,
+ trackLabelsScrollRef,
+ bookmarksScrollRef,
+}: UseScrollSyncProps) {
+ const isUpdatingRef = useRef(false);
+ const lastRulerSync = useRef(0);
+ const lastTracksSync = useRef(0);
+ const lastVerticalSync = useRef(0);
+ const lastBookmarksSync = useRef(0);
+
+ useEffect(() => {
+ const rulerViewport = rulerScrollRef?.current ?? null;
+ const tracksViewport = tracksScrollRef.current;
+ const trackLabelsViewport = trackLabelsScrollRef?.current;
+ const bookmarksViewport = bookmarksScrollRef?.current;
+ let handleBookmarksScroll: (() => void) | null = null;
+
+ if (!tracksViewport) return;
+
+ const syncScrollLeft = ({
+ target,
+ scrollLeft,
+ }: {
+ target: HTMLDivElement | null | undefined;
+ scrollLeft: number;
+ }) => {
+ if (target) {
+ target.scrollLeft = scrollLeft;
+ }
+ };
+
+ const handleRulerScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
+ lastRulerSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0;
+ syncScrollLeft({
+ target: bookmarksViewport,
+ scrollLeft: rulerViewport?.scrollLeft ?? 0,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ const handleTracksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
+ lastTracksSync.current = now;
+ isUpdatingRef.current = true;
+ syncScrollLeft({
+ target: rulerViewport,
+ scrollLeft: tracksViewport.scrollLeft,
+ });
+ syncScrollLeft({
+ target: bookmarksViewport,
+ scrollLeft: tracksViewport.scrollLeft,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.addEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.addEventListener("scroll", handleTracksScroll);
+ }
+
+ if (bookmarksViewport) {
+ handleBookmarksScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
+ return;
+ lastBookmarksSync.current = now;
+ isUpdatingRef.current = true;
+ const nextScrollLeft = bookmarksViewport.scrollLeft;
+ tracksViewport.scrollLeft = nextScrollLeft;
+ syncScrollLeft({
+ target: rulerViewport,
+ scrollLeft: nextScrollLeft,
+ });
+ isUpdatingRef.current = false;
+ };
+
+ if (bookmarksViewport !== tracksViewport) {
+ bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
+ }
+ }
+
+ if (trackLabelsViewport) {
+ const handleTrackLabelsScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ const handleTracksVerticalScroll = () => {
+ const now = Date.now();
+ if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
+ return;
+ lastVerticalSync.current = now;
+ isUpdatingRef.current = true;
+ trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
+ isUpdatingRef.current = false;
+ };
+
+ trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
+ tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
+
+ return () => {
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ }
+ if (
+ bookmarksViewport &&
+ bookmarksViewport !== tracksViewport &&
+ handleBookmarksScroll
+ ) {
+ bookmarksViewport.removeEventListener(
+ "scroll",
+ handleBookmarksScroll,
+ );
+ }
+ trackLabelsViewport.removeEventListener(
+ "scroll",
+ handleTrackLabelsScroll,
+ );
+ tracksViewport.removeEventListener(
+ "scroll",
+ handleTracksVerticalScroll,
+ );
+ };
+ }
+
+ return () => {
+ if (rulerViewport && rulerViewport !== tracksViewport) {
+ rulerViewport.removeEventListener("scroll", handleRulerScroll);
+ }
+ if (tracksViewport !== rulerViewport) {
+ tracksViewport.removeEventListener("scroll", handleTracksScroll);
+ }
+ if (
+ bookmarksViewport &&
+ bookmarksViewport !== tracksViewport &&
+ handleBookmarksScroll
+ ) {
+ bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
+ }
+ };
+ }, [
+ rulerScrollRef,
+ tracksScrollRef,
+ trackLabelsScrollRef,
+ bookmarksScrollRef,
+ ]);
+}
diff --git a/apps/web/src/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts new file mode 100644 index 00000000..2081b939 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/layout";
+interface UseSnapIndicatorPositionParams {
+ snapPoint: { time: number } | null;
+ zoomLevel: number;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+}
+
+interface SnapIndicatorPosition {
+ leftPosition: number;
+ topPosition: number;
+ height: number;
+}
+
+export function useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ timelineRef,
+ tracksScrollRef,
+}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
+ const [scrollLeft, setScrollLeft] = useState(0);
+
+ useEffect(() => {
+ const tracksViewport = tracksScrollRef.current;
+
+ if (!tracksViewport) return;
+
+ const handleScroll = () => {
+ setScrollLeft(tracksViewport.scrollLeft);
+ };
+
+ setScrollLeft(tracksViewport.scrollLeft);
+
+ tracksViewport.addEventListener("scroll", handleScroll);
+ return () => tracksViewport.removeEventListener("scroll", handleScroll);
+ }, [tracksScrollRef]);
+
+ const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
+ const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
+
+ const timelinePosition = timelineTimeToSnappedPixels({
+ time: snapPoint?.time ?? 0,
+ zoomLevel,
+ });
+ const leftPosition =
+ TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
+
+ return {
+ leftPosition,
+ topPosition: 0,
+ height: totalHeight,
+ };
+}
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts new file mode 100644 index 00000000..775b7337 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -0,0 +1,648 @@ +import { useState, useCallback, type RefObject } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { processMediaAssets } from "@/media/processing"; +import { toast } from "sonner"; +import { showMediaUploadToast } from "@/media/upload-toast"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { roundToFrame } from "opencut-wasm"; +import { + buildTextElement, + buildGraphicElement, + buildStickerElement, + buildElementFromMedia, + buildEffectElement, +} from "@/timeline/element-utils"; +import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; +import { BatchCommand } from "@/commands"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getDragData, hasDragData } from "@/timeline/drag-data"; +import type { TrackType, DropTarget, ElementType } from "@/timeline"; +import type { + MediaDragData, + GraphicDragData, + StickerDragData, + EffectDragData, +} from "@/timeline/drag"; + +interface UseTimelineDragDropProps { + containerRef: RefObject<HTMLDivElement | null>; + headerRef?: RefObject<HTMLElement | null>; + tracksScrollRef?: RefObject<HTMLDivElement | null>; + zoomLevel: number; +} + +export function useTimelineDragDrop({ + containerRef, + headerRef, + tracksScrollRef, + zoomLevel, +}: UseTimelineDragDropProps) { + const editor = useEditor(); + const [isDragOver, setIsDragOver] = useState(false); + const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); + const [dragElementType, setElementType] = useState<ElementType | null>(null); + + const getSnappedTime = useCallback( + ({ time }: { time: number }) => { + const projectFps = editor.project.getActive().settings.fps; + return roundToFrame({ time, rate: projectFps }) ?? time; + }, + [editor], + ); + + const getElementType = useCallback( + ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { + const dragData = getDragData({ dataTransfer }); + if (!dragData) return null; + + if (dragData.type === "text") return "text"; + if (dragData.type === "graphic") return "graphic"; + if (dragData.type === "sticker") return "sticker"; + if (dragData.type === "effect") return "effect"; + if (dragData.type === "media") { + return dragData.mediaType; + } + return null; + }, + [], + ); + + const getElementDuration = useCallback( + ({ + elementType, + mediaId, + }: { + elementType: ElementType; + mediaId?: string; + }): number => { + if ( + elementType === "text" || + elementType === "graphic" || + elementType === "sticker" || + elementType === "effect" + ) { + return DEFAULT_NEW_ELEMENT_DURATION; + } + if (mediaId) { + const mediaAssets = editor.media.getAssets(); + const media = mediaAssets.find((m) => m.id === mediaId); + return media?.duration != null + ? Math.round(media.duration * TICKS_PER_SECOND) + : DEFAULT_NEW_ELEMENT_DURATION; + } + return DEFAULT_NEW_ELEMENT_DURATION; + }, + [editor], + ); + + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); + const hasFiles = e.dataTransfer.types.includes("Files"); + if (!hasAsset && !hasFiles) return; + setIsDragOver(true); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + + const scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; + + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const hasFiles = e.dataTransfer.types.includes("Files"); + const isExternal = + hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); + + const elementType = getElementType({ dataTransfer: e.dataTransfer }); + + if (!elementType && hasFiles && isExternal) { + setDropTarget(null); + setElementType(null); + return; + } + + if (!elementType) return; + + setElementType(elementType); + + const dragData = getDragData({ dataTransfer: e.dataTransfer }); + const duration = getElementDuration({ + elementType, + mediaId: dragData?.type === "media" ? dragData.id : undefined, + }); + + const mouseX = e.clientX - referenceRect.left + scrollLeft; + const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; + + const targetElementTypes = + dragData?.type === "effect" + ? (dragData as EffectDragData).targetElementTypes + : dragData?.type === "media" + ? (dragData as MediaDragData).targetElementTypes + : undefined; + + const sceneTracks = editor.scenes.getActiveScene().tracks; + const currentTime = editor.playback.getCurrentTime(); + const target = computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks: sceneTracks, + playheadTime: currentTime, + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + targetElementTypes, + }); + + target.xPosition = getSnappedTime({ time: target.xPosition }); + + setDropTarget(target); + e.dataTransfer.dropEffect = "copy"; + }, + [ + containerRef, + headerRef, + tracksScrollRef, + zoomLevel, + getElementType, + getElementDuration, + getSnappedTime, + editor, + ], + ); + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + const { clientX, clientY } = e; + if ( + clientX < rect.left || + clientX > rect.right || + clientY < rect.top || + clientY > rect.bottom + ) { + setIsDragOver(false); + setDropTarget(null); + setElementType(null); + } + } + }, + [containerRef], + ); + + const executeTextDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: { name?: string; content?: string }; + }) => { + const element = buildTextElement({ + raw: { + name: dragData.name ?? "", + content: dragData.content ?? "", + }, + startTime: target.xPosition, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("text", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeStickerDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: StickerDragData; + }) => { + const element = buildStickerElement({ + stickerId: dragData.stickerId, + name: dragData.name, + startTime: target.xPosition, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeGraphicDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: GraphicDragData; + }) => { + const element = buildGraphicElement({ + definitionId: dragData.definitionId, + name: dragData.name, + startTime: target.xPosition, + params: dragData.params, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeMediaDrop = useCallback( + ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { + if (target.targetElement) { + toast.info("Replace media source is coming soon!"); + return; + } + + const mediaAssets = editor.media.getAssets(); + const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + + const duration = + mediaAsset.duration != null + ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) + : DEFAULT_NEW_ELEMENT_DURATION; + const element = buildElementFromMedia({ + mediaId: mediaAsset.id, + mediaType: mediaAsset.type, + name: mediaAsset.name, + duration, + startTime: target.xPosition, + }); + + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } + + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; + const track = tracks[target.trackIndex]; + if (!track) return; + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + }, + [editor], + ); + + const executeEffectDrop = useCallback( + ({ + target, + dragData, + }: { + target: DropTarget; + dragData: EffectDragData; + }) => { + if (target.targetElement) { + editor.timeline.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; + const effectTrack = tracks.find((t) => t.type === "effect"); + let trackId: string; + + if (effectTrack) { + trackId = effectTrack.id; + } else if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); + const insertCmd = new InsertElementCommand({ + element: buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }), + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }); + editor.command.execute({ + command: new BatchCommand([addTrackCmd, insertCmd]), + }); + return; + } else { + const track = tracks[target.trackIndex]; + if (!track || track.type !== "effect") return; + trackId = track.id; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + }, + [editor], + ); + + const executeFileDrop = useCallback( + async ({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }) => { + const activeProject = editor.project.getActiveOrNull(); + if (!activeProject) return; + + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const projectId = activeProject.metadata.id; + + for (const asset of processedAssets) { + const createdAsset = await editor.media.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; + + const duration = + createdAsset.duration != null + ? Math.round(createdAsset.duration * TICKS_PER_SECOND) + : DEFAULT_NEW_ELEMENT_DURATION; + const sceneTracks = editor.scenes.getActiveScene().tracks; + const currentTime = editor.playback.getCurrentTime(); + const reuseMainTrackId = + createdAsset.type !== "audio" && + sceneTracks.overlay.length === 0 && + sceneTracks.audio.length === 0 && + sceneTracks.main.elements.length === 0 + ? sceneTracks.main.id + : null; + const dropTarget = reuseMainTrackId + ? null + : computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: sceneTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + }); + + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; + + const startTime = dropTarget?.xPosition ?? currentTime; + const element = buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime, + }); + + if (reuseMainTrackId) { + editor.command.execute({ + command: new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: reuseMainTrackId }, + }), + }); + } else { + if (!dropTarget) continue; + if (dropTarget.isNewTrack) { + const addTrackCmd = new AddTrackCommand( + trackType, + dropTarget.trackIndex, + ); + editor.command.execute({ + command: new BatchCommand([ + addTrackCmd, + new InsertElementCommand({ + element, + placement: { + mode: "explicit", + trackId: addTrackCmd.getTrackId(), + }, + }), + ]), + }); + } else { + const trackId = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ][dropTarget.trackIndex]?.id; + if (!trackId) continue; + editor.command.execute({ + command: new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId }, + }), + }); + } + } + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + }, + [editor, zoomLevel], + ); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + + const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); + const hasFiles = e.dataTransfer.files?.length > 0; + + if (!hasAsset && !hasFiles) return; + + const currentTarget = dropTarget; + setIsDragOver(false); + setDropTarget(null); + setElementType(null); + + try { + if (hasAsset) { + if (!currentTarget) return; + const dragData = getDragData({ dataTransfer: e.dataTransfer }); + if (!dragData) return; + + if (dragData.type === "text") { + executeTextDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "graphic") { + executeGraphicDrop({ + target: currentTarget, + dragData: dragData as GraphicDragData, + }); + } else if (dragData.type === "sticker") { + executeStickerDrop({ target: currentTarget, dragData }); + } else if (dragData.type === "effect") { + executeEffectDrop({ + target: currentTarget, + dragData: dragData as EffectDragData, + }); + } else { + executeMediaDrop({ target: currentTarget, dragData }); + } + } else if (hasFiles) { + const scrollContainer = tracksScrollRef?.current; + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + containerRef.current?.getBoundingClientRect(); + if (!referenceRect) return; + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const mouseX = e.clientX - referenceRect.left + scrollLeft; + const headerHeight = + headerRef?.current?.getBoundingClientRect().height ?? 0; + const mouseY = + e.clientY - referenceRect.top + scrollTop - headerHeight; + await executeFileDrop({ + files: Array.from(e.dataTransfer.files), + mouseX, + mouseY, + }); + } + } catch (err) { + console.error("Failed to process drop:", err); + } + }, + [ + dropTarget, + executeTextDrop, + executeGraphicDrop, + executeStickerDrop, + executeMediaDrop, + executeEffectDrop, + executeFileDrop, + containerRef, + headerRef, + tracksScrollRef, + ], + ); + + return { + isDragOver, + dropTarget, + dragElementType, + dragProps: { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + }; +} diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts new file mode 100644 index 00000000..7d9cf447 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -0,0 +1,301 @@ +import { snappedSeekTime } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useCallback, useRef } from "react"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, +} from "@/timeline/snapping"; +import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + getCenteredLineLeft, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; + +interface UseTimelinePlayheadProps { + zoomLevel: number; + rulerRef: React.RefObject<HTMLDivElement | null>; + rulerScrollRef: React.RefObject<HTMLDivElement | null>; + tracksScrollRef: React.RefObject<HTMLDivElement | null>; + playheadRef?: React.RefObject<HTMLDivElement | null>; +} + +export function useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: UseTimelinePlayheadProps) { + const editor = useEditor(); + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + const isShiftHeldRef = useShiftKey(); + + const zoomLevelRef = useRef(zoomLevel); + const durationRef = useRef(duration); + const isScrubbingRef = useRef(isScrubbing); + const isPlayingRef = useRef(false); + + useEffect(() => { + zoomLevelRef.current = zoomLevel; + durationRef.current = duration; + isScrubbingRef.current = isScrubbing; + isPlayingRef.current = editor.playback.getIsPlaying(); + }, [zoomLevel, duration, isScrubbing, editor.playback]); + + const seek = useCallback( + ({ time }: { time: number }) => editor.playback.seek({ time }), + [editor.playback], + ); + + const scrubTimeRef = useRef<number | null>(null); + const isDraggingRulerRef = useRef(false); + const hasDraggedRulerRef = useRef(false); + const lastMouseXRef = useRef<number>(0); + + const handleScrub = useCallback( + ({ + event, + snappingEnabled = true, + }: { + event: MouseEvent | React.MouseEvent; + snappingEnabled?: boolean; + }) => { + const ruler = rulerRef.current; + if (!ruler) return; + const rulerRect = ruler.getBoundingClientRect(); + const relativeMouseX = event.clientX - rulerRect.left; + + const timelineContentWidth = timelineTimeToPixels({ + time: duration, + zoomLevel, + }); + + const clampedMouseX = Math.max( + 0, + Math.min(timelineContentWidth, relativeMouseX), + ); + + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + + const rate = activeProject.settings.fps; + const frameTime = + snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; + + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + const time = (() => { + if (!shouldSnap) return frameTime; + const tracks = editor.scenes.getActiveScene().tracks; + const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks }), + () => getBookmarkSnapPoints({ bookmarks }), + () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), + ], + }); + const snapResult = resolveTimelineSnap({ + targetTime: frameTime, + snapPoints, + maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), + }); + return snapResult.snapPoint ? snapResult.snappedTime : frameTime; + })(); + + scrubTimeRef.current = time; + seek({ time }); + + lastMouseXRef.current = event.clientX; + }, + [ + duration, + zoomLevel, + seek, + rulerRef, + activeProject.settings.fps, + isShiftHeldRef, + editor.scenes, + ], + ); + + const handlePlayheadMouseDown = useCallback( + ({ event }: { event: React.MouseEvent }) => { + event.preventDefault(); + event.stopPropagation(); + editor.playback.setScrubbing({ isScrubbing: true }); + handleScrub({ event }); + }, + [handleScrub, editor.playback], + ); + + const handleRulerMouseDown = useCallback( + ({ event }: { event: React.MouseEvent }) => { + if (event.button !== 0) return; + if (playheadRef?.current?.contains(event.target as Node)) return; + + event.preventDefault(); + isDraggingRulerRef.current = true; + hasDraggedRulerRef.current = false; + + editor.playback.setScrubbing({ isScrubbing: true }); + handleScrub({ event, snappingEnabled: false }); + }, + [handleScrub, playheadRef, editor.playback], + ); + + const handlePlayheadMouseDownEvent = useCallback( + (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), + [handlePlayheadMouseDown], + ); + + const handleRulerMouseDownEvent = useCallback( + (event: React.MouseEvent) => handleRulerMouseDown({ event }), + [handleRulerMouseDown], + ); + + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => lastMouseXRef.current, + rulerScrollRef, + tracksScrollRef, + contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), + }); + + useEffect(() => { + if (!isScrubbing) return; + + const handleMouseMove = ({ event }: { event: MouseEvent }) => { + handleScrub({ event }); + if (isDraggingRulerRef.current) { + hasDraggedRulerRef.current = true; + } + }; + + const handleMouseUp = ({ event }: { event: MouseEvent }) => { + editor.playback.setScrubbing({ isScrubbing: false }); + const finalTime = scrubTimeRef.current; + if (finalTime !== null) { + seek({ time: finalTime }); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, + playheadTime: finalTime, + }, + }); + } + scrubTimeRef.current = null; + + if (isDraggingRulerRef.current) { + isDraggingRulerRef.current = false; + if (!hasDraggedRulerRef.current) { + handleScrub({ event, snappingEnabled: false }); + } + hasDraggedRulerRef.current = false; + } + }; + + const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); + const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); + + const updatePlayheadLeft = useCallback( + (time: number) => { + const playheadEl = playheadRef?.current; + if (!playheadEl) return; + const centerPosition = timelineTimeToSnappedPixels({ + time, + zoomLevel: zoomLevelRef.current, + }); + const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); + const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; + playheadEl.style.left = `${leftPosition - scrollLeft}px`; + }, + [playheadRef, rulerScrollRef], + ); + + useEffect(() => { + const scrollEl = rulerScrollRef.current; + if (!scrollEl) return; + + const handleScroll = () => { + updatePlayheadLeft(editor.playback.getCurrentTime()); + }; + + scrollEl.addEventListener("scroll", handleScroll, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handleScroll); + }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); + + useEffect(() => { + const handlePlaybackUpdate = (e: Event) => { + const time = (e as CustomEvent<{ time: number }>).detail.time; + updatePlayheadLeft(time); + + if (!isPlayingRef.current || isScrubbingRef.current) return; + const rulerViewport = rulerScrollRef.current; + const tracksViewport = tracksScrollRef.current; + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = timelineTimeToPixels({ + time, + zoomLevel: zoomLevelRef.current, + }); + const viewportWidth = rulerViewport.clientWidth; + const scrollMinimum = 0; + const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; + + const needsScroll = + playheadPixels < rulerViewport.scrollLeft || + playheadPixels > rulerViewport.scrollLeft + viewportWidth; + + if (needsScroll) { + const desiredScroll = Math.max( + scrollMinimum, + Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), + ); + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }; + + const initialTime = editor.playback.getCurrentTime(); + handlePlaybackUpdate({ + detail: { time: initialTime }, + } as CustomEvent<{ time: number }>); + + window.addEventListener("playback-update", handlePlaybackUpdate); + window.addEventListener("playback-seek", handlePlaybackUpdate); + return () => { + window.removeEventListener("playback-update", handlePlaybackUpdate); + window.removeEventListener("playback-seek", handlePlaybackUpdate); + }; + }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); + + return { + handlePlayheadMouseDown: handlePlayheadMouseDownEvent, + handleRulerMouseDown: handleRulerMouseDownEvent, + }; +} diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts new file mode 100644 index 00000000..fb8da291 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -0,0 +1,332 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EditorCore } from "@/core"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { + computeGroupResize, + type GroupResizeMember, + type GroupResizeResult, + type ResizeSide, +} from "@/timeline/group-resize"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; +import { isRetimableElement } from "@/timeline"; +import { registerCanceller } from "@/editor/cancel-interaction"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; + +interface ResizeInteractionState { + side: ResizeSide; + startX: number; + members: GroupResizeMember[]; +} + +export function useTimelineResize({ + zoomLevel, + onSnapPointChange, +}: { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); + const { selectedElements } = useElementSelection(); + const [resizeState, setResizeState] = useState<ResizeInteractionState | null>( + null, + ); + const latestResultRef = useRef<GroupResizeResult | null>(null); + + const cancelResize = useCallback(() => { + editor.timeline.discardPreview(); + setResizeState(null); + latestResultRef.current = null; + onSnapPointChange?.(null); + }, [editor.timeline, onSnapPointChange]); + + useEffect(() => { + if (!resizeState) { + return; + } + + return registerCanceller({ fn: cancelResize }); + }, [resizeState, cancelResize]); + + const handleResizeStart = useCallback( + ({ + event, + element, + track, + side, + }: { + event: React.MouseEvent; + element: TimelineElement; + track: TimelineTrack; + side: ResizeSide; + }) => { + event.stopPropagation(); + event.preventDefault(); + + const elementRef = { + trackId: track.id, + elementId: element.id, + }; + const activeSelection = selectedElements.some( + (selectedElement) => + selectedElement.trackId === track.id && + selectedElement.elementId === element.id, + ) + ? selectedElements + : [elementRef]; + const members = buildResizeMembers({ + tracks: editor.scenes.getActiveScene().tracks, + selectedElements: activeSelection, + }); + if (members.length === 0) { + return; + } + + editor.timeline.discardPreview(); + latestResultRef.current = null; + setResizeState({ + side, + startX: event.clientX, + members, + }); + }, + [selectedElements, editor], + ); + + useEffect(() => { + if (!resizeState) { + return; + } + + const handleMouseMove = ({ clientX }: MouseEvent) => { + const deltaX = clientX - resizeState.startX; + const rawDeltaTime = Math.round( + (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * + TICKS_PER_SECOND, + ); + const snappedDeltaTime = getSnappedResizeDelta({ + editor, + resizeState, + rawDeltaTime, + zoomLevel, + shouldSnap: snappingEnabled && !isShiftHeldRef.current, + onSnapPointChange, + }); + const fps = editor.project.getActive().settings.fps; + const result = computeGroupResize({ + members: resizeState.members, + side: resizeState.side, + deltaTime: snappedDeltaTime.deltaTime, + fps, + }); + + latestResultRef.current = result; + editor.timeline.previewElements({ + updates: result.updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + updates: patch, + })), + }); + }; + + const handleMouseUp = () => { + const result = latestResultRef.current; + editor.timeline.discardPreview(); + if ( + result && + hasResizeChanges({ members: resizeState.members, result }) + ) { + editor.timeline.updateElements({ + updates: result.updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch, + })), + }); + } + + setResizeState(null); + latestResultRef.current = null; + onSnapPointChange?.(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + resizeState, + zoomLevel, + snappingEnabled, + isShiftHeldRef, + editor, + onSnapPointChange, + ]); + + return { + isResizing: resizeState !== null, + handleResizeStart, + }; +} + +function buildResizeMembers({ + tracks, + selectedElements, +}: { + tracks: SceneTracks; + selectedElements: Array<{ trackId: string; elementId: string }>; +}): GroupResizeMember[] { + const selectedElementIds = new Set( + selectedElements.map((selectedElement) => selectedElement.elementId), + ); + const trackMap = new Map( + [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ + track.id, + track, + ]), + ); + + return selectedElements.flatMap(({ trackId, elementId }) => { + const track = trackMap.get(trackId); + const element = track?.elements.find( + (trackElement) => trackElement.id === elementId, + ); + if (!track || !element) { + return []; + } + + const otherElements = track.elements.filter( + (trackElement) => !selectedElementIds.has(trackElement.id), + ); + const leftNeighborBound = otherElements + .filter( + (trackElement) => + trackElement.startTime + trackElement.duration <= element.startTime, + ) + .reduce((bound, trackElement) => { + return Math.max(bound, trackElement.startTime + trackElement.duration); + }, -Infinity); + const rightNeighborBound = otherElements + .filter( + (trackElement) => + trackElement.startTime >= element.startTime + element.duration, + ) + .reduce((bound, trackElement) => { + return Math.min(bound, trackElement.startTime); + }, Infinity); + + return [ + { + trackId, + elementId, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + sourceDuration: element.sourceDuration, + retime: isRetimableElement(element) ? element.retime : undefined, + leftNeighborBound, + rightNeighborBound, + }, + ]; + }); +} + +function getSnappedResizeDelta({ + editor, + resizeState, + rawDeltaTime, + zoomLevel, + shouldSnap, + onSnapPointChange, +}: { + editor: EditorCore; + resizeState: ResizeInteractionState; + rawDeltaTime: number; + zoomLevel: number; + shouldSnap: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}): { deltaTime: number } { + if (!shouldSnap) { + onSnapPointChange?.(null); + return { deltaTime: rawDeltaTime }; + } + + const tracks = editor.scenes.getActiveScene().tracks; + const playheadTime = editor.playback.getCurrentTime(); + const excludeElementIds = new Set( + resizeState.members.map((member) => member.elementId), + ); + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), + () => getPlayheadSnapPoints({ playheadTime }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, + }), + ], + }); + const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); + let closestSnapPoint: SnapPoint | null = null; + let closestSnapDistance = Infinity; + let deltaTime = rawDeltaTime; + + for (const member of resizeState.members) { + const baseEdgeTime = + resizeState.side === "left" + ? member.startTime + : member.startTime + member.duration; + const snapResult = resolveTimelineSnap({ + targetTime: baseEdgeTime + rawDeltaTime, + snapPoints, + maxSnapDistance, + }); + if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { + closestSnapDistance = snapResult.snapDistance; + closestSnapPoint = snapResult.snapPoint; + deltaTime = snapResult.snappedTime - baseEdgeTime; + } + } + + onSnapPointChange?.(closestSnapPoint); + return { deltaTime }; +} + +function hasResizeChanges({ + members, + result, +}: { + members: GroupResizeMember[]; + result: GroupResizeResult; +}): boolean { + return result.updates.some((update) => { + const member = members.find( + (candidateMember) => candidateMember.elementId === update.elementId, + ); + return ( + member?.trimStart !== update.patch.trimStart || + member?.trimEnd !== update.patch.trimEnd || + member?.startTime !== update.patch.startTime || + member?.duration !== update.patch.duration + ); + }); +} diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts new file mode 100644 index 00000000..889177c9 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -0,0 +1,197 @@ +import { useCallback, useRef } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { snappedSeekTime } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; + +interface UseTimelineSeekProps { + playheadRef: RefObject<HTMLDivElement | null>; + trackLabelsRef: RefObject<HTMLDivElement | null>; + rulerScrollRef: RefObject<HTMLDivElement | null>; + tracksScrollRef: RefObject<HTMLDivElement | null>; + zoomLevel: number; + duration: number; + isSelecting: boolean; + clearSelectedElements: () => void; + seek: (time: number) => void; +} + +function resetMouseTracking({ + mouseTrackingRef, +}: { + mouseTrackingRef: MutableRefObject<{ + isMouseDown: boolean; + downX: number; + downY: number; + downTime: number; + }>; +}) { + mouseTrackingRef.current = { + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }; +} + +function setMouseTracking({ + mouseTrackingRef, + event, +}: { + mouseTrackingRef: MutableRefObject<{ + isMouseDown: boolean; + downX: number; + downY: number; + downTime: number; + }>; + event: React.MouseEvent; +}) { + mouseTrackingRef.current = { + isMouseDown: true, + downX: event.clientX, + downY: event.clientY, + downTime: event.timeStamp, + }; +} + +export function useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration, + isSelecting, + clearSelectedElements, + seek, +}: UseTimelineSeekProps) { + const editor = useEditor(); + const activeProject = editor.project.getActive(); + + const mouseTrackingRef = useRef({ + isMouseDown: false, + downX: 0, + downY: 0, + downTime: 0, + }); + + const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { + if (event.button !== 0) return; + setMouseTracking({ mouseTrackingRef, event }); + }, []); + + const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { + if (event.button !== 0) return; + setMouseTracking({ mouseTrackingRef, event }); + }, []); + + const shouldProcessTimelineClick = useCallback( + ({ event }: { event: React.MouseEvent }) => { + const target = event.target as HTMLElement; + const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; + const deltaX = Math.abs(event.clientX - downX); + const deltaY = Math.abs(event.clientY - downY); + const deltaTime = event.timeStamp - downTime; + const isPlayhead = !!playheadRef.current?.contains(target); + const isTrackLabels = !!trackLabelsRef.current?.contains(target); + const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; + + if (!isMouseDown) return false; + if (shouldBlockForDrag) return false; + if (isSelecting) return false; + if (isPlayhead) return false; + if (isTrackLabels) { + clearSelectedElements(); + return false; + } + + return true; + }, + [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], + ); + + const handleTimelineSeek = useCallback( + ({ + event, + source, + }: { + event: React.MouseEvent; + source: "ruler" | "tracks"; + }) => { + const scrollContainer = + source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; + + if (!scrollContainer) return; + + const rect = scrollContainer.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const scrollLeft = scrollContainer.scrollLeft; + + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + + const rate = activeProject?.settings.fps; + const time = rate + ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) + : rawTime; + seek(time); + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }, + }); + }, + [ + duration, + zoomLevel, + rulerScrollRef, + tracksScrollRef, + seek, + editor, + activeProject?.settings.fps.numerator, + activeProject?.settings.fps.denominator, + ], + ); + + const handleTracksClick = useCallback( + (event: React.MouseEvent) => { + const shouldProcess = shouldProcessTimelineClick({ event }); + resetMouseTracking({ mouseTrackingRef }); + + if (shouldProcess) { + clearSelectedElements(); + handleTimelineSeek({ event, source: "tracks" }); + } + }, + [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], + ); + + const handleRulerClick = useCallback( + (event: React.MouseEvent) => { + const shouldProcess = shouldProcessTimelineClick({ event }); + resetMouseTracking({ mouseTrackingRef }); + + if (shouldProcess) { + clearSelectedElements(); + handleTimelineSeek({ event, source: "ruler" }); + } + }, + [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], + ); + + return { + handleTracksMouseDown, + handleTracksClick, + handleRulerMouseDown, + handleRulerClick, + }; +} diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts new file mode 100644 index 00000000..7929d495 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -0,0 +1,291 @@ +import { + type WheelEvent as ReactWheelEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; +import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { useEditor } from "@/editor/use-editor"; +import { zoomToSlider } from "@/timeline/zoom-utils"; + +interface UseTimelineZoomProps { + containerRef: RefObject<HTMLDivElement | null>; + minZoom?: number; + initialZoom?: number; + initialScrollLeft?: number; + initialPlayheadTime?: number; + tracksScrollRef: RefObject<HTMLDivElement | null>; + rulerScrollRef: RefObject<HTMLDivElement | null>; +} + +interface UseTimelineZoomReturn { + zoomLevel: number; + setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; + handleWheel: (event: ReactWheelEvent) => void; + saveScrollPosition: () => void; +} + +export function useTimelineZoom({ + containerRef, + minZoom = TIMELINE_ZOOM_MIN, + initialZoom, + initialScrollLeft, + initialPlayheadTime, + tracksScrollRef, + rulerScrollRef, +}: UseTimelineZoomProps): UseTimelineZoomReturn { + const editor = useEditor(); + const hasInitializedRef = useRef(false); + const hasRestoredPlayheadRef = useRef(false); + const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( + null, + ); + + const [zoomLevel, setZoomLevelRaw] = useState(() => { + if (initialZoom !== undefined) { + hasInitializedRef.current = true; + return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)); + } + return minZoom; + }); + const previousZoomRef = useRef(zoomLevel); + const hasRestoredScrollRef = useRef(false); + const preZoomScrollLeftRef = useRef(0); + const prePlayheadAnchorScrollLeftRef = useRef(0); + const isInPlayheadAnchorModeRef = useRef(false); + + const setZoomLevel = useCallback( + (updater: number | ((prev: number) => number)) => { + const scrollElement = tracksScrollRef.current; + if (scrollElement) { + preZoomScrollLeftRef.current = scrollElement.scrollLeft; + } + setZoomLevelRaw(updater); + }, + [tracksScrollRef], + ); + + const handleWheel = useCallback( + (event: ReactWheelEvent) => { + const isZoomGesture = event.ctrlKey || event.metaKey; + const isHorizontalScrollGesture = + event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (isHorizontalScrollGesture) { + return; + } + + // pinch-zoom (ctrl/meta + wheel) + if (isZoomGesture) { + const normalizedDelta = + event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const cappedDelta = + Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + setZoomLevel((prev) => { + const nextZoom = Math.max( + minZoom, + Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), + ); + return nextZoom; + }); + return; + } + }, + [minZoom, setZoomLevel], + ); + + useEffect(() => { + if (initialZoom !== undefined && !hasInitializedRef.current) { + hasInitializedRef.current = true; + setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); + return; + } + setZoomLevel((prev) => { + if (prev < minZoom) { + return minZoom; + } + return prev; + }); + }, [minZoom, initialZoom, setZoomLevel]); + + const wrappedSetZoomLevel = useCallback( + (zoomLevelOrUpdater: number | ((prev: number) => number)) => { + setZoomLevel((prev) => { + const nextZoom = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(prev) + : zoomLevelOrUpdater; + const clampedZoom = Math.max( + minZoom, + Math.min(TIMELINE_ZOOM_MAX, nextZoom), + ); + return clampedZoom; + }); + }, + [minZoom, setZoomLevel], + ); + + useLayoutEffect(() => { + const previousZoom = previousZoomRef.current; + if (previousZoom === zoomLevel) return; + + const scrollElement = tracksScrollRef.current; + if (!scrollElement) { + previousZoomRef.current = zoomLevel; + return; + } + + const currentScrollLeft = preZoomScrollLeftRef.current; + const playheadTime = editor.playback.getCurrentTime(); + const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); + const previousSliderPercent = zoomToSlider({ + zoomLevel: previousZoom, + minZoom, + }); + const isCrossingThresholdUp = + previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + const isCrossingThresholdDown = + previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + + const syncScroll = (scrollLeft: number) => { + scrollElement.scrollLeft = scrollLeft; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = scrollLeft; + } + }; + + const clampScrollLeft = (scrollLeft: number) => { + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + }; + + if (isCrossingThresholdUp) { + prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; + isInPlayheadAnchorModeRef.current = true; + } + + if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { + const playheadPixelsBefore = timelineTimeToPixels({ + time: playheadTime, + zoomLevel: previousZoom, + }); + const playheadPixelsAfter = timelineTimeToPixels({ + time: playheadTime, + zoomLevel, + }); + + const viewportOffset = playheadPixelsBefore - currentScrollLeft; + const newScrollLeft = playheadPixelsAfter - viewportOffset; + + syncScroll(clampScrollLeft(newScrollLeft)); + } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { + syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); + isInPlayheadAnchorModeRef.current = false; + } + + previousZoomRef.current = zoomLevel; + + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime, + }, + }); + }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref + const saveScrollPosition = useCallback(() => { + if (scrollSaveTimeoutRef.current) { + clearTimeout(scrollSaveTimeoutRef.current); + } + scrollSaveTimeoutRef.current = setTimeout(() => { + const scrollElement = tracksScrollRef.current; + if (scrollElement) { + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime: editor.playback.getCurrentTime(), + }, + }); + } + }, 300); + }, [zoomLevel, editor]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable + useEffect(() => { + if (initialScrollLeft === undefined) return; + if (hasRestoredScrollRef.current) return; + const scrollElement = tracksScrollRef.current; + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + if (rulerScrollRef.current) { + rulerScrollRef.current.scrollLeft = initialScrollLeft; + } + hasRestoredScrollRef.current = true; + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + } else { + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + }, [initialScrollLeft]); + + useEffect(() => { + if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { + hasRestoredPlayheadRef.current = true; + editor.playback.seek({ time: initialPlayheadTime }); + } + }, [initialPlayheadTime, editor]); + + // prevent browser zoom in the timeline + useEffect(() => { + const preventZoom = (event: WheelEvent) => { + const isZoomKeyPressed = event.ctrlKey || event.metaKey; + const isInContainer = containerRef.current?.contains( + event.target as Node, + ); + // only check isInContainer, not isInTimeline state - the state check + // causes race conditions where the closure captures stale state + if (isZoomKeyPressed && isInContainer) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + }, [containerRef]); + + return { + zoomLevel, + setZoomLevel: wrappedSetZoomLevel, + handleWheel, + saveScrollPosition, + }; +} diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts new file mode 100644 index 00000000..5ee079b1 --- /dev/null +++ b/apps/web/src/timeline/index.ts @@ -0,0 +1,29 @@ +import type { SceneTracks } from "./types";
+
+export * from "./types";
+export * from "./drag";
+export * from "./track-capabilities";
+export * from "./track-element-update";
+export * from "./element-utils";
+export * from "./audio-separation";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+export * from "./pixel-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: SceneTracks;
+}): number {
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+ if (orderedTracks.length === 0) return 0;
+
+ const trackEndTimes = orderedTracks.map((track) =>
+ track.elements.reduce((maxEnd, element) => {
+ const elementEnd = element.startTime + element.duration;
+ return Math.max(maxEnd, elementEnd);
+ }, 0),
+ );
+
+ return Math.max(...trackEndTimes, 0);
+}
diff --git a/apps/web/src/timeline/pixel-utils.ts b/apps/web/src/timeline/pixel-utils.ts new file mode 100644 index 00000000..71e8466a --- /dev/null +++ b/apps/web/src/timeline/pixel-utils.ts @@ -0,0 +1,80 @@ +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; + +export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; + +function getDevicePixelRatio({ + devicePixelRatio, +}: { + devicePixelRatio?: number; +}): number { + if ( + typeof devicePixelRatio === "number" && + Number.isFinite(devicePixelRatio) && + devicePixelRatio > 0 + ) { + return devicePixelRatio; + } + + if (typeof window === "undefined") { + return 1; + } + + if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) { + return window.devicePixelRatio; + } + + return 1; +} + +export function getTimelinePixelsPerSecond({ + zoomLevel, +}: { + zoomLevel: number; +}): number { + return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; +} + +export function timelineTimeToPixels({ + time, + zoomLevel, +}: { + time: number; + zoomLevel: number; +}): number { + return (time / TICKS_PER_SECOND) * getTimelinePixelsPerSecond({ zoomLevel }); +} + +export function snapPixelToDeviceGrid({ + pixel, + devicePixelRatio, +}: { + pixel: number; + devicePixelRatio?: number; +}): number { + const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio }); + return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio; +} + +export function timelineTimeToSnappedPixels({ + time, + zoomLevel, + devicePixelRatio, +}: { + time: number; + zoomLevel: number; + devicePixelRatio?: number; +}): number { + const rawPixel = timelineTimeToPixels({ time, zoomLevel }); + return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio }); +} + +export function getCenteredLineLeft({ + centerPixel, + lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX, +}: { + centerPixel: number; + lineWidthPx?: number; +}): number { + return centerPixel - lineWidthPx / 2; +} diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts new file mode 100644 index 00000000..ebbfe578 --- /dev/null +++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts @@ -0,0 +1,661 @@ +import { describe, expect, test } from "bun:test"; +import type { + AudioElement, + AudioTrack, + GraphicElement, + GraphicTrack, + OverlayTrack, + SceneTracks, + TextElement, + TextTrack, + TimelineTrack, + VideoElement, + VideoTrack, +} from "@/timeline"; +import type { Transform } from "@/rendering"; +import { resolveTrackPlacement } from "@/timeline/placement"; + +function buildTransform(): Transform { + return { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, + }; +} + +type TestElement = AudioElement | GraphicElement | TextElement | VideoElement; + +function buildElement(params: { + id: string; + type: "audio"; + startTime: number; + duration: number; +}): AudioElement; +function buildElement(params: { + id: string; + type: "graphic"; + startTime: number; + duration: number; +}): GraphicElement; +function buildElement(params: { + id: string; + type: "text"; + startTime: number; + duration: number; +}): TextElement; +function buildElement(params: { + id: string; + type: "video"; + startTime: number; + duration: number; +}): VideoElement; +function buildElement({ + id, + type, + startTime, + duration, +}: { + id: string; + type: TestElement["type"]; + startTime: number; + duration: number; +}): TestElement { + switch (type) { + case "audio": + return { + id, + type: "audio", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + volume: 1, + sourceType: "upload", + mediaId: `media-${id}`, + } satisfies AudioElement; + case "graphic": + return { + id, + type: "graphic", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + definitionId: `graphic-${id}`, + params: {}, + transform: buildTransform(), + opacity: 1, + } satisfies GraphicElement; + case "text": + return { + id, + type: "text", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + content: id, + fontSize: 32, + fontFamily: "sans-serif", + color: "#ffffff", + background: { + enabled: false, + color: "#000000", + }, + textAlign: "left", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + transform: buildTransform(), + opacity: 1, + } satisfies TextElement; + case "video": + return { + id, + type: "video", + name: id, + startTime, + duration, + trimStart: 0, + trimEnd: 0, + mediaId: `media-${id}`, + transform: buildTransform(), + opacity: 1, + } satisfies VideoElement; + } + + throw new Error(`Unsupported test element type: ${type}`); +} + +type BuildTrackParams = + | { + id: string; + type: "audio"; + elements?: AudioTrack["elements"]; + } + | { + id: string; + type: "graphic"; + elements?: GraphicTrack["elements"]; + } + | { + id: string; + type: "text"; + elements?: TextTrack["elements"]; + } + | { + id: string; + type: "video"; + elements?: VideoTrack["elements"]; + }; + +function buildTrack(params: { + id: string; + type: "audio"; + elements?: AudioTrack["elements"]; +}): AudioTrack; +function buildTrack(params: { + id: string; + type: "graphic"; + elements?: GraphicTrack["elements"]; +}): GraphicTrack; +function buildTrack(params: { + id: string; + type: "text"; + elements?: TextTrack["elements"]; +}): TextTrack; +function buildTrack(params: { + id: string; + type: "video"; + elements?: VideoTrack["elements"]; +}): VideoTrack; +function buildTrack(params: BuildTrackParams): TimelineTrack { + const { id, type } = params; + + switch (type) { + case "audio": + return { + id, + type: "audio", + name: id, + elements: params.elements ?? [], + muted: false, + }; + case "graphic": + return { + id, + type: "graphic", + name: id, + elements: params.elements ?? [], + hidden: false, + }; + case "text": + return { + id, + type: "text", + name: id, + elements: params.elements ?? [], + hidden: false, + }; + case "video": + return { + id, + type: "video", + name: id, + elements: params.elements ?? [], + muted: false, + hidden: false, + }; + } + + throw new Error(`Unsupported test track type: ${type}`); +} + +function buildTimeSpan({ + startTime, + duration, + excludeElementId, +}: { + startTime: number; + duration: number; + excludeElementId?: string; +}) { + return { startTime, duration, excludeElementId }; +} + +function buildSceneTracks({ + overlay = [], + main, + audio = [], +}: { + overlay?: Array<OverlayTrack>; + main?: VideoTrack; + audio?: Array<AudioTrack>; +}): SceneTracks { + return { + overlay, + main: + main ?? + buildTrack({ + id: "video-main", + type: "video", + }), + audio, + }; +} + +describe("resolveTrackPlacement", () => { + test("explicit returns the requested compatible track", () => { + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })], + strategy: { type: "explicit", trackId: "text-1" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-1", + trackIndex: 0, + trackType: "text", + }); + }); + + test("explicit rejects missing and incompatible tracks", () => { + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-1", type: "video" }), + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { type: "explicit", trackId: "missing" }, + }), + ).toBeNull(); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { type: "explicit", trackId: "video-1" }, + }), + ).toBeNull(); + }); + + test("firstAvailable picks the first compatible track without overlap", () => { + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-1", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-2", type: "text" }), + ], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-2", + trackIndex: 1, + trackType: "text", + }); + }); + + test("firstAvailable creates a new track when all compatible tracks are full", () => { + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "graphic-1", + type: "graphic", + elements: [ + buildElement({ + id: "a", + type: "graphic", + startTime: 0, + duration: 5, + }), + ], + }), + ], + main: buildTrack({ + id: "video-main", + type: "video", + }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "graphic", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "graphic", + insertIndex: 0, + insertPosition: null, + }); + }); + + test("preferIndex uses the preferred track when it fits", () => { + const tracks = buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: "below", + }); + }); + + test("preferIndex creates a new overlay track above the main track", () => { + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "graphic", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })], + strategy: { + type: "preferIndex", + trackIndex: 1, + hoverDirection: "below", + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "graphic", + insertIndex: 0, + insertPosition: "above", + }); + }); + + test("preferIndex keeps audio tracks below the main track", () => { + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "above", + createNewTrackOnly: true, + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: "below", + }); + }); + + test("aboveSource tries the track above source, then any compatible track", () => { + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ id: "text-top", type: "text" }), + buildTrack({ + id: "text-middle", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-source", type: "text" }), + ], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "aboveSource", sourceTrackIndex: 2 }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "text-top", + trackIndex: 0, + trackType: "text", + }); + }); + + test("aboveSource creates a new overlay track in the overlay zone when none fit", () => { + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-top", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ + id: "text-source", + type: "text", + elements: [ + buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }), + ], + }), + ], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "text", + timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })], + strategy: { type: "aboveSource", sourceTrackIndex: 1 }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "text", + insertIndex: 0, + insertPosition: null, + }); + }); + + test("alwaysNew honors highest and default insertion rules", () => { + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "default" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: null, + }); + }); + + test("batch time spans reject tracks when any span overlaps", () => { + const tracks = buildSceneTracks({ + audio: [ + buildTrack({ + id: "audio-1", + type: "audio", + elements: [ + buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), + buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), + ], + }), + ], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [ + buildTimeSpan({ startTime: 2.5, duration: 1 }), + buildTimeSpan({ startTime: 5.5, duration: 1 }), + ], + strategy: { type: "firstAvailable" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 1, + insertPosition: null, + }); + }); + + test("handles main-only timelines, single tracks, and track-type derivation", () => { + expect( + resolveTrackPlacement({ + tracks: buildSceneTracks({}), + elementType: "video", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "below", + createNewTrackOnly: true, + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "video", + insertIndex: 0, + insertPosition: "above", + }); + + expect( + resolveTrackPlacement({ + tracks: buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }), + elementType: "audio", + timeSpans: [], + strategy: { type: "alwaysNew", position: "default" }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: null, + }); + }); + + test("existingTrack on main video includes adjustedStartTime when start snaps", () => { + const tracks = buildSceneTracks({ + main: buildTrack({ + id: "video-main", + type: "video", + elements: [ + buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }), + ], + }), + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "video", + timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })], + strategy: { type: "explicit", trackId: "video-main" }, + }), + ).toEqual({ + kind: "existingTrack", + trackId: "video-main", + trackIndex: 0, + trackType: "video", + adjustedStartTime: 0, + }); + }); + + test("preferIndex uses vertical drag direction when hovered track is incompatible", () => { + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); + + expect( + resolveTrackPlacement({ + tracks, + elementType: "audio", + timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })], + strategy: { + type: "preferIndex", + trackIndex: 0, + hoverDirection: "above", + verticalDragDirection: "down", + }, + }), + ).toEqual({ + kind: "newTrack", + trackType: "audio", + insertIndex: 2, + insertPosition: "below", + }); + }); +}); diff --git a/apps/web/src/timeline/placement/apply.ts b/apps/web/src/timeline/placement/apply.ts new file mode 100644 index 00000000..214b0611 --- /dev/null +++ b/apps/web/src/timeline/placement/apply.ts @@ -0,0 +1,158 @@ +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + OverlayTrack, + SceneTracks, + TextTrack, + TimelineElement, + TimelineTrack, + VideoTrack, +} from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { buildEmptyTrack } from "./track-factory"; +import type { PlacementResult } from "./types"; +import { updateTrackInSceneTracks } from "@/timeline/track-element-update"; + +export function applyPlacement({ + tracks, + placementResult, + elements, + newTrackInsertIndexOverride, +}: { + tracks: SceneTracks; + placementResult: PlacementResult; + elements: TimelineElement[]; + newTrackInsertIndexOverride?: number; +}): { updatedTracks: SceneTracks; targetTrackId: string } | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (placementResult.kind === "existingTrack") { + const targetTrack = orderedTracks[placementResult.trackIndex]; + if (!targetTrack) { + return null; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks, + trackId: targetTrack.id, + update: (track) => ({ + ...track, + elements: [...track.elements, ...elements], + }), + }); + + return { updatedTracks, targetTrackId: targetTrack.id }; + } + + const newTrackId = generateUUID(); + const insertIndex = + newTrackInsertIndexOverride ?? placementResult.insertIndex; + const updatedTracks = + placementResult.trackType === "audio" + ? { + ...tracks, + audio: insertIntoAudioTracks({ + tracks, + insertIndex, + track: buildPlacedAudioTrack({ + id: newTrackId, + elements, + }), + }), + } + : { + ...tracks, + overlay: insertIntoOverlayTracks({ + tracks, + insertIndex, + track: buildPlacedOverlayTrack({ + id: newTrackId, + type: placementResult.trackType, + elements, + }), + }), + }; + return { updatedTracks, targetTrackId: newTrackId }; +} + +function insertIntoOverlayTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: OverlayTrack; +}): OverlayTrack[] { + const normalizedInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + const nextTracks = [...tracks.overlay]; + nextTracks.splice(normalizedInsertIndex, 0, track); + return nextTracks; +} + +function insertIntoAudioTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: AudioTrack; +}): AudioTrack[] { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + const nextTracks = [...tracks.audio]; + nextTracks.splice(audioInsertIndex, 0, track); + return nextTracks; +} + +function buildPlacedAudioTrack({ + id, + elements, +}: { + id: string; + elements: TimelineElement[]; +}): AudioTrack { + return { + ...buildEmptyTrack({ id, type: "audio" }), + elements: elements as AudioTrack["elements"], + }; +} + +function buildPlacedOverlayTrack({ + id, + type, + elements, +}: { + id: string; + type: Exclude<OverlayTrack["type"], "audio">; + elements: TimelineElement[]; +}): OverlayTrack { + switch (type) { + case "video": + return { + ...buildEmptyTrack({ id, type: "video" }), + elements: elements as VideoTrack["elements"], + }; + case "text": + return { + ...buildEmptyTrack({ id, type: "text" }), + elements: elements as TextTrack["elements"], + }; + case "graphic": + return { + ...buildEmptyTrack({ id, type: "graphic" }), + elements: elements as GraphicTrack["elements"], + }; + case "effect": + return { + ...buildEmptyTrack({ id, type: "effect" }), + elements: elements as EffectTrack["elements"], + }; + } +} diff --git a/apps/web/src/timeline/placement/compatibility.ts b/apps/web/src/timeline/placement/compatibility.ts new file mode 100644 index 00000000..b1af547c --- /dev/null +++ b/apps/web/src/timeline/placement/compatibility.ts @@ -0,0 +1,51 @@ +import type { ElementType, TrackType } from "@/timeline"; + +const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = { + audio: "audio", + text: "text", + sticker: "graphic", + graphic: "graphic", + effect: "effect", + video: "video", + image: "video", +}; + +export function getTrackTypeForElementType({ + elementType, +}: { + elementType: ElementType; +}): TrackType { + return ELEMENT_TRACK_MAP[elementType]; +} + +export function canElementGoOnTrack({ + elementType, + trackType, +}: { + elementType: ElementType; + trackType: TrackType; +}): boolean { + return getTrackTypeForElementType({ elementType }) === trackType; +} + +export function validateElementTrackCompatibility({ + element, + track, +}: { + element: { type: ElementType }; + track: { type: TrackType }; +}): { isValid: boolean; errorMessage?: string } { + const isValid = canElementGoOnTrack({ + elementType: element.type, + trackType: track.type, + }); + + if (!isValid) { + return { + isValid: false, + errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`, + }; + } + + return { isValid: true }; +} diff --git a/apps/web/src/timeline/placement/index.ts b/apps/web/src/timeline/placement/index.ts new file mode 100644 index 00000000..530cb69f --- /dev/null +++ b/apps/web/src/timeline/placement/index.ts @@ -0,0 +1,12 @@ +export { applyPlacement } from "./apply"; +export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; +export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; +export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track"; +export { resolveTrackPlacement } from "./resolve"; +export { buildEmptyTrack } from "./track-factory"; +export type { + PlacementResult, + PlacementStrategy, + PlacementSubject, + PlacementTimeSpan, +} from "./types"; diff --git a/apps/web/src/timeline/placement/insert-index.ts b/apps/web/src/timeline/placement/insert-index.ts new file mode 100644 index 00000000..5e2786dc --- /dev/null +++ b/apps/web/src/timeline/placement/insert-index.ts @@ -0,0 +1,88 @@ +import type { SceneTracks, TrackType } from "@/timeline"; + +export function getDefaultInsertIndexForTrack({ + tracks, + trackType, +}: { + tracks: SceneTracks; + trackType: TrackType; +}): number { + if (trackType === "audio") { + return tracks.overlay.length + 1 + tracks.audio.length; + } + + if (trackType === "effect") { + return 0; + } + + return tracks.overlay.length; +} + +export function getHighestInsertIndexForTrack({ + tracks, + trackType, +}: { + tracks: SceneTracks; + trackType: TrackType; +}): number { + if (trackType === "audio") { + return tracks.overlay.length + 1; + } + + return 0; +} + +export function resolvePreferredNewTrackPlacement({ + tracks, + trackType, + preferredIndex, + direction, +}: { + tracks: SceneTracks; + trackType: TrackType; + preferredIndex: number; + direction: "above" | "below"; +}): { insertIndex: number; insertPosition: "above" | "below" | null } { + const trackCount = tracks.overlay.length + 1 + tracks.audio.length; + if (trackCount === 0) { + return { + insertIndex: 0, + insertPosition: trackType === "audio" ? "below" : null, + }; + } + + const safePreferredIndex = Math.min( + Math.max(preferredIndex, 0), + trackCount - 1, + ); + const mainTrackIndex = tracks.overlay.length; + + if (trackType === "audio") { + if (safePreferredIndex <= mainTrackIndex) { + return { + insertIndex: mainTrackIndex + 1, + insertPosition: "below", + }; + } + + return { + insertIndex: + direction === "above" ? safePreferredIndex : safePreferredIndex + 1, + insertPosition: direction, + }; + } + + const insertIndex = + direction === "above" ? safePreferredIndex : safePreferredIndex + 1; + if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) { + return { + insertIndex: mainTrackIndex, + insertPosition: "above", + }; + } + + return { + insertIndex, + insertPosition: direction, + }; +} diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts new file mode 100644 index 00000000..ab2ab7bd --- /dev/null +++ b/apps/web/src/timeline/placement/main-track.ts @@ -0,0 +1,54 @@ +import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline"; + +export const MAIN_TRACK_NAME = "Main Track"; + +export function getEarliestMainTrackElement({ + mainTrack, + excludeElementId, +}: { + mainTrack: VideoTrack; + excludeElementId?: string; +}): TimelineElement | null { + const elements = mainTrack.elements.filter((element) => { + return !excludeElementId || element.id !== excludeElementId; + }); + if (elements.length === 0) { + return null; + } + + return elements.reduce((earliestElement, element) => { + return element.startTime < earliestElement.startTime + ? element + : earliestElement; + }); +} + +export function enforceMainTrackStart({ + tracks, + targetTrackId, + requestedStartTime, + excludeElementId, +}: { + tracks: SceneTracks; + targetTrackId: string; + requestedStartTime: number; + excludeElementId?: string; +}): number { + if (tracks.main.id !== targetTrackId) { + return requestedStartTime; + } + + const earliestElement = getEarliestMainTrackElement({ + mainTrack: tracks.main, + excludeElementId, + }); + if (!earliestElement) { + return 0; + } + + if (requestedStartTime <= earliestElement.startTime) { + return 0; + } + + return requestedStartTime; +} diff --git a/apps/web/src/timeline/placement/overlap.ts b/apps/web/src/timeline/placement/overlap.ts new file mode 100644 index 00000000..daf3e627 --- /dev/null +++ b/apps/web/src/timeline/placement/overlap.ts @@ -0,0 +1,44 @@ +import type { TimelineElement } from "@/timeline"; +import type { PlacementTimeSpan } from "./types"; + +interface TrackWithElements { + elements: TimelineElement[]; +} + +function wouldElementOverlap({ + elements, + startTime, + endTime, + excludeElementId, +}: { + elements: TimelineElement[]; + startTime: number; + endTime: number; + excludeElementId?: string; +}): boolean { + return elements.some((element) => { + if (excludeElementId && element.id === excludeElementId) { + return false; + } + + const elementEnd = element.startTime + element.duration; + return startTime < elementEnd && endTime > element.startTime; + }); +} + +export function canPlaceTimeSpansOnTrack({ + track, + timeSpans, +}: { + track: TrackWithElements; + timeSpans: PlacementTimeSpan[]; +}): boolean { + return timeSpans.every(({ startTime, duration, excludeElementId }) => { + return !wouldElementOverlap({ + elements: track.elements, + startTime, + endTime: startTime + duration, + excludeElementId, + }); + }); +} diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts new file mode 100644 index 00000000..26275392 --- /dev/null +++ b/apps/web/src/timeline/placement/resolve.ts @@ -0,0 +1,277 @@ +import type { SceneTracks, TrackType, TimelineTrack } from "@/timeline"; +import { + getDefaultInsertIndexForTrack, + getHighestInsertIndexForTrack, + resolvePreferredNewTrackPlacement, +} from "./insert-index"; +import { getTrackTypeForElementType } from "./compatibility"; +import { enforceMainTrackStart } from "./main-track"; +import { canPlaceTimeSpansOnTrack } from "./overlap"; +import type { + PlacementResult, + PlacementStrategy, + PlacementSubject, + PlacementTimeSpan, +} from "./types"; + +type ResolveTrackPlacementParams = PlacementSubject & { + tracks: SceneTracks; + timeSpans: PlacementTimeSpan[]; + strategy: PlacementStrategy; +}; + +function buildExistingTrackResult({ + track, + trackIndex, + tracks, + timeSpans, +}: { + track: TimelineTrack; + trackIndex: number; + tracks: SceneTracks; + timeSpans: PlacementTimeSpan[]; +}): PlacementResult { + const firstSpan = timeSpans[0]; + const requestedStartTime = firstSpan?.startTime ?? 0; + const adjustedStartTime = enforceMainTrackStart({ + tracks, + targetTrackId: track.id, + requestedStartTime, + excludeElementId: firstSpan?.excludeElementId, + }); + return { + kind: "existingTrack", + trackId: track.id, + trackIndex, + trackType: track.type, + ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}), + }; +} + +function buildNewTrackResult({ + trackType, + insertIndex, + insertPosition, +}: { + trackType: TrackType; + insertIndex: number; + insertPosition: "above" | "below" | null; +}): PlacementResult { + return { + kind: "newTrack", + trackType, + insertIndex, + insertPosition, + }; +} + +function findFirstAvailableTrackIndex({ + tracks, + trackType, + timeSpans, +}: { + tracks: TimelineTrack[]; + trackType: TrackType; + timeSpans: PlacementTimeSpan[]; +}): number { + return tracks.findIndex((track) => { + return ( + track.type === trackType && + canPlaceTimeSpansOnTrack({ + track, + timeSpans, + }) + ); + }); +} + +function resolveAlwaysNewTrack({ + tracks, + trackType, + position, +}: { + tracks: SceneTracks; + trackType: TrackType; + position: "highest" | "default"; +}): PlacementResult { + const insertIndex = + position === "highest" + ? getHighestInsertIndexForTrack({ + tracks, + trackType, + }) + : getDefaultInsertIndexForTrack({ + tracks, + trackType, + }); + + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition: null, + }); +} + +function getInsertDirection({ + hoverDirection, + verticalDragDirection, +}: { + hoverDirection: "above" | "below"; + verticalDragDirection?: "up" | "down" | null; +}): "above" | "below" { + if (verticalDragDirection === "up") { + return "above"; + } + + if (verticalDragDirection === "down") { + return "below"; + } + + return hoverDirection; +} + +export function resolveTrackPlacement({ + tracks, + ...placement +}: ResolveTrackPlacementParams): PlacementResult | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + const trackType = + "trackType" in placement + ? placement.trackType + : getTrackTypeForElementType({ + elementType: placement.elementType, + }); + const { timeSpans, strategy } = placement; + + if (strategy.type === "explicit") { + const trackIndex = orderedTracks.findIndex( + (track) => track.id === strategy.trackId, + ); + if (trackIndex < 0) { + return null; + } + + const track = orderedTracks[trackIndex]; + if (track.type !== trackType) { + return null; + } + + return buildExistingTrackResult({ + track, + trackIndex, + tracks, + timeSpans, + }); + } + + if (strategy.type === "firstAvailable") { + const existingTrackIndex = findFirstAvailableTrackIndex({ + tracks: orderedTracks, + trackType, + timeSpans, + }); + if (existingTrackIndex >= 0) { + return buildExistingTrackResult({ + track: orderedTracks[existingTrackIndex], + trackIndex: existingTrackIndex, + tracks, + timeSpans, + }); + } + + return resolveAlwaysNewTrack({ + tracks, + trackType, + position: "highest", + }); + } + + if (strategy.type === "preferIndex") { + const preferredTrack = orderedTracks[strategy.trackIndex]; + const isPreferredTrackCompatible = + !!preferredTrack && preferredTrack.type === trackType; + const canUseExistingTrack = + !strategy.createNewTrackOnly && + isPreferredTrackCompatible && + canPlaceTimeSpansOnTrack({ + track: preferredTrack, + timeSpans, + }); + if (canUseExistingTrack) { + return buildExistingTrackResult({ + track: preferredTrack, + trackIndex: strategy.trackIndex, + tracks, + timeSpans, + }); + } + + const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({ + tracks, + trackType, + preferredIndex: strategy.trackIndex, + direction: getInsertDirection({ + hoverDirection: strategy.hoverDirection, + verticalDragDirection: !isPreferredTrackCompatible + ? strategy.verticalDragDirection + : null, + }), + }); + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition, + }); + } + + if (strategy.type === "aboveSource") { + const aboveTrackIndex = strategy.sourceTrackIndex - 1; + const aboveTrack = orderedTracks[aboveTrackIndex]; + if ( + aboveTrack && + aboveTrack.type === trackType && + canPlaceTimeSpansOnTrack({ + track: aboveTrack, + timeSpans, + }) + ) { + return buildExistingTrackResult({ + track: aboveTrack, + trackIndex: aboveTrackIndex, + tracks, + timeSpans, + }); + } + + const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ + tracks: orderedTracks, + trackType, + timeSpans, + }); + if (firstAvailableTrackIndex >= 0) { + return buildExistingTrackResult({ + track: orderedTracks[firstAvailableTrackIndex], + trackIndex: firstAvailableTrackIndex, + tracks, + timeSpans, + }); + } + + const insertIndex = getHighestInsertIndexForTrack({ + tracks, + trackType, + }); + + return buildNewTrackResult({ + trackType, + insertIndex, + insertPosition: null, + }); + } + + return resolveAlwaysNewTrack({ + tracks, + trackType, + position: strategy.position, + }); +} diff --git a/apps/web/src/timeline/placement/track-factory.ts b/apps/web/src/timeline/placement/track-factory.ts new file mode 100644 index 00000000..9c69ea8c --- /dev/null +++ b/apps/web/src/timeline/placement/track-factory.ts @@ -0,0 +1,123 @@ +import { DEFAULT_TRACK_NAMES } from "@/timeline/tracks"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + TextTrack, + TrackType, + TimelineTrack, + VideoTrack, +} from "@/timeline"; + +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "video"; + name?: string; +}): VideoTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "text"; + name?: string; +}): TextTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "audio"; + name?: string; +}): AudioTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "graphic"; + name?: string; +}): GraphicTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "effect"; + name?: string; +}): EffectTrack; + +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: TrackType; + name?: string; +}): TimelineTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: TrackType; + name?: string; +}): TimelineTrack { + const trackName = name ?? DEFAULT_TRACK_NAMES[type]; + + switch (type) { + case "video": + return { + id, + name: trackName, + type: "video", + elements: [], + hidden: false, + muted: false, + }; + case "text": + return { + id, + name: trackName, + type: "text", + elements: [], + hidden: false, + }; + case "graphic": + return { + id, + name: trackName, + type: "graphic", + elements: [], + hidden: false, + }; + case "audio": + return { + id, + name: trackName, + type: "audio", + elements: [], + muted: false, + }; + case "effect": + return { + id, + name: trackName, + type: "effect", + elements: [], + hidden: false, + }; + default: + throw new Error(`Unsupported track type: ${type}`); + } +} diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts new file mode 100644 index 00000000..6847ab36 --- /dev/null +++ b/apps/web/src/timeline/placement/types.ts @@ -0,0 +1,39 @@ +import type { ElementType, TrackType } from "@/timeline"; + +export interface PlacementTimeSpan { + startTime: number; + duration: number; + excludeElementId?: string; +} + +export type PlacementSubject = + | { elementType: ElementType } + | { trackType: TrackType }; + +export type PlacementStrategy = + | { type: "explicit"; trackId: string } + | { type: "firstAvailable" } + | { + type: "preferIndex"; + trackIndex: number; + hoverDirection: "above" | "below"; + verticalDragDirection?: "up" | "down" | null; + createNewTrackOnly?: boolean; + } + | { type: "aboveSource"; sourceTrackIndex: number } + | { type: "alwaysNew"; position: "highest" | "default" }; + +export type PlacementResult = + | { + kind: "existingTrack"; + trackId: string; + trackIndex: number; + trackType: TrackType; + adjustedStartTime?: number; + } + | { + kind: "newTrack"; + insertIndex: number; + insertPosition: "above" | "below" | null; + trackType: TrackType; + }; diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts new file mode 100644 index 00000000..88e21eba --- /dev/null +++ b/apps/web/src/timeline/playhead-snap-source.ts @@ -0,0 +1,9 @@ +import type { SnapPoint } from "@/timeline/snapping"; + +export function getPlayheadSnapPoints({ + playheadTime, +}: { + playheadTime: number; +}): SnapPoint[] { + return [{ time: playheadTime, type: "playhead" }]; +} diff --git a/apps/web/src/timeline/ruler-utils.ts b/apps/web/src/timeline/ruler-utils.ts new file mode 100644 index 00000000..d355ef31 --- /dev/null +++ b/apps/web/src/timeline/ruler-utils.ts @@ -0,0 +1,257 @@ +import type { FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { frameRateToFloat } from "@/fps/utils"; + +/** + * frame intervals for labels - starts at 2 so there's always at least + * one tick between labels even at max zoom. + * pattern: 2, 3, 5, 10, 15 (matches CapCut) + */ +const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const; + +/** + * frame intervals for ticks - can go down to 1 for max granularity. + */ +const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const; + +/** + * second intervals for when we're zoomed out past frame-level detail. + */ +const SECOND_MULTIPLIERS = [ + 1, 2, 3, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, +] as const; + +/** + * minimum pixel spacing between labels to keep them readable + */ +const MIN_LABEL_SPACING_PX = 120; + +/** + * minimum pixel spacing between ticks. much denser than labels. + */ +const MIN_TICK_SPACING_PX = 18; + +export interface RulerConfig { + /** time interval in seconds between each label */ + labelIntervalSeconds: number; + /** time interval in seconds between each tick */ + tickIntervalSeconds: number; +} + +/** + * determines the optimal label and tick intervals based on zoom level and FPS. + * + * labels and ticks scale independently: + * - labels need wide spacing (~50px) to stay readable + * - ticks can be denser (~8px) to show finer subdivisions + * + * example at different zoom levels: + * - very zoomed in: labels every 2f, ticks every 1f + * - zoomed in: labels every 10f, ticks every 1f + * - zoomed out: labels every 15f, ticks every 3f + * - very zoomed out: labels every 1s, ticks every 5f + */ +export function getRulerConfig({ + zoomLevel, + fps, +}: { + zoomLevel: number; + fps: FrameRate; +}): RulerConfig { + const fpsFloat = frameRateToFloat(fps); + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + const pixelsPerFrame = pixelsPerSecond / fpsFloat; + + const labelIntervalSeconds = findOptimalInterval({ + pixelsPerFrame, + pixelsPerSecond, + fps: fpsFloat, + minSpacingPx: MIN_LABEL_SPACING_PX, + frameIntervals: LABEL_FRAME_INTERVALS, + }); + + const rawTickIntervalSeconds = findOptimalInterval({ + pixelsPerFrame, + pixelsPerSecond, + fps: fpsFloat, + minSpacingPx: MIN_TICK_SPACING_PX, + frameIntervals: TICK_FRAME_INTERVALS, + }); + + // ensure tick interval divides evenly into label interval so labels always land on ticks + const tickIntervalSeconds = ensureTickDividesLabel({ + tickIntervalSeconds: rawTickIntervalSeconds, + labelIntervalSeconds, + pixelsPerFrame, + pixelsPerSecond, + fps: fpsFloat, + }); + + return { labelIntervalSeconds, tickIntervalSeconds }; +} + +/** + * adjusts tick interval to ensure it divides evenly into the label interval. + * this guarantees labels always land on tick positions. + */ +function ensureTickDividesLabel({ + tickIntervalSeconds, + labelIntervalSeconds, + pixelsPerFrame, + pixelsPerSecond, + fps, +}: { + tickIntervalSeconds: number; + labelIntervalSeconds: number; + pixelsPerFrame: number; + pixelsPerSecond: number; + fps: number; +}): number { + const labelFrames = Math.round(labelIntervalSeconds * fps); + const tickFrames = Math.round(tickIntervalSeconds * fps); + + // if tick already divides label evenly, we're good + if (labelFrames % tickFrames === 0) { + return tickIntervalSeconds; + } + + // find the smallest tick interval that divides the label interval and has adequate spacing + for (const candidateFrames of TICK_FRAME_INTERVALS) { + if (labelFrames % candidateFrames === 0) { + const candidateSpacing = pixelsPerFrame * candidateFrames; + // accept if spacing meets minimum threshold + if (candidateSpacing >= MIN_TICK_SPACING_PX) { + return candidateFrames / fps; + } + } + } + + // try second-level tick intervals that divide the label interval cleanly + for (const candidateSeconds of SECOND_MULTIPLIERS) { + const ratio = labelIntervalSeconds / candidateSeconds; + const isDivisor = Math.abs(ratio - Math.round(ratio)) < 0.0001; + if (isDivisor) { + const candidateSpacing = pixelsPerSecond * candidateSeconds; + if (candidateSpacing >= MIN_TICK_SPACING_PX) { + return candidateSeconds; + } + } + } + + // fallback: use the label interval itself (no intermediate ticks) + return labelIntervalSeconds; +} + +function findOptimalInterval({ + pixelsPerFrame, + pixelsPerSecond, + fps, + minSpacingPx, + frameIntervals, +}: { + pixelsPerFrame: number; + pixelsPerSecond: number; + fps: number; + minSpacingPx: number; + frameIntervals: readonly number[]; +}): number { + // try frame-level intervals first + for (const frameInterval of frameIntervals) { + const pixelSpacing = pixelsPerFrame * frameInterval; + if (pixelSpacing >= minSpacingPx) { + return frameInterval / fps; + } + } + + // then try second-level intervals + for (const secondMultiplier of SECOND_MULTIPLIERS) { + const pixelSpacing = pixelsPerSecond * secondMultiplier; + if (pixelSpacing >= minSpacingPx) { + return secondMultiplier; + } + } + + return 60; +} + +/** + * checks if a time should have a label based on the label interval. + */ +export function shouldShowLabel({ + time, + labelIntervalSeconds, +}: { + time: number; + labelIntervalSeconds: number; +}): boolean { + const epsilon = 0.0001; + const remainder = time % labelIntervalSeconds; + return remainder < epsilon || remainder > labelIntervalSeconds - epsilon; +} + +/** + * formats a ruler tick label. + * + * - on second boundaries: "MM:SS" (e.g., "00:00", "01:30") + * - between seconds: "Xf" (e.g., "5f", "15f") + */ +export function formatRulerLabel({ + timeInSeconds, + fps, +}: { + timeInSeconds: number; + fps: FrameRate; +}): string { + if (isSecondBoundary({ timeInSeconds })) { + return formatTimestamp({ timeInSeconds }); + } + + const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) }); + return `${frameWithinSecond}f`; +} + +/** + * checks if a time falls exactly on a second boundary. + */ +function isSecondBoundary({ + timeInSeconds, +}: { + timeInSeconds: number; +}): boolean { + const epsilon = 0.0001; + const remainder = timeInSeconds % 1; + return remainder < epsilon || remainder > 1 - epsilon; +} + +/** + * gets the frame number within the current second. + */ +function getFrameWithinSecond({ + timeInSeconds, + fps, +}: { + timeInSeconds: number; + fps: number; +}): number { + const fractionalPart = timeInSeconds % 1; + return Math.round(fractionalPart * fps); +} + +/** + * formats a timestamp as MM:SS or H:MM:SS when >= 1 hour. + */ +function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string { + const totalSeconds = Math.round(timeInSeconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const mm = minutes.toString().padStart(2, "0"); + const ss = seconds.toString().padStart(2, "0"); + + if (hours > 0) { + return `${hours}:${mm}:${ss}`; + } + + return `${mm}:${ss}`; +} diff --git a/apps/web/src/timeline/scale.ts b/apps/web/src/timeline/scale.ts new file mode 100644 index 00000000..7770f59f --- /dev/null +++ b/apps/web/src/timeline/scale.ts @@ -0,0 +1,3 @@ +export const BASE_TIMELINE_PIXELS_PER_SECOND = 50; +export const TIMELINE_ZOOM_MIN = 0.1; +export const TIMELINE_ZOOM_MAX = 100; diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts new file mode 100644 index 00000000..d48c943c --- /dev/null +++ b/apps/web/src/timeline/scenes.ts @@ -0,0 +1,113 @@ +import type { TScene } from "@/timeline";
+import { generateUUID } from "@/utils/id";
+import { calculateTotalDuration } from "@/timeline";
+import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
+
+export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
+ return scenes.find((scene) => scene.isMain) || null;
+}
+
+export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] {
+ const hasMain = scenes.some((scene) => scene.isMain);
+ if (!hasMain) {
+ const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
+ return [mainScene, ...scenes];
+ }
+ return scenes;
+}
+
+export function buildDefaultScene({
+ name,
+ isMain,
+}: {
+ name: string;
+ isMain: boolean;
+}): TScene {
+ return {
+ id: generateUUID(),
+ name,
+ isMain,
+ tracks: {
+ overlay: [],
+ main: {
+ id: generateUUID(),
+ name: MAIN_TRACK_NAME,
+ type: "video",
+ elements: [],
+ muted: false,
+ hidden: false,
+ },
+ audio: [],
+ },
+ bookmarks: [],
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+}
+
+export function canDeleteScene({ scene }: { scene: TScene }): {
+ canDelete: boolean;
+ reason?: string;
+} {
+ if (scene.isMain) {
+ return { canDelete: false, reason: "Cannot delete main scene" };
+ }
+ return { canDelete: true };
+}
+
+export function getFallbackSceneAfterDelete({
+ scenes,
+ deletedSceneId,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ deletedSceneId: string;
+ currentSceneId: string | null;
+}): TScene | null {
+ if (currentSceneId !== deletedSceneId) {
+ return scenes.find((s) => s.id === currentSceneId) || null;
+ }
+ return getMainScene({ scenes });
+}
+
+export function findCurrentScene({
+ scenes,
+ currentSceneId,
+}: {
+ scenes: TScene[];
+ currentSceneId: string;
+}): TScene | null {
+ return (
+ scenes.find((s) => s.id === currentSceneId) ||
+ getMainScene({ scenes }) ||
+ scenes[0] ||
+ null
+ );
+}
+
+export function getProjectDurationFromScenes({
+ scenes,
+}: {
+ scenes: TScene[];
+}): number {
+ const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
+ if (!mainScene?.tracks) {
+ return 0;
+ }
+
+ return calculateTotalDuration({ tracks: mainScene.tracks });
+}
+
+export function updateSceneInArray({
+ scenes,
+ sceneId,
+ updates,
+}: {
+ scenes: TScene[];
+ sceneId: string;
+ updates: Partial<TScene>;
+}): TScene[] {
+ return scenes.map((scene) =>
+ scene.id === sceneId ? { ...scene, ...updates } : scene,
+ );
+}
diff --git a/apps/web/src/timeline/snapping/build.ts b/apps/web/src/timeline/snapping/build.ts new file mode 100644 index 00000000..87d3db2e --- /dev/null +++ b/apps/web/src/timeline/snapping/build.ts @@ -0,0 +1,17 @@ +import type { SnapPoint, TimelineSnapPointSource } from "./types"; + +export function buildTimelineSnapPoints({ + sources, +}: { + sources: TimelineSnapPointSource[]; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + + for (const source of sources) { + for (const snapPoint of source()) { + snapPoints.push(snapPoint); + } + } + + return snapPoints; +} diff --git a/apps/web/src/timeline/snapping/index.ts b/apps/web/src/timeline/snapping/index.ts new file mode 100644 index 00000000..73f49272 --- /dev/null +++ b/apps/web/src/timeline/snapping/index.ts @@ -0,0 +1,9 @@ +export { buildTimelineSnapPoints } from "./build"; +export { resolveTimelineSnap } from "./resolve"; +export { getTimelineSnapThresholdInTicks } from "./threshold"; +export type { + SnapPoint, + SnapPointType, + SnapResult, + TimelineSnapPointSource, +} from "./types"; diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts new file mode 100644 index 00000000..2f00d081 --- /dev/null +++ b/apps/web/src/timeline/snapping/resolve.ts @@ -0,0 +1,28 @@ +import type { SnapPoint, SnapResult } from "./types"; + +export function resolveTimelineSnap({ + targetTime, + snapPoints, + maxSnapDistance, +}: { + targetTime: number; + snapPoints: SnapPoint[]; + maxSnapDistance: number; +}): SnapResult { + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; + + for (const snapPoint of snapPoints) { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance <= maxSnapDistance && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + } + + return { + snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; +} diff --git a/apps/web/src/timeline/snapping/threshold.ts b/apps/web/src/timeline/snapping/threshold.ts new file mode 100644 index 00000000..f03111dc --- /dev/null +++ b/apps/web/src/timeline/snapping/threshold.ts @@ -0,0 +1,15 @@ +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; + +const DEFAULT_TIMELINE_SNAP_THRESHOLD_PX = 10; + +export function getTimelineSnapThresholdInTicks({ + zoomLevel, + snapThresholdPx = DEFAULT_TIMELINE_SNAP_THRESHOLD_PX, +}: { + zoomLevel: number; + snapThresholdPx?: number; +}): number { + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + return (snapThresholdPx / pixelsPerSecond) * TICKS_PER_SECOND; +} diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts new file mode 100644 index 00000000..2a6d0903 --- /dev/null +++ b/apps/web/src/timeline/snapping/types.ts @@ -0,0 +1,21 @@ +export type SnapPointType = + | "element-start" + | "element-end" + | "playhead" + | "bookmark" + | "keyframe"; + +export interface SnapPoint { + time: number; + type: SnapPointType; + elementId?: string; + trackId?: string; +} + +export interface SnapResult { + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; +} + +export type TimelineSnapPointSource = () => Iterable<SnapPoint>; diff --git a/apps/web/src/timeline/timeline-store.ts b/apps/web/src/timeline/timeline-store.ts new file mode 100644 index 00000000..b7ddb885 --- /dev/null +++ b/apps/web/src/timeline/timeline-store.ts @@ -0,0 +1,57 @@ +/** + * UI state for the timeline + * For core logic, use EditorCore instead. + */ + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface TimelineStore { + snappingEnabled: boolean; + toggleSnapping: () => void; + rippleEditingEnabled: boolean; + toggleRippleEditing: () => void; + expandedElementIds: Set<string>; + toggleElementExpanded: (elementId: string) => void; +} + +export const useTimelineStore = create<TimelineStore>()( + persist( + (set) => ({ + snappingEnabled: true, + + toggleSnapping: () => { + set((state) => ({ snappingEnabled: !state.snappingEnabled })); + }, + + rippleEditingEnabled: false, + + toggleRippleEditing: () => { + set((state) => ({ + rippleEditingEnabled: !state.rippleEditingEnabled, + })); + }, + + expandedElementIds: new Set<string>(), + + toggleElementExpanded: (elementId) => { + set((state) => { + const next = new Set(state.expandedElementIds); + if (next.has(elementId)) { + next.delete(elementId); + } else { + next.add(elementId); + } + return { expandedElementIds: next }; + }); + }, + }), + { + name: "timeline-store", + partialize: (state) => ({ + snappingEnabled: state.snappingEnabled, + rippleEditingEnabled: state.rippleEditingEnabled, + }), + }, + ), +); diff --git a/apps/web/src/timeline/track-capabilities.ts b/apps/web/src/timeline/track-capabilities.ts new file mode 100644 index 00000000..e6ff3945 --- /dev/null +++ b/apps/web/src/timeline/track-capabilities.ts @@ -0,0 +1,20 @@ +import type { + TimelineTrack, + VideoTrack, + AudioTrack, + GraphicTrack, + TextTrack, + EffectTrack, +} from "@/timeline"; + +export function canTrackHaveAudio( + track: TimelineTrack, +): track is VideoTrack | AudioTrack { + return track.type === "audio" || track.type === "video"; +} + +export function canTrackBeHidden( + track: TimelineTrack, +): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack { + return track.type !== "audio"; +} diff --git a/apps/web/src/timeline/track-element-update.ts b/apps/web/src/timeline/track-element-update.ts new file mode 100644 index 00000000..7d86a4e8 --- /dev/null +++ b/apps/web/src/timeline/track-element-update.ts @@ -0,0 +1,147 @@ +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
+
+export function findTrackInSceneTracks({
+ tracks,
+ trackId,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+}): TimelineTrack | null {
+ if (tracks.main.id === trackId) {
+ return tracks.main;
+ }
+
+ return (
+ tracks.overlay.find((track) => track.id === trackId) ??
+ tracks.audio.find((track) => track.id === trackId) ??
+ null
+ );
+}
+
+export function updateTrackInSceneTracks({
+ tracks,
+ trackId,
+ update,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
+}): SceneTracks {
+ if (tracks.main.id === trackId) {
+ return {
+ ...tracks,
+ main: update(tracks.main),
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
+ if (overlayTrackIndex >= 0) {
+ return {
+ ...tracks,
+ overlay: tracks.overlay.map((track, index) =>
+ index === overlayTrackIndex ? update(track) : track,
+ ),
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
+ if (audioTrackIndex >= 0) {
+ return {
+ ...tracks,
+ audio: tracks.audio.map((track, index) =>
+ index === audioTrackIndex ? update(track) : track,
+ ),
+ };
+ }
+
+ return tracks;
+}
+
+function updateElementInTrack<TTrack extends TimelineTrack>({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ track: TTrack;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): TTrack {
+ const nextElements = track.elements.map((element) => {
+ if (element.id !== elementId) {
+ return element;
+ }
+ if (elementPredicate && !elementPredicate(element)) {
+ return element;
+ }
+ return update(element);
+ });
+
+ return {
+ ...track,
+ elements: nextElements,
+ } as TTrack;
+}
+
+export function updateElementInSceneTracks({
+ tracks,
+ trackId,
+ elementId,
+ update,
+ elementPredicate,
+}: {
+ tracks: SceneTracks;
+ trackId: string;
+ elementId: string;
+ update: (element: TimelineElement) => TimelineElement;
+ elementPredicate?: (element: TimelineElement) => boolean;
+}): SceneTracks {
+ if (tracks.main.id === trackId) {
+ return {
+ ...tracks,
+ main: updateElementInTrack({
+ track: tracks.main,
+ elementId,
+ update,
+ elementPredicate,
+ }),
+ };
+ }
+
+ const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
+ if (overlayTrackIndex >= 0) {
+ return {
+ ...tracks,
+ overlay: tracks.overlay.map((track, index) =>
+ index === overlayTrackIndex
+ ? updateElementInTrack({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+ })
+ : track,
+ ),
+ };
+ }
+
+ const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
+ if (audioTrackIndex >= 0) {
+ return {
+ ...tracks,
+ audio: tracks.audio.map((track, index) =>
+ index === audioTrackIndex
+ ? updateElementInTrack({
+ track,
+ elementId,
+ update,
+ elementPredicate,
+ })
+ : track,
+ ),
+ };
+ }
+
+ return tracks;
+}
diff --git a/apps/web/src/timeline/tracks.ts b/apps/web/src/timeline/tracks.ts new file mode 100644 index 00000000..9ce5511d --- /dev/null +++ b/apps/web/src/timeline/tracks.ts @@ -0,0 +1,9 @@ +import type { TrackType } from "@/timeline"; + +export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = { + video: "Video track", + text: "Text track", + audio: "Audio track", + graphic: "Graphic track", + effect: "Effect track", +} as const; diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts new file mode 100644 index 00000000..cd543cdf --- /dev/null +++ b/apps/web/src/timeline/types.ts @@ -0,0 +1,314 @@ +import type { ElementAnimations } from "@/animation/types";
+import type { Effect } from "@/effects/types";
+import type { Mask } from "@/masks/types";
+import type { ParamValues } from "@/params";
+import type { BlendMode, Transform } from "@/rendering";
+
+export type ElementRef = {
+ trackId: string;
+ elementId: string;
+};
+
+export interface Bookmark {
+ time: number;
+ note?: string;
+ color?: string;
+ duration?: number;
+}
+
+export interface TScene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ tracks: SceneTracks;
+ bookmarks: Bookmark[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
+
+interface BaseTrack {
+ id: string;
+ name: string;
+}
+
+export interface VideoTrack extends BaseTrack {
+ type: "video";
+ elements: (VideoElement | ImageElement)[];
+ muted: boolean;
+ hidden: boolean;
+}
+
+export interface TextTrack extends BaseTrack {
+ type: "text";
+ elements: TextElement[];
+ hidden: boolean;
+}
+
+export interface AudioTrack extends BaseTrack {
+ type: "audio";
+ elements: AudioElement[];
+ muted: boolean;
+}
+
+export interface GraphicTrack extends BaseTrack {
+ type: "graphic";
+ elements: (StickerElement | GraphicElement)[];
+ hidden: boolean;
+}
+
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | GraphicTrack
+ | EffectTrack;
+
+export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
+
+export interface SceneTracks {
+ overlay: OverlayTrack[];
+ main: VideoTrack;
+ audio: AudioTrack[];
+}
+
+export interface RetimeConfig {
+ rate: number;
+ maintainPitch?: boolean;
+}
+
+interface BaseAudioElement extends BaseTimelineElement {
+ type: "audio";
+ volume: number;
+ muted?: boolean;
+ buffer?: AudioBuffer;
+ retime?: RetimeConfig;
+}
+
+export interface UploadAudioElement extends BaseAudioElement {
+ sourceType: "upload";
+ mediaId: string;
+}
+
+export interface LibraryAudioElement extends BaseAudioElement {
+ sourceType: "library";
+ sourceUrl: string;
+}
+
+export type AudioElement = UploadAudioElement | LibraryAudioElement;
+
+interface BaseTimelineElement {
+ id: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+ sourceDuration?: number;
+ animations?: ElementAnimations;
+}
+
+export interface VideoElement extends BaseTimelineElement {
+ type: "video";
+ mediaId: string;
+ volume?: number;
+ muted?: boolean;
+ isSourceAudioEnabled?: boolean;
+ hidden?: boolean;
+ retime?: RetimeConfig;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface ImageElement extends BaseTimelineElement {
+ type: "image";
+ mediaId: string;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface TextBackground {
+ enabled: boolean;
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
+}
+
+export interface TextElement extends BaseTimelineElement {
+ type: "text";
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ background: TextBackground;
+ textAlign: "left" | "center" | "right";
+ fontWeight: "normal" | "bold";
+ fontStyle: "normal" | "italic";
+ textDecoration: "none" | "underline" | "line-through";
+ letterSpacing?: number;
+ lineHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface StickerElement extends BaseTimelineElement {
+ type: "sticker";
+ stickerId: string;
+ /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface GraphicElement extends BaseTimelineElement {
+ type: "graphic";
+ definitionId: string;
+ params: ParamValues;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: ParamValues;
+}
+
+export type ElementUpdatePatch =
+ | { transform: Transform }
+ | { opacity: number }
+ | { volume: number };
+
+export type TimelineElement =
+ | AudioElement
+ | VideoElement
+ | ImageElement
+ | TextElement
+ | StickerElement
+ | GraphicElement
+ | EffectElement;
+
+export type ElementType = TimelineElement["type"];
+
+function elementTypes<T extends ElementType[]>(...types: T): T {
+ return types;
+}
+
+export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
+
+export type MaskableElement = Extract<
+ TimelineElement,
+ { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
+
+export type RetimableElement = Extract<
+ TimelineElement,
+ { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const VISUAL_ELEMENT_TYPES = elementTypes(
+ "video",
+ "image",
+ "text",
+ "sticker",
+ "graphic",
+);
+
+export type VisualElement = Extract<
+ TimelineElement,
+ { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
+>;
+
+export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
+export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
+export type CreateAudioElement =
+ | CreateUploadAudioElement
+ | CreateLibraryAudioElement;
+export type CreateVideoElement = Omit<VideoElement, "id">;
+export type CreateImageElement = Omit<ImageElement, "id">;
+export type CreateTextElement = Omit<TextElement, "id">;
+export type CreateStickerElement = Omit<StickerElement, "id">;
+export type CreateGraphicElement = Omit<GraphicElement, "id">;
+export type CreateEffectElement = Omit<EffectElement, "id">;
+export type CreateTimelineElement =
+ | CreateAudioElement
+ | CreateVideoElement
+ | CreateImageElement
+ | CreateTextElement
+ | CreateStickerElement
+ | CreateGraphicElement
+ | CreateEffectElement;
+
+export interface ElementDragState {
+ isDragging: boolean;
+ elementId: string | null;
+ dragElementIds: string[];
+ dragTimeOffsets: Record<string, number>;
+ trackId: string | null;
+ startMouseX: number;
+ startMouseY: number;
+ startElementTime: number;
+ clickOffsetTime: number;
+ currentTime: number;
+ currentMouseY: number;
+}
+
+export interface DropTarget {
+ trackIndex: number;
+ isNewTrack: boolean;
+ insertPosition: "above" | "below" | null;
+ xPosition: number;
+ targetElement: { elementId: string; trackId: string } | null;
+}
+
+export interface ComputeDropTargetParams {
+ elementType: ElementType;
+ mouseX: number;
+ mouseY: number;
+ tracks: SceneTracks;
+ playheadTime: number;
+ isExternalDrop: boolean;
+ elementDuration: number;
+ pixelsPerSecond: number;
+ zoomLevel: number;
+ verticalDragDirection?: "up" | "down" | null;
+ startTimeOverride?: number;
+ excludeElementId?: string;
+ targetElementTypes?: string[];
+}
+
+export interface ClipboardItem {
+ trackId: string;
+ trackType: TrackType;
+ element: CreateTimelineElement;
+}
diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts new file mode 100644 index 00000000..beacf897 --- /dev/null +++ b/apps/web/src/timeline/update-pipeline.ts @@ -0,0 +1,211 @@ +import { clampAnimationsToDuration } from "@/animation"; +import { + clampRetimeRate, + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/retime"; +import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; +import { isRetimableElement } from "@/timeline"; + +type ElementUpdateField = keyof TimelineElement | string; + +export interface ElementUpdateContext { + tracks: SceneTracks; + trackId: string; +} + +interface ElementUpdateRuleResult { + element: TimelineElement; + changedFields?: ElementUpdateField[]; +} + +interface ElementUpdateRuleParams { + element: TimelineElement; + originalElement: TimelineElement; + patch: Partial<TimelineElement>; + context: ElementUpdateContext; +} + +interface ElementUpdateRule { + triggers: ElementUpdateField[]; + apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult; +} + +const deriveRules: ElementUpdateRule[] = [ + { + triggers: ["retime"], + apply: ({ element, originalElement, patch }) => { + if (!("retime" in patch) || !isRetimableElement(element)) { + return { element }; + } + + const nextRetime = patch.retime + ? { + ...patch.retime, + rate: clampRetimeRate({ rate: patch.retime.rate }), + } + : undefined; + + const sourceDuration = getSourceDuration({ + trimStart: originalElement.trimStart, + trimEnd: originalElement.trimEnd, + duration: originalElement.duration, + sourceDuration: isRetimableElement(originalElement) + ? originalElement.sourceDuration + : undefined, + retime: isRetimableElement(originalElement) + ? originalElement.retime + : undefined, + }); + const visibleSourceSpan = Math.max( + 0, + sourceDuration - element.trimStart - element.trimEnd, + ); + const nextDuration = getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }); + + return { + element: { + ...element, + retime: nextRetime, + duration: nextDuration, + }, + changedFields: ["retime", "duration"], + }; + }, + }, +]; + +const enforceRules: ElementUpdateRule[] = [ + { + triggers: ["duration"], + apply: ({ element }) => ({ + element: { + ...element, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: element.duration, + }), + }, + }), + }, + { + triggers: ["startTime"], + apply: ({ element, context }) => { + const requestedStartTime = Math.max(0, element.startTime); + if (context.trackId !== context.tracks.main.id) { + return { + element: { + ...element, + startTime: requestedStartTime, + }, + }; + } + + const earliestElement = context.tracks.main.elements + .filter((candidate) => candidate.id !== element.id) + .reduce<TimelineElement | null>((earliest, candidate) => { + if (!earliest || candidate.startTime < earliest.startTime) { + return candidate; + } + return earliest; + }, null); + + return { + element: { + ...element, + startTime: + !earliestElement || requestedStartTime <= earliestElement.startTime + ? 0 + : requestedStartTime, + }, + }; + }, + }, +]; + +export function applyElementUpdate({ + element, + patch, + context, +}: { + element: TimelineElement; + patch: Partial<TimelineElement>; + context: ElementUpdateContext; +}): TimelineElement { + let nextElement = { ...element, ...patch } as TimelineElement; + const changedFields = new Set( + Object.keys(patch) as ElementUpdateField[], + ); + + for (const rule of deriveRules) { + if (!shouldApplyRule({ rule, changedFields })) { + continue; + } + + const result = rule.apply({ + element: nextElement, + originalElement: element, + patch, + context, + }); + nextElement = result.element; + for (const field of result.changedFields ?? []) { + changedFields.add(field); + } + } + + for (const rule of enforceRules) { + if (!shouldApplyRule({ rule, changedFields })) { + continue; + } + + nextElement = rule.apply({ + element: nextElement, + originalElement: element, + patch, + context, + }).element; + } + + return nextElement; +} + +function shouldApplyRule({ + rule, + changedFields, +}: { + rule: ElementUpdateRule; + changedFields: Set<ElementUpdateField>; +}): boolean { + return rule.triggers.some((trigger) => changedFields.has(trigger)); +} + +function getSourceDuration({ + trimStart, + trimEnd, + duration, + sourceDuration, + retime, +}: { + trimStart: number; + trimEnd: number; + duration: number; + sourceDuration?: number; + retime?: RetimeConfig; +}): number { + if (typeof sourceDuration === "number") { + return sourceDuration; + } + + return ( + trimStart + + getSourceSpanAtClipTime({ + clipTime: duration, + retime, + }) + + trimEnd + ); +} diff --git a/apps/web/src/timeline/zoom-utils.ts b/apps/web/src/timeline/zoom-utils.ts new file mode 100644 index 00000000..39090d92 --- /dev/null +++ b/apps/web/src/timeline/zoom-utils.ts @@ -0,0 +1,90 @@ +import { + BASE_TIMELINE_PIXELS_PER_SECOND, + TIMELINE_ZOOM_MAX, +} from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; + +const PADDING_MAX_RATIO = 0.75; +const PADDING_MIN_RATIO = 0.15; +const PADDING_MIN_AT_ZOOM_PERCENT = 0.2; + +export function getTimelineZoomMin({ + duration, + containerWidth, +}: { + duration: number; + containerWidth: number | null | undefined; +}): number { + const safeDurationSeconds = Math.max(duration / TICKS_PER_SECOND, 1); + const safeContainerWidth = containerWidth ?? 1000; + const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO; + const availableWidth = safeContainerWidth * contentRatioAtMinZoom; + const zoomToFit = + availableWidth / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND); + + return Math.min(TIMELINE_ZOOM_MAX, zoomToFit); +} + +export function getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom, +}: { + containerWidth: number; + zoomLevel: number; + minZoom: number; +}): number { + const zoomPercent = getZoomPercent({ zoomLevel, minZoom }); + const paddingTransitionPercent = Math.min( + zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT, + 1, + ); + const paddingRatio = + PADDING_MAX_RATIO - + (PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent; + + return containerWidth * paddingRatio; +} + +export function getZoomPercent({ + zoomLevel, + minZoom, +}: { + zoomLevel: number; + minZoom: number; +}): number { + return (zoomLevel - minZoom) / (TIMELINE_ZOOM_MAX - minZoom); +} + +/** + * convert linear slider position (0-1) to exponential zoom level. + * at low slider values, zoom changes are small. at high values, changes are large. + */ +export function sliderToZoom({ + sliderPosition, + minZoom, + maxZoom = TIMELINE_ZOOM_MAX, +}: { + sliderPosition: number; + minZoom: number; + maxZoom?: number; +}): number { + const clampedPosition = Math.max(0, Math.min(1, sliderPosition)); + return minZoom * (maxZoom / minZoom) ** clampedPosition; +} + +/** + * convert exponential zoom level to linear slider position (0-1). + */ +export function zoomToSlider({ + zoomLevel, + minZoom, + maxZoom = TIMELINE_ZOOM_MAX, +}: { + zoomLevel: number; + minZoom: number; + maxZoom?: number; +}): number { + const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel)); + return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom); +} |
