diff options
Diffstat (limited to 'apps/web/src/timeline')
33 files changed, 549 insertions, 308 deletions
diff --git a/apps/web/src/timeline/__tests__/update-pipeline.test.ts b/apps/web/src/timeline/__tests__/update-pipeline.test.ts new file mode 100644 index 00000000..94b32ced --- /dev/null +++ b/apps/web/src/timeline/__tests__/update-pipeline.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test";
+import type { Transform } from "@/rendering";
+import type { SceneTracks, VideoElement } from "@/timeline";
+import { applyElementUpdate } from "@/timeline/update-pipeline";
+import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm";
+
+function buildTransform(): Transform {
+ return {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ };
+}
+
+function buildVideoElement(overrides: Partial<VideoElement> = {}): VideoElement {
+ return {
+ id: "video-1",
+ type: "video",
+ name: "Video 1",
+ startTime: ZERO_MEDIA_TIME,
+ duration: mediaTime({ ticks: 10 }),
+ trimStart: ZERO_MEDIA_TIME,
+ trimEnd: ZERO_MEDIA_TIME,
+ mediaId: "media-1",
+ transform: buildTransform(),
+ opacity: 1,
+ ...overrides,
+ };
+}
+
+function buildTracks(element: VideoElement): SceneTracks {
+ return {
+ overlay: [],
+ main: {
+ id: "main-track",
+ type: "video",
+ name: "Main",
+ muted: false,
+ hidden: false,
+ elements: [element],
+ },
+ audio: [],
+ };
+}
+
+describe("applyElementUpdate", () => {
+ test("rounds retimed durations back to integer media time", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks(element);
+
+ const updatedElement = applyElementUpdate({
+ element,
+ patch: {
+ retime: { rate: 1.5 },
+ },
+ context: {
+ tracks,
+ trackId: tracks.main.id,
+ },
+ });
+
+ expect(updatedElement.duration).toBe(7);
+ expect(Number.isInteger(updatedElement.duration)).toBe(true);
+ });
+});
diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx index 20e22c57..0f0710cb 100644 --- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -26,6 +26,13 @@ import { Label } from "@/components/ui/label"; import { uppercase } from "@/utils/string"; import { clamp, formatNumberForDisplay } from "@/utils/math"; import { timelineTimeToPixels, timelineTimeToSnappedPixels } from "@/timeline"; +import { + type MediaTime, + mediaTimeFromSeconds, + mediaTimeToSeconds, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; const MIN_BOOKMARK_WIDTH_PX = 2; const BOOKMARK_MARKER_WIDTH_PX = 12; @@ -39,12 +46,12 @@ function seekToBookmarkTime({ time, }: { editor: EditorCore; - time: number; + time: MediaTime; }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; + const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime; editor.playback.seek({ time: snappedTime }); } @@ -137,7 +144,7 @@ function TimelineBookmark({ const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; - const bookmarkDuration = bookmark.duration ?? 0; + const bookmarkDuration = bookmark.duration ?? ZERO_MEDIA_TIME; const durationWidth = bookmarkDuration > 0 ? timelineTimeToPixels({ time: bookmarkDuration, zoomLevel }) @@ -182,7 +189,7 @@ function TimelineBookmark({ left: `${bookmarkLeft}px`, width: `${bookmarkWidth}px`, }} - aria-label={`Bookmark at ${formatNumberForDisplay({ value: time, fractionDigits: 1 })}s`} + aria-label={`Bookmark at ${formatNumberForDisplay({ value: mediaTimeToSeconds({ time }), fractionDigits: 1 })}s`} type="button" onMouseDown={handleMouseDown} onClick={handleClick} @@ -286,8 +293,8 @@ function BookmarkPopoverContent({ onPopoverClose, }: { bookmark: Bookmark; - time: number; - timelineDuration: number; + time: MediaTime; + timelineDuration: MediaTime; onPopoverClose: () => void; }) { const editor = useEditor(); @@ -314,9 +321,8 @@ function BookmarkPopoverContent({ note, color, duration, - }: Partial<{ note: string; color: string; duration: number }>) => { - const updates: Partial<{ note: string; color: string; duration: number }> = - {}; + }: Partial<Omit<Bookmark, "time">>) => { + const updates: Partial<Omit<Bookmark, "time">> = {}; if (note !== undefined && note !== bookmark.note) updates.note = note; if ( color !== undefined && @@ -330,6 +336,15 @@ function BookmarkPopoverContent({ if (Object.keys(updates).length === 0) return; editor.scenes.updateBookmark({ time, updates }); }; + const maxDuration = mediaTimeToSeconds({ + time: + timelineDuration > time + ? subMediaTime({ a: timelineDuration, b: time }) + : ZERO_MEDIA_TIME, + }); + const durationSeconds = mediaTimeToSeconds({ + time: bookmark.duration ?? ZERO_MEDIA_TIME, + }); return ( <> @@ -387,7 +402,7 @@ function BookmarkPopoverContent({ type="number" min={0} step={0.1} - value={bookmark.duration ?? 0} + value={durationSeconds} onChange={(event) => { const parsed = parseFloat(event.target.value); const value = Number.isNaN(parsed) @@ -395,9 +410,11 @@ function BookmarkPopoverContent({ : clamp({ value: parsed, min: 0, - max: Math.max(0, timelineDuration - time), + max: maxDuration, }); - handleUpdate({ duration: value }); + handleUpdate({ + duration: mediaTimeFromSeconds({ seconds: 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 index 1071c6dd..8996330b 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -21,15 +21,16 @@ 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"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export interface BookmarkDragState { isDragging: boolean; - bookmarkTime: number | null; - currentTime: number; + bookmarkTime: MediaTime | null; + currentTime: MediaTime; } interface PendingBookmarkDrag { - bookmarkTime: number; + bookmarkTime: MediaTime; startMouseX: number; startMouseY: number; } @@ -58,7 +59,7 @@ export function useBookmarkDrag({ const [dragState, setDragState] = useState<BookmarkDragState>({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); @@ -69,8 +70,8 @@ export function useBookmarkDrag({ bookmarkTime, initialCurrentTime, }: { - bookmarkTime: number; - initialCurrentTime: number; + bookmarkTime: MediaTime; + initialCurrentTime: MediaTime; }) => { setDragState({ isDragging: true, @@ -85,7 +86,7 @@ export function useBookmarkDrag({ setDragState({ isDragging: false, bookmarkTime: null, - currentTime: 0, + currentTime: ZERO_MEDIA_TIME, }); }, []); @@ -94,9 +95,9 @@ export function useBookmarkDrag({ rawTime, excludeBookmarkTime, }: { - rawTime: number; - excludeBookmarkTime: number; - }): { snappedTime: number; snapPoint: SnapPoint | null } => { + rawTime: MediaTime; + excludeBookmarkTime: MediaTime; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } => { const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (!shouldSnap) { return { snappedTime: rawTime, snapPoint: null }; @@ -116,7 +117,7 @@ export function useBookmarkDrag({ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), }); return { - snappedTime: result.snappedTime, + snappedTime: result.snappedTime as MediaTime, snapPoint: result.snapPoint, }; }, @@ -162,11 +163,14 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const frameSnappedTime = + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = ( roundToFrame({ - time: Math.max(0, Math.min(mouseTime, duration)), + time: clampedTime, rate: activeProject.settings.fps, - }) ?? Math.max(0, Math.min(mouseTime, duration)); + }) ?? clampedTime + ) as MediaTime; const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -193,10 +197,12 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = + const clampedTime = + mouseTime > duration ? duration : mouseTime; + const frameSnappedTime = ( roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? - clampedTime; + clampedTime + ) as MediaTime; const snapResult = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, @@ -234,10 +240,8 @@ export function useBookmarkDrag({ return; } - const clampedTime = Math.max( - 0, - Math.min(dragState.currentTime, duration), - ); + const clampedTime = + dragState.currentTime > duration ? duration : dragState.currentTime; editor.scenes.moveBookmark({ fromTime: dragState.bookmarkTime, diff --git a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx index 8e2b4bcf..2c254c84 100644 --- a/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx +++ b/apps/web/src/timeline/bookmarks/preview-overlay-source.tsx @@ -5,6 +5,7 @@ import { type PreviewOverlaySourceResult, } from "@/preview/overlays"; import { getBookmarksActiveAtTime } from "./utils"; +import type { MediaTime } from "@/wasm"; export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { id: "bookmark-notes", @@ -15,7 +16,7 @@ export const bookmarkNotesPreviewOverlay: PreviewOverlayDefinition = { function BookmarkNotesOverlay({ bookmarks, }: { - bookmarks: Array<{ time: number; note: string; color?: string }>; + bookmarks: Array<{ time: MediaTime; note: string; color?: string }>; }) { return ( <div className="flex flex-col gap-1.5" aria-live="polite"> @@ -43,7 +44,7 @@ export function getBookmarkPreviewOverlaySource({ isVisible, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; isVisible: boolean; }): PreviewOverlaySourceResult { const bookmarksWithNotes = getBookmarksActiveAtTime({ diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts index d05fe3a9..52e5aa60 100644 --- a/apps/web/src/timeline/bookmarks/utils.ts +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -1,13 +1,14 @@ import type { Bookmark } from "@/timeline"; import type { FrameRate } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm"; +import { addMediaTime, type MediaTime } from "@/wasm"; function bookmarkTimeEqual({ bookmarkTime, frameTime, }: { - bookmarkTime: number; - frameTime: number; + bookmarkTime: MediaTime; + frameTime: MediaTime; }): boolean { return bookmarkTime === frameTime; } @@ -17,7 +18,7 @@ export function findBookmarkIndex({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): number { return bookmarks.findIndex((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -29,7 +30,7 @@ export function isBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): boolean { return bookmarks.some((bookmark) => bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }), @@ -41,7 +42,7 @@ export function toggleBookmarkInArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime }); @@ -58,7 +59,7 @@ export function removeBookmarkFromArray({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark[] { return bookmarks.filter( (bookmark) => @@ -72,7 +73,7 @@ export function updateBookmarkInArray({ updates, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; updates: Partial<Omit<Bookmark, "time">>; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime }); @@ -92,8 +93,8 @@ export function moveBookmarkInArray({ toTime, }: { bookmarks: Bookmark[]; - fromTime: number; - toTime: number; + fromTime: MediaTime; + toTime: MediaTime; }): Bookmark[] { const index = findBookmarkIndex({ bookmarks, frameTime: fromTime }); if (index === -1) { @@ -110,10 +111,10 @@ export function getFrameTime({ time, fps, }: { - time: number; + time: MediaTime; fps: FrameRate; -}): number { - return roundToFrame({ time, rate: fps }) ?? time; +}): MediaTime { + return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime; } export function getBookmarkAtTime({ @@ -121,7 +122,7 @@ export function getBookmarkAtTime({ frameTime, }: { bookmarks: Bookmark[]; - frameTime: number; + frameTime: MediaTime; }): Bookmark | null { const index = findBookmarkIndex({ bookmarks, frameTime }); return index === -1 ? null : bookmarks[index]; @@ -132,13 +133,13 @@ export function getBookmarksActiveAtTime({ time, }: { bookmarks: Bookmark[]; - time: number; + time: MediaTime; }): Bookmark[] { return bookmarks.filter((bookmark) => { const start = bookmark.time; const end = bookmark.duration != null && bookmark.duration > 0 - ? start + bookmark.duration + ? addMediaTime({ a: start, b: bookmark.duration }) : start; return time >= start && time <= end; }); diff --git a/apps/web/src/timeline/components/drop-target.ts b/apps/web/src/timeline/components/drop-target.ts index be2495e4..f68a4b72 100644 --- a/apps/web/src/timeline/components/drop-target.ts +++ b/apps/web/src/timeline/components/drop-target.ts @@ -3,7 +3,12 @@ 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"; +import { + mediaTime, + type MediaTime, + roundMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; function findElementAtPosition({ mouseX, @@ -20,9 +25,11 @@ function findElementAtPosition({ pixelsPerSecond: number; zoomLevel: number; }): { elementId: string; trackId: string } | null { - const time = Math.round( - (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + const time = mediaTime({ + ticks: Math.round( + (mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, + ), + }); const track = tracks[trackIndex]; if (!track || !("elements" in track)) return null; @@ -82,7 +89,7 @@ const EMPTY_TARGET_ELEMENT = null; function fallbackNewTrackDropTarget({ xPosition, }: { - xPosition: number; + xPosition: MediaTime; }): DropTarget { return { trackIndex: 0, @@ -111,13 +118,16 @@ export function computeDropTarget({ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mainTrackIndex = tracks.overlay.length; const xPosition = - typeof startTimeOverride === "number" + startTimeOverride !== undefined ? startTimeOverride : isExternalDrop ? playheadTime - : Math.round( - Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * TICKS_PER_SECOND, - ); + : mediaTime({ + ticks: Math.round( + Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)) * + TICKS_PER_SECOND, + ), + }); if (orderedTracks.length === 0) { const placementResult = resolveTrackPlacement({ @@ -221,7 +231,10 @@ export function computeDropTarget({ } if (placementResult.kind === "existingTrack") { - const adjustedXPosition = placementResult.adjustedStartTime ?? xPosition; + const adjustedXPosition = + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ time: placementResult.adjustedStartTime }) + : xPosition; return { trackIndex: placementResult.trackIndex, diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index e6a9df49..5d61a0a9 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -29,6 +29,7 @@ import { useState, type ReactNode, } from "react"; +import type { MediaTime } from "@/wasm"; import type { ElementDragState, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; @@ -132,7 +133,7 @@ export function Timeline() { [scene], ); const mainTrackId = scene?.tracks.main.id ?? null; - const seek = (time: number) => editor.playback.seek({ time }); + const seek = (time: MediaTime) => editor.playback.seek({ time }); const timelineRef = useRef<HTMLDivElement>(null); const timelineHeaderRef = useRef<HTMLDivElement>(null); diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx index 74c34607..e0eeb378 100644 --- a/apps/web/src/timeline/components/timeline-element.tsx +++ b/apps/web/src/timeline/components/timeline-element.tsx @@ -49,7 +49,7 @@ import { import { buildWaveformGainSamples } from "@/timeline/audio-state"; import { getTimelinePixelsPerSecond } from "@/timeline"; import { buildWaveformSourceKey } from "@/media/waveform-summary"; -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { addMediaTime, TICKS_PER_SECOND, ZERO_MEDIA_TIME } from "@/wasm"; import { getActionDefinition, type TAction, @@ -257,10 +257,11 @@ export function TimelineElement({ isBeingDragged && dragState.isDragging ? dragState.currentMouseY - dragState.startMouseY : 0; - const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0; + const dragTimeOffset = + dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME; const elementStartTime = isBeingDragged && dragState.isDragging - ? dragState.currentTime + dragTimeOffset + ? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; diff --git a/apps/web/src/timeline/components/timeline-playhead.tsx b/apps/web/src/timeline/components/timeline-playhead.tsx index 4dd3329f..5b1e45ab 100644 --- a/apps/web/src/timeline/components/timeline-playhead.tsx +++ b/apps/web/src/timeline/components/timeline-playhead.tsx @@ -7,7 +7,14 @@ import { timelineTimeToSnappedPixels, } from "@/timeline"; import { useTimelinePlayhead } from "@/timeline/hooks/use-timeline-playhead"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + maxMediaTime, + mediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { useEditor } from "@/editor/use-editor"; import { TIMELINE_SCROLLBAR_SIZE_PX } from "./layout"; import { TIMELINE_LAYERS } from "./layers"; @@ -72,17 +79,24 @@ export function TimelinePlayhead({ event.preventDefault(); const fps = editor.project.getActive().settings.fps; - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); + const ticksPerFrame = mediaTime({ + ticks: 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), - ); + const nextTime = + direction > 0 + ? addMediaTime({ a: now, b: ticksPerFrame }) + : subMediaTime({ a: now, b: ticksPerFrame }); - editor.playback.seek({ time: nextTime }); + editor.playback.seek({ + time: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: duration < nextTime ? duration : nextTime, + }), + }); }; return ( diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index 2ac7f246..f3c93b8d 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,3 +1,5 @@ -import { TICKS_PER_SECOND } from "@/wasm/ticks"; +import { mediaTime, TICKS_PER_SECOND } from "@/wasm"; -export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; +export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({ + ticks: 5 * TICKS_PER_SECOND, +}); diff --git a/apps/web/src/timeline/defaults.ts b/apps/web/src/timeline/defaults.ts index 06639e01..3b47b4f4 100644 --- a/apps/web/src/timeline/defaults.ts +++ b/apps/web/src/timeline/defaults.ts @@ -1,6 +1,7 @@ import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; import type { TTimelineViewState } from "@/project/types"; import type { BlendMode, Transform } from "@/rendering"; +import { ZERO_MEDIA_TIME } from "@/wasm"; import type { TextElement } from "./types"; const defaultTransform: Transform = { @@ -42,9 +43,9 @@ const defaultTextElement: Omit<TextElement, "id"> = { letterSpacing: defaultTextLetterSpacing, lineHeight: defaultTextLineHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, + startTime: ZERO_MEDIA_TIME, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...defaultTransform, position: { ...defaultTransform.position }, @@ -55,7 +56,7 @@ const defaultTextElement: Omit<TextElement, "id"> = { const defaultTimelineViewState: TTimelineViewState = { zoomLevel: 1, scrollLeft: 0, - playheadTime: 0, + playheadTime: ZERO_MEDIA_TIME, }; export const DEFAULTS = { diff --git a/apps/web/src/timeline/drag-utils.ts b/apps/web/src/timeline/drag-utils.ts index a731d7a0..ad8d8001 100644 --- a/apps/web/src/timeline/drag-utils.ts +++ b/apps/web/src/timeline/drag-utils.ts @@ -1,5 +1,5 @@ import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; export function getMouseTimeFromClientX({ clientX, @@ -11,11 +11,13 @@ export function getMouseTimeFromClientX({ containerRect: DOMRect; zoomLevel: number; scrollLeft: number; -}): number { +}): MediaTime { 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); + return mediaTime({ + ticks: Math.round(seconds * TICKS_PER_SECOND), + }); } diff --git a/apps/web/src/timeline/element-utils.ts b/apps/web/src/timeline/element-utils.ts index 676bc823..cddbf6fd 100644 --- a/apps/web/src/timeline/element-utils.ts +++ b/apps/web/src/timeline/element-utils.ts @@ -29,6 +29,7 @@ import { buildDefaultEffectInstance } from "@/effects"; import { buildDefaultGraphicInstance } from "@/graphics"; import type { ParamValues } from "@/params"; import { capitalizeFirstLetter } from "@/utils/string"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export function canElementHaveAudio( element: TimelineElement, @@ -107,7 +108,7 @@ export function buildTextElement({ startTime, }: { raw: Partial<Omit<TextElement, "type" | "id">>; - startTime: number; + startTime: MediaTime; }): CreateTimelineElement { const t = raw as Partial<TextElement>; @@ -117,8 +118,8 @@ export function buildTextElement({ content: t.content ?? DEFAULTS.text.element.content, duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, color: t.color ?? DEFAULTS.text.element.color, @@ -141,8 +142,8 @@ export function buildEffectElement({ duration, }: { effectType: string; - startTime: number; - duration?: number; + startTime: MediaTime; + duration?: MediaTime; }): CreateEffectElement { const instance = buildDefaultEffectInstance({ effectType }); return { @@ -152,8 +153,8 @@ export function buildEffectElement({ params: instance.params, duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, }; } @@ -166,7 +167,7 @@ export function buildStickerElement({ }: { stickerId: string; name?: string; - startTime: number; + startTime: MediaTime; intrinsicWidth?: number; intrinsicHeight?: number; }): CreateStickerElement { @@ -180,8 +181,8 @@ export function buildStickerElement({ intrinsicHeight, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...DEFAULTS.element.transform, position: { ...DEFAULTS.element.transform.position }, @@ -199,7 +200,7 @@ export function buildGraphicElement({ }: { definitionId: string; name?: string; - startTime: number; + startTime: MediaTime; params?: Partial<ParamValues>; }): CreateGraphicElement { const instance = buildDefaultGraphicInstance({ definitionId }); @@ -210,8 +211,8 @@ export function buildGraphicElement({ params: { ...instance.params, ...(params ?? {}) } as ParamValues, duration: DEFAULT_NEW_ELEMENT_DURATION, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, transform: { ...DEFAULTS.element.transform, position: { ...DEFAULTS.element.transform.position }, @@ -229,8 +230,8 @@ function buildVideoElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateVideoElement { return { type: "video", @@ -238,8 +239,8 @@ function buildVideoElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, muted: false, isSourceAudioEnabled: true, @@ -262,8 +263,8 @@ function buildImageElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; }): CreateImageElement { return { type: "image", @@ -271,8 +272,8 @@ function buildImageElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, hidden: false, transform: { ...DEFAULTS.element.transform, @@ -292,8 +293,8 @@ function buildUploadAudioElement({ }: { mediaId: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateUploadAudioElement { const element: CreateUploadAudioElement = { @@ -303,8 +304,8 @@ function buildUploadAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, volume: DEFAULTS.element.volume, muted: false, @@ -326,8 +327,8 @@ export function buildElementFromMedia({ mediaId: string; mediaType: MediaType; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateTimelineElement { switch (mediaType) { @@ -355,8 +356,8 @@ export function buildLibraryAudioElement({ }: { sourceUrl: string; name: string; - duration: number; - startTime: number; + duration: MediaTime; + startTime: MediaTime; buffer?: AudioBuffer; }): CreateLibraryAudioElement { const element: CreateLibraryAudioElement = { @@ -366,8 +367,8 @@ export function buildLibraryAudioElement({ name, duration, startTime, - trimStart: 0, - trimEnd: 0, + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, sourceDuration: duration, volume: DEFAULTS.element.volume, muted: false, diff --git a/apps/web/src/timeline/group-move/build-group.ts b/apps/web/src/timeline/group-move/build-group.ts index 00f39d55..3e19e3e3 100644 --- a/apps/web/src/timeline/group-move/build-group.ts +++ b/apps/web/src/timeline/group-move/build-group.ts @@ -2,6 +2,7 @@ import type { ElementRef, SceneTracks } from "@/timeline"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; import type { GroupMember, MoveGroup } from "./types"; import { getTrackPlacementById } from "./track-placement"; +import { subMediaTime } from "@/wasm"; export function buildMoveGroup({ anchorRef, @@ -59,7 +60,10 @@ export function buildMoveGroup({ elementId: element.id, elementType: element.type, duration: element.duration, - timeOffset: element.startTime - anchorElement.startTime, + timeOffset: subMediaTime({ + a: element.startTime, + b: anchorElement.startTime, + }), trackSection: placement.section, sectionIndex: placement.sectionIndex, displayIndex: placement.displayIndex, diff --git a/apps/web/src/timeline/group-move/resolve-move.ts b/apps/web/src/timeline/group-move/resolve-move.ts index 60940315..8307aad0 100644 --- a/apps/web/src/timeline/group-move/resolve-move.ts +++ b/apps/web/src/timeline/group-move/resolve-move.ts @@ -12,6 +12,13 @@ import { getTrackPlacementByDisplayIndex, getTrackPlacementById, } from "./track-placement"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; type GroupMoveTarget = | { @@ -32,7 +39,7 @@ export function resolveGroupMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; target: GroupMoveTarget; }): GroupMoveResult | null { if (target.kind === "newTracks") { @@ -61,7 +68,7 @@ function resolveExistingTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorTargetTrackId: string; }): GroupMoveResult | null { const anchorTargetPlacement = getTrackPlacementById({ @@ -93,7 +100,10 @@ function resolveExistingTrackMove({ targetTrackId: targetTrackIdsByElementId.get(member.elementId) ?? member.trackId, elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); if (!canApplyMovesToExistingTracks({ tracks, moves })) { @@ -119,7 +129,7 @@ function resolveNewTrackMove({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; anchorInsertIndex: number; newTrackIds: string[]; }): GroupMoveResult | null { @@ -173,7 +183,10 @@ function resolveNewTrackMove({ sourceTrackId: member.trackId, targetTrackId: newTrackIds[memberIndex], elementId: member.elementId, - newStartTime: clampedAnchorStartTime + member.timeOffset, + newStartTime: addMediaTime({ + a: clampedAnchorStartTime, + b: member.timeOffset, + }), })); return { @@ -335,17 +348,26 @@ function clampAnchorStartTime({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; targetTrackIdsByElementId: Map<string, string>; -}): number { - const minimumAnchorStartTime = Math.max( - 0, - ...group.members.map((member) => -member.timeOffset), - ); - let clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - anchorStartTime, +}): MediaTime { + const minimumAnchorStartTime = group.members.reduce( + (minimumStartTime, member) => + member.timeOffset < ZERO_MEDIA_TIME + ? maxMediaTime({ + a: minimumStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: member.timeOffset, + }), + }) + : minimumStartTime, + ZERO_MEDIA_TIME, ); + let clampedAnchorStartTime = + anchorStartTime < minimumAnchorStartTime + ? minimumAnchorStartTime + : anchorStartTime; const memberOnMainTrack = group.members.find( (member) => @@ -358,11 +380,13 @@ function clampAnchorStartTime({ const movingElementIds = new Set( group.members.map((member) => member.elementId), ); - const requestedMainStartTime = - clampedAnchorStartTime + memberOnMainTrack.timeOffset; + const requestedMainStartTime = addMediaTime({ + a: clampedAnchorStartTime, + b: memberOnMainTrack.timeOffset, + }); const earliestStationaryMainStartTime = tracks.main.elements .filter((element) => !movingElementIds.has(element.id)) - .reduce<number | null>((earliestStartTime, element) => { + .reduce<MediaTime | null>((earliestStartTime, element) => { if (earliestStartTime == null || element.startTime < earliestStartTime) { return element.startTime; } @@ -373,10 +397,13 @@ function clampAnchorStartTime({ earliestStationaryMainStartTime == null || requestedMainStartTime <= earliestStationaryMainStartTime ) { - clampedAnchorStartTime = Math.max( - minimumAnchorStartTime, - -memberOnMainTrack.timeOffset, - ); + clampedAnchorStartTime = maxMediaTime({ + a: minimumAnchorStartTime, + b: subMediaTime({ + a: ZERO_MEDIA_TIME, + b: memberOnMainTrack.timeOffset, + }), + }); } return clampedAnchorStartTime; @@ -422,7 +449,7 @@ function canApplyMovesToExistingTracks({ const sourceElement = sourceElements.get(move.elementId); return { startTime: move.newStartTime, - duration: sourceElement?.duration ?? 0, + duration: sourceElement?.duration ?? ZERO_MEDIA_TIME, }; }); if (hasOverlappingTimeSpans({ timeSpans })) { diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index 32c0a2f1..aa06a35b 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -9,6 +9,7 @@ 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"; +import { addMediaTime, type MediaTime, subMediaTime } from "@/wasm"; export function snapGroupEdges({ group, @@ -18,12 +19,12 @@ export function snapGroupEdges({ zoomLevel, }: { group: MoveGroup; - anchorStartTime: number; + anchorStartTime: MediaTime; tracks: SceneTracks; - playheadTime: number; + playheadTime: MediaTime; zoomLevel: number; }): { - snappedAnchorStartTime: number; + snappedAnchorStartTime: MediaTime; snapPoint: SnapPoint | null; } { const excludeElementIds = new Set( @@ -47,7 +48,10 @@ export function snapGroupEdges({ let snapPoint: SnapPoint | null = null; for (const member of group.members) { - const memberStartTime = anchorStartTime + member.timeOffset; + const memberStartTime = addMediaTime({ + a: anchorStartTime, + b: member.timeOffset, + }); const memberStartSnap = resolveTimelineSnap({ targetTime: memberStartTime, snapPoints, @@ -58,12 +62,18 @@ export function snapGroupEdges({ memberStartSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberStartSnap.snapDistance; - snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: memberStartSnap.snappedTime as MediaTime, + b: member.timeOffset, + }); snapPoint = memberStartSnap.snapPoint; } const memberEndSnap = resolveTimelineSnap({ - targetTime: memberStartTime + member.duration, + targetTime: addMediaTime({ + a: memberStartTime, + b: member.duration, + }), snapPoints, maxSnapDistance, }); @@ -72,8 +82,13 @@ export function snapGroupEdges({ memberEndSnap.snapDistance < closestSnapDistance ) { closestSnapDistance = memberEndSnap.snapDistance; - snappedAnchorStartTime = - memberEndSnap.snappedTime - member.duration - member.timeOffset; + snappedAnchorStartTime = subMediaTime({ + a: subMediaTime({ + a: memberEndSnap.snappedTime as MediaTime, + b: member.duration, + }), + b: member.timeOffset, + }); snapPoint = memberEndSnap.snapPoint; } } diff --git a/apps/web/src/timeline/group-move/types.ts b/apps/web/src/timeline/group-move/types.ts index daf9c6e9..f97b698c 100644 --- a/apps/web/src/timeline/group-move/types.ts +++ b/apps/web/src/timeline/group-move/types.ts @@ -1,11 +1,12 @@ import type { ElementRef, ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export type GroupTrackSection = "overlay" | "main" | "audio"; export interface GroupMember extends ElementRef { elementType: ElementType; - duration: number; - timeOffset: number; + duration: MediaTime; + timeOffset: MediaTime; trackSection: GroupTrackSection; sectionIndex: number; displayIndex: number; @@ -26,7 +27,7 @@ export interface PlannedElementMove { sourceTrackId: string; targetTrackId: string; elementId: string; - newStartTime: number; + newStartTime: MediaTime; } export interface GroupMoveResult { diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index dd542967..bbe829cc 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -3,7 +3,7 @@ import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/retime"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm"; import type { ComputeGroupResizeArgs, GroupResizeMember, @@ -188,15 +188,17 @@ function getSourceDeltaForClipDelta({ return clipDelta; } - return clipDelta >= 0 - ? getSourceSpanAtClipTime({ - clipTime: clipDelta, - retime: member.retime, - }) - : -getSourceSpanAtClipTime({ - clipTime: Math.abs(clipDelta), - retime: member.retime, - }); + const sourceDelta = + clipDelta >= 0 + ? getSourceSpanAtClipTime({ + clipTime: clipDelta, + retime: member.retime, + }) + : -getSourceSpanAtClipTime({ + clipTime: Math.abs(clipDelta), + retime: member.retime, + }); + return roundMediaTime({ time: sourceDelta }); } function getVisibleSourceSpanForDuration({ @@ -227,9 +229,11 @@ function getDurationForVisibleSourceSpan({ return sourceSpan; } - return getTimelineDurationForSourceSpan({ - sourceSpan, - retime: member.retime, + return roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan, + retime: member.retime, + }), }); } diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 621259b9..9caca28a 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -17,7 +17,14 @@ import { type MoveGroup, } from "@/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + addMediaTime, + type MediaTime, + mediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { roundToFrame } from "opencut-wasm"; import { computeDropTarget } from "@/timeline/components/drop-target"; @@ -54,9 +61,9 @@ const initialDragState: ElementDragState = { trackId: null, startMouseX: 0, startMouseY: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, + startElementTime: ZERO_MEDIA_TIME, + clickOffsetTime: ZERO_MEDIA_TIME, + currentTime: ZERO_MEDIA_TIME, currentMouseY: 0, }; @@ -66,8 +73,8 @@ interface PendingDragState { selectedElements: ElementRef[]; startMouseX: number; startMouseY: number; - startElementTime: number; - clickOffsetTime: number; + startElementTime: MediaTime; + clickOffsetTime: MediaTime; } function getClickOffsetTime({ @@ -78,10 +85,12 @@ function getClickOffsetTime({ clientX: number; elementRect: DOMRect; zoomLevel: number; -}): number { +}): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ + ticks: Math.round(seconds * TICKS_PER_SECOND), + }); } function getVerticalDragDirection({ @@ -118,7 +127,7 @@ function getDragDropTarget({ tracksScrollRef: RefObject<HTMLDivElement | null>; headerRef?: RefObject<HTMLElement | null>; zoomLevel: number; - snappedTime: number; + snappedTime: MediaTime; verticalDragDirection?: "up" | "down" | null; }): DropTarget | null { const containerRect = tracksContainerRef.current?.getBoundingClientRect(); @@ -162,7 +171,7 @@ interface StartDragParams ElementDragState, "isDragging" | "currentTime" | "currentMouseY" > { - initialCurrentTime: number; + initialCurrentTime: MediaTime; initialCurrentMouseY: number; } @@ -249,7 +258,7 @@ export function useElementInteraction({ dropTarget, }: { group: MoveGroup; - snappedTime: number; + snappedTime: MediaTime; dropTarget: DropTarget | null; }): GroupMoveResult | null => { if (!dropTarget) { @@ -317,7 +326,7 @@ export function useElementInteraction({ frameSnappedTime, group, }: { - frameSnappedTime: number; + frameSnappedTime: MediaTime; group: MoveGroup | null; }) => { if (!group || !snappingEnabled || isShiftHeldRef.current) { @@ -366,15 +375,19 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = + const adjustedTime = + mouseTime > pendingDragRef.current.clickOffsetTime + ? subMediaTime({ + a: mouseTime, + b: pendingDragRef.current.clickOffsetTime, + }) + : ZERO_MEDIA_TIME; + const snappedTime = ( roundToFrame({ time: adjustedTime, rate: activeProject.settings.fps, - }) ?? adjustedTime; + }) ?? adjustedTime + ) as MediaTime; const moveGroup = buildMoveGroup({ anchorRef: { trackId: pendingDragRef.current.trackId, @@ -389,7 +402,7 @@ export function useElementInteraction({ moveGroupRef.current = moveGroup; newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record<string, number> = {}; + const dragTimeOffsets: Record<string, MediaTime> = {}; for (const member of moveGroup.members) { dragTimeOffsets[member.elementId] = member.timeOffset; } @@ -478,10 +491,17 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const adjustedTime = + mouseTime > dragState.clickOffsetTime + ? subMediaTime({ + a: mouseTime, + b: dragState.clickOffsetTime, + }) + : ZERO_MEDIA_TIME; const fps = activeProject.settings.fps; - const frameSnappedTime = - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; + const frameSnappedTime = ( + roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime + ) as MediaTime; const moveGroup = moveGroupRef.current; const { snappedTime, snapPoint } = getDragSnapResult({ @@ -596,8 +616,10 @@ export function useElementInteraction({ const currentMember = moveGroup.members.find( (member) => member.elementId === move.elementId, ); - const originalStartTime = - dragState.startElementTime + (currentMember?.timeOffset ?? 0); + const originalStartTime = addMediaTime({ + a: dragState.startElementTime, + b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME, + }); return ( currentMember?.trackId !== move.targetTrackId || originalStartTime !== move.newStartTime diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index 92b6f0d8..afead4e9 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -11,7 +11,15 @@ 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 { + addMediaTime, + type MediaTime, + maxMediaTime, + mediaTime, + minMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; import { BatchCommand } from "@/commands"; @@ -22,13 +30,13 @@ import { registerCanceller } from "@/editor/cancel-interaction"; export interface KeyframeDragState { isDragging: boolean; draggingKeyframeIds: Set<string>; - deltaTime: number; + deltaTime: MediaTime; } const initialDragState: KeyframeDragState = { isDragging: false, draggingKeyframeIds: new Set(), - deltaTime: 0, + deltaTime: ZERO_MEDIA_TIME, }; interface PendingKeyframeDrag { @@ -43,7 +51,7 @@ export function useKeyframeDrag({ }: { zoomLevel: number; element: TimelineElement; - displayedStartTime: number; + displayedStartTime: MediaTime; }) { const editor = useEditor(); const { @@ -83,7 +91,7 @@ export function useKeyframeDrag({ deltaTime, }: { keyframeRefs: SelectedKeyframeRef[]; - deltaTime: number; + deltaTime: MediaTime; }) => { const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { const keyframe = getKeyframeById({ @@ -92,10 +100,13 @@ export function useKeyframeDrag({ keyframeId: keyframeRef.keyframeId, }); if (!keyframe) return []; - const nextTime = Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTime), - ); + const nextTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ + a: element.duration, + b: addMediaTime({ a: keyframe.time, b: deltaTime }), + }), + }); return [ new RetimeKeyframeCommand({ trackId: keyframeRef.trackId, @@ -138,7 +149,7 @@ export function useKeyframeDrag({ draggingKeyframeIds: new Set( pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), ), - deltaTime: 0, + deltaTime: ZERO_MEDIA_TIME, }); return; } @@ -146,11 +157,14 @@ export function useKeyframeDrag({ 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; + const rawDelta = mediaTime({ + ticks: Math.round( + ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, + ), + }); + const snappedDelta = ( + roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta + ) as MediaTime; setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); }; @@ -246,7 +260,7 @@ export function useKeyframeDrag({ event: ReactMouseEvent; keyframes: SelectedKeyframeRef[]; orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; + indicatorTime: MediaTime; }) => { event.stopPropagation(); @@ -259,12 +273,17 @@ export function useKeyframeDrag({ if (wasDrag) return; const duration = editor.timeline.getTotalDuration(); - const seekTime = + const absoluteIndicatorTime = addMediaTime({ + a: displayedStartTime, + b: indicatorTime, + }); + const seekTime = ( snappedSeekTime({ - time: displayedStartTime + indicatorTime, + time: absoluteIndicatorTime, duration, rate: fps, - }) ?? displayedStartTime + indicatorTime; + }) ?? absoluteIndicatorTime + ) as MediaTime; editor.playback.seek({ time: seekTime }); if (event.shiftKey) { diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 775b7337..5a892ccf 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -4,7 +4,7 @@ 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 { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; import { roundToFrame } from "opencut-wasm"; import { @@ -45,9 +45,9 @@ export function useTimelineDragDrop({ const [dragElementType, setElementType] = useState<ElementType | null>(null); const getSnappedTime = useCallback( - ({ time }: { time: number }) => { + ({ time }: { time: MediaTime }) => { const projectFps = editor.project.getActive().settings.fps; - return roundToFrame({ time, rate: projectFps }) ?? time; + return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime; }, [editor], ); @@ -76,7 +76,7 @@ export function useTimelineDragDrop({ }: { elementType: ElementType; mediaId?: string; - }): number => { + }): MediaTime => { if ( elementType === "text" || elementType === "graphic" || @@ -89,7 +89,7 @@ export function useTimelineDragDrop({ const mediaAssets = editor.media.getAssets(); const media = mediaAssets.find((m) => m.id === mediaId); return media?.duration != null - ? Math.round(media.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: media.duration }) : DEFAULT_NEW_ELEMENT_DURATION; } return DEFAULT_NEW_ELEMENT_DURATION; @@ -346,7 +346,7 @@ export function useTimelineDragDrop({ const duration = mediaAsset.duration != null - ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: mediaAsset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: mediaAsset.id, @@ -470,7 +470,7 @@ export function useTimelineDragDrop({ const duration = createdAsset.duration != null - ? Math.round(createdAsset.duration * TICKS_PER_SECOND) + ? mediaTimeFromSeconds({ seconds: createdAsset.duration }) : DEFAULT_NEW_ELEMENT_DURATION; const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 7d9cf447..6fbf2147 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,5 +1,5 @@ import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, type MediaTime, 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"; @@ -53,11 +53,11 @@ export function useTimelinePlayhead({ }, [zoomLevel, duration, isScrubbing, editor.playback]); const seek = useCallback( - ({ time }: { time: number }) => editor.playback.seek({ time }), + ({ time }: { time: MediaTime }) => editor.playback.seek({ time }), [editor.playback], ); - const scrubTimeRef = useRef<number | null>(null); + const scrubTimeRef = useRef<MediaTime | null>(null); const isDraggingRulerRef = useRef(false); const hasDraggedRulerRef = useRef(false); const lastMouseXRef = useRef<number>(0); @@ -92,11 +92,14 @@ export function useTimelinePlayhead({ clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ), ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + const rawTime = mediaTime({ + ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), + }); const rate = activeProject.settings.fps; - const frameTime = - snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; + const frameTime = ( + snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime + ) as MediaTime; const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const time = (() => { @@ -224,7 +227,7 @@ export function useTimelinePlayhead({ }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); const updatePlayheadLeft = useCallback( - (time: number) => { + (time: MediaTime) => { const playheadEl = playheadRef?.current; if (!playheadEl) return; const centerPosition = timelineTimeToSnappedPixels({ @@ -251,8 +254,7 @@ export function useTimelinePlayhead({ }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); useEffect(() => { - const handlePlaybackUpdate = (e: Event) => { - const time = (e as CustomEvent<{ time: number }>).detail.time; + const handlePlaybackTime = (time: MediaTime) => { updatePlayheadLeft(time); if (!isPlayingRef.current || isScrubbingRef.current) return; @@ -280,11 +282,12 @@ export function useTimelinePlayhead({ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; } }; + const handlePlaybackUpdate = (e: Event) => { + handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time); + }; const initialTime = editor.playback.getCurrentTime(); - handlePlaybackUpdate({ - detail: { time: initialTime }, - } as CustomEvent<{ time: number }>); + handlePlaybackTime(initialTime); window.addEventListener("playback-update", handlePlaybackUpdate); window.addEventListener("playback-seek", handlePlaybackUpdate); diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index fb8da291..accda4a1 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -139,7 +139,7 @@ export function useTimelineResize({ updates: result.updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - updates: patch, + updates: patch as Partial<TimelineElement>, })), }); }; @@ -155,7 +155,7 @@ export function useTimelineResize({ updates: result.updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, - patch, + patch: patch as Partial<TimelineElement>, })), }); } diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 889177c9..88a8268d 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -2,7 +2,7 @@ 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 { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { useEditor } from "@/editor/use-editor"; interface UseTimelineSeekProps { @@ -11,10 +11,10 @@ interface UseTimelineSeekProps { rulerScrollRef: RefObject<HTMLDivElement | null>; tracksScrollRef: RefObject<HTMLDivElement | null>; zoomLevel: number; - duration: number; + duration: MediaTime; isSelecting: boolean; clearSelectedElements: () => void; - seek: (time: number) => void; + seek: (time: MediaTime) => void; } function resetMouseTracking({ @@ -135,11 +135,13 @@ export function useTimelineSeek({ (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), ), ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); + const rawTime = mediaTime({ + ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), + }); const rate = activeProject?.settings.fps; const time = rate - ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) + ? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime) : rawTime; seek(time); editor.project.setTimelineViewState({ diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 7929d495..0f75face 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -12,13 +12,14 @@ 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"; +import type { MediaTime } from "@/wasm"; interface UseTimelineZoomProps { containerRef: RefObject<HTMLDivElement | null>; minZoom?: number; initialZoom?: number; initialScrollLeft?: number; - initialPlayheadTime?: number; + initialPlayheadTime?: MediaTime; tracksScrollRef: RefObject<HTMLDivElement | null>; rulerScrollRef: RefObject<HTMLDivElement | null>; } @@ -106,7 +107,7 @@ export function useTimelineZoom({ setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); return; } - setZoomLevel((prev) => { + setZoomLevel((prev: number) => { if (prev < minZoom) { return minZoom; } @@ -116,7 +117,7 @@ export function useTimelineZoom({ const wrappedSetZoomLevel = useCallback( (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev) => { + setZoomLevel((prev: number) => { const nextZoom = typeof zoomLevelOrUpdater === "function" ? zoomLevelOrUpdater(prev) diff --git a/apps/web/src/timeline/index.ts b/apps/web/src/timeline/index.ts index 5ee079b1..3775e006 100644 --- a/apps/web/src/timeline/index.ts +++ b/apps/web/src/timeline/index.ts @@ -1,29 +1,32 @@ -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);
-}
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +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; +}): MediaTime { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (orderedTracks.length === 0) return ZERO_MEDIA_TIME; + + let maxEnd: MediaTime = ZERO_MEDIA_TIME; + for (const track of orderedTracks) { + for (const element of track.elements) { + // `startTime + duration` is integer-by-construction (both `MediaTime`), + // so the cast re-establishes the brand without runtime work. + const elementEnd = (element.startTime + element.duration) as MediaTime; + if (elementEnd > maxEnd) maxEnd = elementEnd; + } + } + return maxEnd; +} diff --git a/apps/web/src/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/timeline/placement/__tests__/resolve.test.ts index ebbfe578..44f0a1fe 100644 --- a/apps/web/src/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/timeline/placement/__tests__/resolve.test.ts @@ -14,6 +14,7 @@ import type { } from "@/timeline"; import type { Transform } from "@/rendering"; import { resolveTrackPlacement } from "@/timeline/placement"; +import { mediaTime, ZERO_MEDIA_TIME } from "@/wasm"; function buildTransform(): Transform { return { @@ -67,10 +68,10 @@ function buildElement({ id, type: "audio", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, volume: 1, sourceType: "upload", mediaId: `media-${id}`, @@ -80,10 +81,10 @@ function buildElement({ id, type: "graphic", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, definitionId: `graphic-${id}`, params: {}, transform: buildTransform(), @@ -94,10 +95,10 @@ function buildElement({ id, type: "text", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, content: id, fontSize: 32, fontFamily: "sans-serif", @@ -118,10 +119,10 @@ function buildElement({ id, type: "video", name: id, - startTime, - duration, - trimStart: 0, - trimEnd: 0, + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + trimStart: ZERO_MEDIA_TIME, + trimEnd: ZERO_MEDIA_TIME, mediaId: `media-${id}`, transform: buildTransform(), opacity: 1, @@ -224,7 +225,11 @@ function buildTimeSpan({ duration: number; excludeElementId?: string; }) { - return { startTime, duration, excludeElementId }; + return { + startTime: mediaTime({ ticks: startTime }), + duration: mediaTime({ ticks: duration }), + excludeElementId, + }; } function buildSceneTracks({ diff --git a/apps/web/src/timeline/placement/main-track.ts b/apps/web/src/timeline/placement/main-track.ts index ab2ab7bd..36632226 100644 --- a/apps/web/src/timeline/placement/main-track.ts +++ b/apps/web/src/timeline/placement/main-track.ts @@ -1,4 +1,5 @@ import type { SceneTracks, TimelineElement, VideoTrack } from "@/timeline"; +import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export const MAIN_TRACK_NAME = "Main Track"; @@ -31,9 +32,9 @@ export function enforceMainTrackStart({ }: { tracks: SceneTracks; targetTrackId: string; - requestedStartTime: number; + requestedStartTime: MediaTime; excludeElementId?: string; -}): number { +}): MediaTime { if (tracks.main.id !== targetTrackId) { return requestedStartTime; } @@ -43,11 +44,11 @@ export function enforceMainTrackStart({ excludeElementId, }); if (!earliestElement) { - return 0; + return ZERO_MEDIA_TIME; } if (requestedStartTime <= earliestElement.startTime) { - return 0; + return ZERO_MEDIA_TIME; } return requestedStartTime; diff --git a/apps/web/src/timeline/placement/resolve.ts b/apps/web/src/timeline/placement/resolve.ts index 26275392..680cb0bb 100644 --- a/apps/web/src/timeline/placement/resolve.ts +++ b/apps/web/src/timeline/placement/resolve.ts @@ -13,6 +13,7 @@ import type { PlacementSubject, PlacementTimeSpan, } from "./types"; +import { ZERO_MEDIA_TIME } from "@/wasm"; type ResolveTrackPlacementParams = PlacementSubject & { tracks: SceneTracks; @@ -32,7 +33,7 @@ function buildExistingTrackResult({ timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; - const requestedStartTime = firstSpan?.startTime ?? 0; + const requestedStartTime = firstSpan?.startTime ?? ZERO_MEDIA_TIME; const adjustedStartTime = enforceMainTrackStart({ tracks, targetTrackId: track.id, diff --git a/apps/web/src/timeline/placement/types.ts b/apps/web/src/timeline/placement/types.ts index 6847ab36..748f34d7 100644 --- a/apps/web/src/timeline/placement/types.ts +++ b/apps/web/src/timeline/placement/types.ts @@ -1,8 +1,9 @@ import type { ElementType, TrackType } from "@/timeline"; +import type { MediaTime } from "@/wasm"; export interface PlacementTimeSpan { - startTime: number; - duration: number; + startTime: MediaTime; + duration: MediaTime; excludeElementId?: string; } @@ -29,7 +30,7 @@ export type PlacementResult = trackId: string; trackIndex: number; trackType: TrackType; - adjustedStartTime?: number; + adjustedStartTime?: MediaTime; } | { kind: "newTrack"; diff --git a/apps/web/src/timeline/scenes.ts b/apps/web/src/timeline/scenes.ts index d48c943c..8aa1b6b5 100644 --- a/apps/web/src/timeline/scenes.ts +++ b/apps/web/src/timeline/scenes.ts @@ -2,6 +2,7 @@ import type { TScene } from "@/timeline"; import { generateUUID } from "@/utils/id";
import { calculateTotalDuration } from "@/timeline";
import { MAIN_TRACK_NAME } from "@/timeline/placement/main-track";
+import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null {
return scenes.find((scene) => scene.isMain) || null;
@@ -89,10 +90,10 @@ export function getProjectDurationFromScenes({ scenes,
}: {
scenes: TScene[];
-}): number {
+}): MediaTime {
const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null;
if (!mainScene?.tracks) {
- return 0;
+ return ZERO_MEDIA_TIME;
}
return calculateTotalDuration({ tracks: mainScene.tracks });
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index cd543cdf..2a30a065 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -3,6 +3,7 @@ import type { Effect } from "@/effects/types"; import type { Mask } from "@/masks/types";
import type { ParamValues } from "@/params";
import type { BlendMode, Transform } from "@/rendering";
+import type { MediaTime } from "@/wasm";
export type ElementRef = {
trackId: string;
@@ -10,10 +11,10 @@ export type ElementRef = { };
export interface Bookmark {
- time: number;
+ time: MediaTime;
note?: string;
color?: string;
- duration?: number;
+ duration?: MediaTime;
}
export interface TScene {
@@ -107,11 +108,11 @@ export type AudioElement = UploadAudioElement | LibraryAudioElement; interface BaseTimelineElement {
id: string;
name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
+ duration: MediaTime;
+ startTime: MediaTime;
+ trimStart: MediaTime;
+ trimEnd: MediaTime;
+ sourceDuration?: MediaTime;
animations?: ElementAnimations;
}
@@ -273,13 +274,13 @@ export interface ElementDragState { isDragging: boolean;
elementId: string | null;
dragElementIds: string[];
- dragTimeOffsets: Record<string, number>;
+ dragTimeOffsets: Record<string, MediaTime>;
trackId: string | null;
startMouseX: number;
startMouseY: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
+ startElementTime: MediaTime;
+ clickOffsetTime: MediaTime;
+ currentTime: MediaTime;
currentMouseY: number;
}
@@ -287,7 +288,7 @@ export interface DropTarget { trackIndex: number;
isNewTrack: boolean;
insertPosition: "above" | "below" | null;
- xPosition: number;
+ xPosition: MediaTime;
targetElement: { elementId: string; trackId: string } | null;
}
@@ -296,13 +297,13 @@ export interface ComputeDropTargetParams { mouseX: number;
mouseY: number;
tracks: SceneTracks;
- playheadTime: number;
+ playheadTime: MediaTime;
isExternalDrop: boolean;
- elementDuration: number;
+ elementDuration: MediaTime;
pixelsPerSecond: number;
zoomLevel: number;
verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: number;
+ startTimeOverride?: MediaTime;
excludeElementId?: string;
targetElementTypes?: string[];
}
diff --git a/apps/web/src/timeline/update-pipeline.ts b/apps/web/src/timeline/update-pipeline.ts index beacf897..7ab129e9 100644 --- a/apps/web/src/timeline/update-pipeline.ts +++ b/apps/web/src/timeline/update-pipeline.ts @@ -6,6 +6,7 @@ import { } from "@/retime"; import type { RetimeConfig, SceneTracks, TimelineElement } from "@/timeline"; import { isRetimableElement } from "@/timeline"; +import { ZERO_MEDIA_TIME, roundMediaTime } from "@/wasm"; type ElementUpdateField = keyof TimelineElement | string; @@ -61,9 +62,11 @@ const deriveRules: ElementUpdateRule[] = [ 0, sourceDuration - element.trimStart - element.trimEnd, ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, + const nextDuration = roundMediaTime({ + time: getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }), }); return { @@ -94,7 +97,10 @@ const enforceRules: ElementUpdateRule[] = [ { triggers: ["startTime"], apply: ({ element, context }) => { - const requestedStartTime = Math.max(0, element.startTime); + const requestedStartTime = + element.startTime < ZERO_MEDIA_TIME + ? ZERO_MEDIA_TIME + : element.startTime; if (context.trackId !== context.tracks.main.id) { return { element: { @@ -118,7 +124,7 @@ const enforceRules: ElementUpdateRule[] = [ ...element, startTime: !earliestElement || requestedStartTime <= earliestElement.startTime - ? 0 + ? ZERO_MEDIA_TIME : requestedStartTime, }, }; |
