From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- .../timeline/bookmarks/components/bookmarks.tsx | 425 +++++++++++++++++++++ .../timeline/bookmarks/hooks/use-bookmark-drag.ts | 298 +++++++++++++++ apps/web/src/timeline/bookmarks/index.ts | 19 + .../timeline/bookmarks/preview-overlay-source.tsx | 85 +++++ apps/web/src/timeline/bookmarks/snap-source.ts | 20 + apps/web/src/timeline/bookmarks/utils.ts | 145 +++++++ 6 files changed, 992 insertions(+) create mode 100644 apps/web/src/timeline/bookmarks/components/bookmarks.tsx create mode 100644 apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts create mode 100644 apps/web/src/timeline/bookmarks/index.ts create mode 100644 apps/web/src/timeline/bookmarks/preview-overlay-source.tsx create mode 100644 apps/web/src/timeline/bookmarks/snap-source.ts create mode 100644 apps/web/src/timeline/bookmarks/utils.ts (limited to 'apps/web/src/timeline/bookmarks') diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx new file mode 100644 index 00000000..20e22c57 --- /dev/null +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { EditorCore } from "@/core"; +import { useEditor } from "@/editor/use-editor"; +import type { BookmarkDragState } from "../hooks/use-bookmark-drag"; +import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme"; +import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout"; +import { DEFAULT_FPS } from "@/fps/defaults"; +import { snappedSeekTime } from "opencut-wasm"; +import { + ArrowTurnBackwardIcon, + Delete02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import type { Bookmark } from "@/timeline"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { uppercase } from "@/utils/string"; +import { clamp, formatNumberForDisplay } from "@/utils/math"; +import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; + +const MIN_BOOKMARK_WIDTH_PX = 2; +const BOOKMARK_MARKER_WIDTH_PX = 12; +const BOOKMARK_MARKER_HEIGHT_PX = 15; +const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2; +const BOOKMARK_MARKER_CLIP_PATH = + "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)"; + +function seekToBookmarkTime({ + editor, + time, +}: { + editor: EditorCore; + time: number; +}) { + const activeProject = editor.project.getActive(); + const duration = editor.timeline.getTotalDuration(); + const rate = activeProject?.settings.fps ?? DEFAULT_FPS; + const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + editor.playback.seek({ time: snappedTime }); +} + +interface TimelineBookmarksRowProps { + zoomLevel: number; + dynamicTimelineWidth: number; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; + handleWheel: (event: React.WheelEvent) => void; + handleTimelineContentClick: (event: React.MouseEvent) => void; + handleRulerTrackingMouseDown: (event: React.MouseEvent) => void; + handleRulerMouseDown: (event: React.MouseEvent) => void; +} + +export function TimelineBookmarksRow({ + zoomLevel, + dynamicTimelineWidth, + dragState, + onBookmarkMouseDown, + handleWheel, + handleTimelineContentClick, + handleRulerTrackingMouseDown, + handleRulerMouseDown, +}: TimelineBookmarksRowProps) { + const bookmarks = useEditor((e) => e.scenes.getActiveScene().bookmarks); + + return ( +
+ +
+ ); +} + +function TimelineBookmark({ + bookmark, + zoomLevel, + dragState, + onBookmarkMouseDown, +}: { + bookmark: Bookmark; + zoomLevel: number; + dragState: BookmarkDragState; + onBookmarkMouseDown: (params: { + event: React.MouseEvent; + bookmark: Bookmark; + }) => void; +}) { + const editor = useEditor(); + const duration = editor.timeline.getTotalDuration(); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const isDragging = + dragState.isDragging && + dragState.bookmarkTime !== null && + dragState.bookmarkTime === bookmark.time; + + const displayTime = isDragging ? dragState.currentTime : bookmark.time; + const time = bookmark.time; + const bookmarkDuration = bookmark.duration ?? 0; + const durationWidth = + bookmarkDuration > 0 + ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) + : 0; + const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX; + const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0); + const left = timelineTimeToSnappedPixels({ time: displayTime, zoomLevel }); + const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX; + const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0); + const iconColor = bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR; + + const handleSeek = () => seekToBookmarkTime({ editor, time }); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (event.detail === 2) { + setIsPopoverOpen(true); + } else { + handleSeek(); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + handleSeek(); + }; + + const handleMouseDown = (event: React.MouseEvent) => { + onBookmarkMouseDown({ event, bookmark }); + event.preventDefault(); + event.stopPropagation(); + }; + + return ( + + + + + event.preventDefault()} + > + setIsPopoverOpen(false)} + /> + + + ); +} + +function BookmarkPopoverContent({ + bookmark, + time, + timelineDuration, + onPopoverClose, +}: { + bookmark: Bookmark; + time: number; + timelineDuration: number; + onPopoverClose: () => void; +}) { + const editor = useEditor(); + const [draftColorHex, setDraftColorHex] = useState( + (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) + .replace("#", "") + .toUpperCase(), + ); + + useEffect(() => { + setDraftColorHex( + (bookmark.color ?? DEFAULT_TIMELINE_BOOKMARK_COLOR) + .replace("#", "") + .toUpperCase(), + ); + }, [bookmark.color]); + + const handleRemove = () => { + editor.scenes.removeBookmark({ time }); + onPopoverClose(); + }; + + const handleUpdate = ({ + note, + color, + duration, + }: Partial<{ note: string; color: string; duration: number }>) => { + const updates: Partial<{ note: string; color: string; duration: number }> = + {}; + if (note !== undefined && note !== bookmark.note) updates.note = note; + if ( + color !== undefined && + color.toUpperCase() !== (bookmark.color ?? "").toUpperCase() + ) { + updates.color = color; + } + if (duration !== undefined && duration !== bookmark.duration) { + updates.duration = duration; + } + if (Object.keys(updates).length === 0) return; + editor.scenes.updateBookmark({ time, updates }); + }; + + return ( + <> +
+ + handleUpdate({ note: event.target.value })} + className="h-8 text-sm" + /> +
+
+ +
+ setDraftColorHex(uppercase({ string: color }))} + onChangeEnd={(color) => + handleUpdate({ color: `#${uppercase({ string: color })}` }) + } + className="bg-background border" + /> + {bookmark.color && + bookmark.color.replace(/^#/, "").toUpperCase() !== + DEFAULT_TIMELINE_BOOKMARK_COLOR.replace( + /^#/, + "", + ).toUpperCase() && ( + + )} +
+
+
+ +
+ { + const parsed = parseFloat(event.target.value); + const value = Number.isNaN(parsed) + ? 0 + : clamp({ + value: parsed, + min: 0, + max: Math.max(0, timelineDuration - time), + }); + handleUpdate({ duration: value }); + }} + className="h-8 text-sm" + containerClassName="w-full" + /> +
+
+ + + ); +} diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts new file mode 100644 index 00000000..1071c6dd --- /dev/null +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -0,0 +1,298 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type RefObject, +} from "react"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { roundToFrame } from "opencut-wasm"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getBookmarkSnapPoints } from "../snap-source"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import type { Bookmark } from "@/timeline"; + +export interface BookmarkDragState { + isDragging: boolean; + bookmarkTime: number | null; + currentTime: number; +} + +interface PendingBookmarkDrag { + bookmarkTime: number; + startMouseX: number; + startMouseY: number; +} + +interface UseBookmarkDragProps { + zoomLevel: number; + scrollRef: RefObject; + snappingEnabled: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +export function useBookmarkDrag({ + zoomLevel, + scrollRef, + snappingEnabled, + onSnapPointChange, +}: UseBookmarkDragProps) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const tracks = editor.scenes.getActiveScene().tracks; + const activeScene = editor.scenes.getActiveScene(); + const bookmarks = activeScene?.bookmarks ?? []; + const playheadTime = editor.playback.getCurrentTime(); + const duration = editor.timeline.getTotalDuration(); + + const [dragState, setDragState] = useState({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + const [isPendingDrag, setIsPendingDrag] = useState(false); + const pendingDragRef = useRef(null); + const lastMouseXRef = useRef(0); + + const startDrag = useCallback( + ({ + bookmarkTime, + initialCurrentTime, + }: { + bookmarkTime: number; + initialCurrentTime: number; + }) => { + setDragState({ + isDragging: true, + bookmarkTime, + currentTime: initialCurrentTime, + }); + }, + [], + ); + + const endDrag = useCallback(() => { + setDragState({ + isDragging: false, + bookmarkTime: null, + currentTime: 0, + }); + }, []); + + const getSnapResult = useCallback( + ({ + rawTime, + excludeBookmarkTime, + }: { + rawTime: number; + excludeBookmarkTime: number; + }): { snappedTime: number; snapPoint: SnapPoint | null } => { + const shouldSnap = snappingEnabled && !isShiftHeldRef.current; + if (!shouldSnap) { + return { snappedTime: rawTime, snapPoint: null }; + } + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks }), + () => getPlayheadSnapPoints({ playheadTime }), + () => getBookmarkSnapPoints({ bookmarks, excludeBookmarkTime }), + () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), + ], + }); + const result = resolveTimelineSnap({ + targetTime: rawTime, + snapPoints, + maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), + }); + return { + snappedTime: result.snappedTime, + snapPoint: result.snapPoint, + }; + }, + [ + snappingEnabled, + tracks, + playheadTime, + bookmarks, + zoomLevel, + isShiftHeldRef, + ], + ); + + useEffect(() => { + if (!dragState.isDragging && !isPendingDrag) return; + + const handleMouseMove = (event: MouseEvent) => { + lastMouseXRef.current = event.clientX; + + const scrollContainer = scrollRef.current; + if (!scrollContainer) return; + + if (isPendingDrag && pendingDragRef.current) { + const { startMouseX, startMouseY, bookmarkTime } = + pendingDragRef.current; + const deltaX = Math.abs(event.clientX - startMouseX); + const deltaY = Math.abs(event.clientY - startMouseY); + + if ( + deltaX <= TIMELINE_DRAG_THRESHOLD_PX && + deltaY <= TIMELINE_DRAG_THRESHOLD_PX + ) { + return; + } + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const frameSnappedTime = + roundToFrame({ + time: Math.max(0, Math.min(mouseTime, duration)), + rate: activeProject.settings.fps, + }) ?? Math.max(0, Math.min(mouseTime, duration)); + const { snappedTime: initialTime } = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: bookmarkTime, + }); + + startDrag({ + bookmarkTime, + initialCurrentTime: initialTime, + }); + pendingDragRef.current = null; + setIsPendingDrag(false); + return; + } + + if (!dragState.isDragging || dragState.bookmarkTime === null) return; + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const scrollLeft = scrollContainer.scrollLeft; + const mouseTime = getMouseTimeFromClientX({ + clientX: event.clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft, + }); + const clampedTime = Math.max(0, Math.min(mouseTime, duration)); + const frameSnappedTime = + roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? + clampedTime; + const snapResult = getSnapResult({ + rawTime: frameSnappedTime, + excludeBookmarkTime: dragState.bookmarkTime, + }); + + setDragState((previousDragState) => ({ + ...previousDragState, + currentTime: snapResult.snappedTime, + })); + onSnapPointChange?.(snapResult.snapPoint); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + zoomLevel, + duration, + editor.project, + scrollRef, + isPendingDrag, + startDrag, + getSnapResult, + onSnapPointChange, + ]); + + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseUp = () => { + if (dragState.bookmarkTime === null) { + endDrag(); + onSnapPointChange?.(null); + return; + } + + const clampedTime = Math.max( + 0, + Math.min(dragState.currentTime, duration), + ); + + editor.scenes.moveBookmark({ + fromTime: dragState.bookmarkTime, + toTime: clampedTime, + }); + + endDrag(); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [ + dragState.isDragging, + dragState.bookmarkTime, + dragState.currentTime, + duration, + endDrag, + onSnapPointChange, + editor.scenes, + ]); + + useEffect(() => { + if (!isPendingDrag) return; + + const handleMouseUp = () => { + pendingDragRef.current = null; + setIsPendingDrag(false); + onSnapPointChange?.(null); + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isPendingDrag, onSnapPointChange]); + + const handleBookmarkMouseDown = useCallback( + ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => { + if (event.button !== 0) return; + + event.preventDefault(); + event.stopPropagation(); + + pendingDragRef.current = { + bookmarkTime: bookmark.time, + startMouseX: event.clientX, + startMouseY: event.clientY, + }; + setIsPendingDrag(true); + }, + [], + ); + + return { + dragState, + handleBookmarkMouseDown, + lastMouseXRef, + }; +} diff --git a/apps/web/src/timeline/bookmarks/index.ts b/apps/web/src/timeline/bookmarks/index.ts new file mode 100644 index 00000000..667979b4 --- /dev/null +++ b/apps/web/src/timeline/bookmarks/index.ts @@ -0,0 +1,19 @@ +export { + findBookmarkIndex, + isBookmarkAtTime, + toggleBookmarkInArray, + removeBookmarkFromArray, + updateBookmarkInArray, + moveBookmarkInArray, + getFrameTime, + getBookmarkAtTime, + getBookmarksActiveAtTime, +} from "./utils"; +export { getBookmarkSnapPoints } from "./snap-source"; +export { + bookmarkNotesPreviewOverlay, + getBookmarkPreviewOverlaySource, +} from "./preview-overlay-source"; +export { useBookmarkDrag } from "./hooks/use-bookmark-drag"; +export type { BookmarkDragState } from "./hooks/use-bookmark-drag"; +export { TimelineBookmarksRow } from "./components/bookmarks"; diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx new file mode 100644 index 00000000..8e2b4bcf --- /dev/null +++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx @@ -0,0 +1,85 @@ +import type { Bookmark } from "@/timeline"; +import { + EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT, + type PreviewOverlayDefinition, + type PreviewOverlaySourceResult, +} from "@/preview/overlays"; +import { getBookmarksActiveAtTime } from "./utils"; + +export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { + id: "bookmark-notes", + label: "Show bookmark notes", + defaultVisible: true, +}; + +function BookmarkNotesOverlay({ + bookmarks, +}: { + bookmarks: Array<{ time: number; note: string; color?: string }>; +}) { + return ( +
+ {bookmarks.map((bookmark) => ( +
+ {bookmark.note} +
+ ))} +
+ ); +} + +export function getBookmarkPreviewOverlaySource({ + bookmarks, + time, + isVisible, +}: { + bookmarks: Bookmark[]; + time: number; + isVisible: boolean; +}): PreviewOverlaySourceResult { + const bookmarksWithNotes = getBookmarksActiveAtTime({ + bookmarks, + time, + }).flatMap((bookmark) => { + if (bookmark.note == null || bookmark.note.trim() === "") { + return []; + } + + return [ + { + time: bookmark.time, + note: bookmark.note, + color: bookmark.color, + }, + ]; + }); + + if (!isVisible || bookmarksWithNotes.length === 0) { + return { + ...EMPTY_PREVIEW_OVERLAY_SOURCE_RESULT, + definitions: [bookmarkNotesPreviewOverlay], + }; + } + + return { + definitions: [bookmarkNotesPreviewOverlay], + instances: [ + { + id: bookmarkNotesPreviewOverlay.id, + mount: { kind: "hud", anchor: "top-left", order: 0 }, + plane: "over-interaction", + pointerEvents: "none", + render: () => , + }, + ], + }; +} diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts new file mode 100644 index 00000000..32a84baa --- /dev/null +++ b/apps/web/src/timeline/bookmarks/snap-source.ts @@ -0,0 +1,20 @@ +import type { Bookmark } from "@/timeline"; +import type { SnapPoint } from "@/timeline/snapping"; + +export function getBookmarkSnapPoints({ + bookmarks, + excludeBookmarkTime, +}: { + bookmarks: Bookmark[]; + excludeBookmarkTime?: number; +}): SnapPoint[] { + return bookmarks.flatMap((bookmark) => { + if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) { + return []; + } + + return [ + { time: bookmark.time, type: "bookmark" satisfies SnapPoint["type"] }, + ]; + }); +} diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts new file mode 100644 index 00000000..d05fe3a9 --- /dev/null +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -0,0 +1,145 @@ +import type { Bookmark } from "@/timeline"; +import type { FrameRate } from "opencut-wasm"; +import { roundToFrame } from "opencut-wasm"; + +function bookmarkTimeEqual({ + bookmarkTime, + frameTime, +}: { + bookmarkTime: number; + frameTime: number; +}): boolean { + return bookmarkTime === frameTime; +} + +export function findBookmarkIndex({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): number { + return bookmarks.findIndex((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); +} + +export function isBookmarkAtTime({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): boolean { + return bookmarks.some((bookmark) => + bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); +} + +export function toggleBookmarkInArray({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark[] { + const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); + + if (bookmarkIndex !== -1) { + return bookmarks.filter((_, index) => index !== bookmarkIndex); + } + + const newBookmarks = [...bookmarks, { time: frameTime }]; + return newBookmarks.slice().sort((a, b) => a.time - b.time); +} + +export function removeBookmarkFromArray({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark[] { + return bookmarks.filter( + (bookmark) => + !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), + ); +} + +export function updateBookmarkInArray({ + bookmarks, + frameTime, + updates, +}: { + bookmarks: Bookmark[]; + frameTime: number; + updates: Partial>; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], ...updates }; + const result = [...bookmarks]; + result[index] = updated; + return result; +} + +export function moveBookmarkInArray({ + bookmarks, + fromTime, + toTime, +}: { + bookmarks: Bookmark[]; + fromTime: number; + toTime: number; +}): Bookmark[] { + const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); + if (index === -1) { + return bookmarks; + } + + const updated = { ...bookmarks[index], time: toTime }; + const result = [...bookmarks]; + result[index] = updated; + return result.slice().sort((a, b) => a.time - b.time); +} + +export function getFrameTime({ + time, + fps, +}: { + time: number; + fps: FrameRate; +}): number { + return roundToFrame({ time, rate: fps }) ?? time; +} + +export function getBookmarkAtTime({ + bookmarks, + frameTime, +}: { + bookmarks: Bookmark[]; + frameTime: number; +}): Bookmark | null { + const index = findBookmarkIndex({ bookmarks, frameTime }); + return index === -1 ? null : bookmarks[index]; +} + +export function getBookmarksActiveAtTime({ + bookmarks, + time, +}: { + bookmarks: Bookmark[]; + time: number; +}): Bookmark[] { + return bookmarks.filter((bookmark) => { + const start = bookmark.time; + const end = + bookmark.duration != null && bookmark.duration > 0 + ? start + bookmark.duration + : start; + return time >= start && time <= end; + }); +} -- cgit v1.3.1