summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline')
-rw-r--r--apps/web/src/timeline/__tests__/update-pipeline.test.ts66
-rw-r--r--apps/web/src/timeline/bookmarks/components/bookmarks.tsx41
-rw-r--r--apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts46
-rw-r--r--apps/web/src/timeline/bookmarks/preview-overlay-source.tsx5
-rw-r--r--apps/web/src/timeline/bookmarks/utils.ts31
-rw-r--r--apps/web/src/timeline/components/drop-target.ts33
-rw-r--r--apps/web/src/timeline/components/index.tsx3
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx7
-rw-r--r--apps/web/src/timeline/components/timeline-playhead.tsx32
-rw-r--r--apps/web/src/timeline/creation.ts6
-rw-r--r--apps/web/src/timeline/defaults.ts9
-rw-r--r--apps/web/src/timeline/drag-utils.ts8
-rw-r--r--apps/web/src/timeline/element-utils.ts63
-rw-r--r--apps/web/src/timeline/group-move/build-group.ts6
-rw-r--r--apps/web/src/timeline/group-move/resolve-move.ts71
-rw-r--r--apps/web/src/timeline/group-move/snap.ts31
-rw-r--r--apps/web/src/timeline/group-move/types.ts7
-rw-r--r--apps/web/src/timeline/group-resize/compute-resize.ts30
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts70
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts57
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts14
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts27
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts4
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts12
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts7
-rw-r--r--apps/web/src/timeline/index.ts61
-rw-r--r--apps/web/src/timeline/placement/__tests__/resolve.test.ts39
-rw-r--r--apps/web/src/timeline/placement/main-track.ts9
-rw-r--r--apps/web/src/timeline/placement/resolve.ts3
-rw-r--r--apps/web/src/timeline/placement/types.ts7
-rw-r--r--apps/web/src/timeline/scenes.ts5
-rw-r--r--apps/web/src/timeline/types.ts31
-rw-r--r--apps/web/src/timeline/update-pipeline.ts16
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,
},
};