summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-07 01:09:13 +0200
commite4b67094e757917588fb624a30aa046667403add (patch)
tree55452692eac69a5a63b58f2a5cdb701dd7023d68 /apps/web/src/lib
parent79df73643185c02493364a077402726d1fa7b9ec (diff)
downloadopencut-e4b67094e757917588fb624a30aa046667403add.tar.gz
refactor: split effects and masks into dedicated rust crates, introduce MediaTime and FrameRate
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/animation/curve-bridge.ts5
-rw-r--r--apps/web/src/lib/animation/interpolation.ts26
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts8
-rw-r--r--apps/web/src/lib/animation/keyframes.ts7
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts12
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts529
-rw-r--r--apps/web/src/lib/export.ts3
-rw-r--r--apps/web/src/lib/fps/__tests__/fps.test.ts8
-rw-r--r--apps/web/src/lib/fps/constants.ts4
-rw-r--r--apps/web/src/lib/fps/utils.ts64
-rw-r--r--apps/web/src/lib/media/audio.ts57
-rw-r--r--apps/web/src/lib/project/types.ts107
-rw-r--r--apps/web/src/lib/ripple/diff.ts20
-rw-r--r--apps/web/src/lib/timeline/audio-state.ts202
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts14
-rw-r--r--apps/web/src/lib/timeline/creation.ts4
-rw-r--r--apps/web/src/lib/timeline/defaults.ts154
-rw-r--r--apps/web/src/lib/timeline/drag-utils.ts40
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts836
-rw-r--r--apps/web/src/lib/timeline/pixel-utils.ts159
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts17
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts344
-rw-r--r--apps/web/src/lib/timeline/update-pipeline.ts2
-rw-r--r--apps/web/src/lib/timeline/zoom-utils.ts5
-rw-r--r--apps/web/src/lib/wasm/constants.ts3
-rw-r--r--apps/web/src/lib/wasm/index.ts1
26 files changed, 1348 insertions, 1283 deletions
diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts
index 55f96933..12103c3f 100644
--- a/apps/web/src/lib/animation/curve-bridge.ts
+++ b/apps/web/src/lib/animation/curve-bridge.ts
@@ -1,4 +1,3 @@
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import {
getDefaultLeftHandle,
getDefaultRightHandle,
@@ -25,7 +24,7 @@ export function getNormalizedCubicBezierForScalarSegment({
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
if (
- Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
+ spanTime === 0 ||
Math.abs(spanValue) <= VALUE_EPSILON
) {
return null;
@@ -59,7 +58,7 @@ export function getCurveHandlesForNormalizedCubicBezier({
const spanTime = rightKey.time - leftKey.time;
const spanValue = rightKey.value - leftKey.value;
if (
- Math.abs(spanTime) <= TIME_EPSILON_SECONDS ||
+ spanTime === 0 ||
Math.abs(spanValue) <= VALUE_EPSILON
) {
return null;
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
index 67456aa0..1a3c0fed 100644
--- a/apps/web/src/lib/animation/interpolation.ts
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -8,7 +8,6 @@ import type {
ScalarAnimationKey,
ScalarSegmentType,
} from "@/lib/animation/types";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { clamp } from "@/utils/math";
import {
getBezierPoint,
@@ -36,10 +35,7 @@ function isWithinTimePair({
leftTime: number;
rightTime: number;
}): boolean {
- return (
- time >= leftTime - TIME_EPSILON_SECONDS &&
- time <= rightTime + TIME_EPSILON_SECONDS
- );
+ return time >= leftTime && time <= rightTime;
}
function lerpNumber({
@@ -67,7 +63,7 @@ function normalizeRightHandle({
return undefined;
}
- const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
+ const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: Math.min(span, Math.max(0, handle.dt)),
dv: handle.dv,
@@ -87,7 +83,7 @@ function normalizeLeftHandle({
return undefined;
}
- const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time);
+ const span = Math.max(1, rightKey.time - leftKey.time);
return {
dt: Math.max(-span, Math.min(0, handle.dt)),
dv: handle.dv,
@@ -187,7 +183,7 @@ function extrapolateScalarEdge({
}
const span = neighborKey.time - edgeKey.time;
- if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
+ if (span === 0) {
return edgeKey.value;
}
@@ -228,8 +224,8 @@ export function getScalarChannelValueAtTime({
return fallbackValue;
}
- if (time <= firstKey.time + TIME_EPSILON_SECONDS) {
- if (time < firstKey.time - TIME_EPSILON_SECONDS) {
+ if (time <= firstKey.time) {
+ if (time < firstKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.before ?? "hold",
edgeKey: firstKey,
@@ -241,8 +237,8 @@ export function getScalarChannelValueAtTime({
return firstKey.value;
}
- if (time >= lastKey.time - TIME_EPSILON_SECONDS) {
- if (time > lastKey.time + TIME_EPSILON_SECONDS) {
+ if (time >= lastKey.time) {
+ if (time > lastKey.time) {
return extrapolateScalarEdge({
mode: normalizedChannel.extrapolation?.after ?? "hold",
edgeKey: lastKey,
@@ -261,7 +257,7 @@ export function getScalarChannelValueAtTime({
) {
const leftKey = normalizedChannel.keys[keyIndex];
const rightKey = normalizedChannel.keys[keyIndex + 1];
- if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) {
+ if (time === rightKey.time) {
return rightKey.value;
}
@@ -280,7 +276,7 @@ export function getScalarChannelValueAtTime({
}
const span = rightKey.time - leftKey.time;
- if (Math.abs(span) <= TIME_EPSILON_SECONDS) {
+ if (span === 0) {
return rightKey.value;
}
@@ -336,7 +332,7 @@ export function getDiscreteChannelValueAtTime({
});
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
- if (time + TIME_EPSILON_SECONDS < key.time) {
+ if (time < key.time) {
break;
}
currentValue = key.value;
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
index 304a6698..dbcd041c 100644
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -1,4 +1,3 @@
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
AnimationBindingInstance,
AnimationChannel,
@@ -76,8 +75,7 @@ function getUniqueBindingKeyframeMatches({
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
if (
!previousMatch ||
- Math.abs(previousMatch.keyframe.time - match.keyframe.time) >
- TIME_EPSILON_SECONDS
+ previousMatch.keyframe.time !== match.keyframe.time
) {
uniqueMatches.push(match);
continue;
@@ -249,9 +247,7 @@ export function getKeyframeAtTime({
matches: getBindingKeyframeMatches({
animations,
binding,
- }).filter(({ keyframe }) =>
- Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS,
- ),
+ }).filter(({ keyframe }) => keyframe.time === time),
});
if (!keyframeMatch) {
return null;
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index d72f9e16..05290c1d 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -14,7 +14,6 @@ import type {
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/lib/animation/types";
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
import {
cloneAnimationBinding,
@@ -44,7 +43,7 @@ function isNearlySameTime({
leftTime: number;
rightTime: number;
}): boolean {
- return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
+ return leftTime === rightTime;
}
function hasChannelKeys({
@@ -1032,8 +1031,8 @@ function splitScalarChannelAtTime({
const rightKey = normalizedChannel.keys[keyIndex + 1];
if (
!(
- splitTime > leftKey.time + TIME_EPSILON_SECONDS &&
- splitTime < rightKey.time - TIME_EPSILON_SECONDS
+ splitTime > leftKey.time &&
+ splitTime < rightKey.time
)
) {
continue;
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
index c12d7ea1..8b3067a1 100644
--- a/apps/web/src/lib/commands/media/add-media-asset.ts
+++ b/apps/web/src/lib/commands/media/add-media-asset.ts
@@ -4,16 +4,17 @@ import { toast } from "sonner";
import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
+import type { FrameRate } from "opencut-wasm";
import { hasMediaId } from "@/lib/timeline/element-utils";
-import { getHighestImportedVideoFps } from "@/lib/fps/utils";
+import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
export class AddMediaAssetCommand extends Command {
private assetId: string;
private savedAssets: MediaAsset[] | null = null;
private createdAsset: MediaAsset | null = null;
- private previousProjectFps: number | null = null;
- private appliedProjectFps: number | null = null;
+ private previousProjectFps: FrameRate | null = null;
+ private appliedProjectFps: FrameRate | null = null;
constructor(
private projectId: string,
@@ -112,14 +113,15 @@ export class AddMediaAssetCommand extends Command {
const activeProject = editor.project.getActiveOrNull();
if (!activeProject) return;
- if (activeProject.settings.fps !== this.appliedProjectFps) return;
+ if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return;
const highestRemainingVideoFps = getHighestImportedVideoFps({
mediaAssets: editor.media.getAssets(),
});
+ const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator;
if (
highestRemainingVideoFps !== null &&
- highestRemainingVideoFps >= this.appliedProjectFps
+ highestRemainingVideoFps >= appliedFpsFloat
) {
return;
}
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index fbef1613..9f0bf941 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -1,264 +1,265 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import { EditorCore } from "@/core";
-import type {
- CreateTimelineElement,
- TimelineTrack,
- TimelineElement,
- TrackType,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { requiresMediaId } from "@/lib/timeline/element-utils";
-import type { MediaAsset } from "@/lib/media/types";
-import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
-import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
-import {
- applyPlacement,
- canElementGoOnTrack,
- resolveTrackPlacement,
- validateElementTrackCompatibility,
-} from "@/lib/timeline/placement";
-
-type InsertElementPlacement =
- | { mode: "explicit"; trackId: string }
- | { mode: "auto"; trackType?: TrackType; insertIndex?: number };
-
-export interface InsertElementParams {
- element: CreateTimelineElement;
- placement: InsertElementPlacement;
-}
-
-export class InsertElementCommand extends Command {
- private elementId: string;
- private savedState: TimelineTrack[] | null = null;
- private targetTrackId: string | null = null;
-
- constructor({ element, placement }: InsertElementParams) {
- super();
- this.elementId = generateUUID();
- this.element = element;
- this.placement = placement;
- }
-
- private element: CreateTimelineElement;
- private placement: InsertElementPlacement;
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- if (!this.savedState) {
- console.error("Tracks not available");
- return;
- }
-
- if (!this.validateElementBasics({ element: this.element })) {
- return;
- }
-
- const totalElementsInTimeline = this.savedState.reduce(
- (total, t) => total + t.elements.length,
- 0,
- );
- const isFirstElement = totalElementsInTimeline === 0;
-
- const newElement = this.buildElement({ element: this.element });
- const updateResult = this.applyPlacementResult({
- tracks: this.savedState,
- element: newElement,
- });
-
- if (!updateResult) {
- return;
- }
-
- const { updatedTracks, targetTrackId } = updateResult;
- this.targetTrackId = targetTrackId;
-
- const isVisualMedia =
- newElement.type === "video" || newElement.type === "image";
-
- if (isFirstElement && isVisualMedia) {
- const mediaAssets = editor.media.getAssets();
- const activeProject = editor.project.getActive();
- const asset = mediaAssets.find(
- (item: MediaAsset) => item.id === newElement.mediaId,
- );
-
- if (asset?.width && asset?.height) {
- const nextCanvasSize = { width: asset.width, height: asset.height };
- const shouldSetOriginalCanvasSize =
- !activeProject?.settings.originalCanvasSize;
- editor.project.updateSettings({
- settings: {
- canvasSize: nextCanvasSize,
- ...(shouldSetOriginalCanvasSize
- ? { originalCanvasSize: nextCanvasSize }
- : {}),
- },
- pushHistory: false,
- });
- }
-
- if (asset?.type === "video" && asset?.fps) {
- editor.project.updateSettings({
- settings: { fps: asset.fps },
- pushHistory: false,
- });
- }
- }
-
- editor.timeline.updateTracks(updatedTracks);
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-
- getElementId(): string {
- return this.elementId;
- }
-
- getTrackId(): string | null {
- return this.targetTrackId;
- }
-
- private buildElement({
- element,
- }: {
- element: CreateTimelineElement;
- }): TimelineElement {
- return {
- ...element,
- id: this.elementId,
- startTime: element.startTime,
- trimStart: element.trimStart ?? 0,
- trimEnd: element.trimEnd ?? 0,
- duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
- } as TimelineElement;
- }
-
- private validateElementBasics({
- element,
- }: {
- element: CreateTimelineElement;
- }): boolean {
- if (requiresMediaId({ element }) && !("mediaId" in element)) {
- console.error("Element requires mediaId");
- return false;
- }
-
- if (
- element.type === "audio" &&
- element.sourceType === "library" &&
- !element.sourceUrl
- ) {
- console.error("Library audio element must have sourceUrl");
- return false;
- }
-
- if (element.type === "sticker" && !element.stickerId) {
- console.error("Sticker element must have stickerId");
- return false;
- }
-
- if (element.type === "graphic") {
- registerDefaultGraphics();
- if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
- console.error("Graphic element must have a valid definitionId");
- return false;
- }
- }
-
- if (element.type === "text" && !element.content) {
- console.error("Text element must have content");
- return false;
- }
-
- if (element.type === "effect" && !element.effectType) {
- console.error("Effect element must have effectType");
- return false;
- }
-
- return true;
- }
-
- private applyPlacementResult({
- tracks,
- element,
- }: {
- tracks: TimelineTrack[];
- element: TimelineElement;
- }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
- const placement = this.placement;
-
- if (
- placement.mode === "auto" &&
- placement.trackType &&
- !canElementGoOnTrack({
- elementType: element.type,
- trackType: placement.trackType,
- })
- ) {
- console.error(
- `${element.type} elements cannot be placed on ${placement.trackType} tracks`,
- );
- return null;
- }
-
- const placementResult = resolveTrackPlacement({
- tracks,
- ...(placement.mode === "auto" && placement.trackType
- ? { trackType: placement.trackType }
- : { elementType: element.type }),
- timeSpans: [
- {
- startTime: element.startTime,
- duration: element.duration,
- },
- ],
- strategy:
- placement.mode === "explicit"
- ? { type: "explicit", trackId: placement.trackId }
- : { type: "firstAvailable" },
- });
- if (!placementResult) {
- if (placement.mode === "explicit") {
- const targetTrack = tracks.find((track) => track.id === placement.trackId);
- if (!targetTrack) {
- console.error("Track not found:", placement.trackId);
- return null;
- }
-
- const validation = validateElementTrackCompatibility({
- element,
- track: targetTrack,
- });
- console.error(validation.errorMessage);
- }
-
- return null;
- }
-
- const elementToPlace =
- placementResult.kind === "existingTrack"
- ? {
- ...element,
- startTime:
- placementResult.adjustedStartTime ?? element.startTime,
- }
- : element;
-
- return applyPlacement({
- tracks,
- placementResult,
- elements: [elementToPlace],
- newTrackInsertIndexOverride:
- placement.mode === "auto" && typeof placement.insertIndex === "number"
- ? placement.insertIndex
- : undefined,
- });
- }
-}
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type {
+ CreateTimelineElement,
+ TimelineTrack,
+ TimelineElement,
+ TrackType,
+} from "@/lib/timeline";
+import { generateUUID } from "@/utils/id";
+import { requiresMediaId } from "@/lib/timeline/element-utils";
+import type { MediaAsset } from "@/lib/media/types";
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
+import { floatToFrameRate } from "@/lib/fps/utils";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import {
+ applyPlacement,
+ canElementGoOnTrack,
+ resolveTrackPlacement,
+ validateElementTrackCompatibility,
+} from "@/lib/timeline/placement";
+
+type InsertElementPlacement =
+ | { mode: "explicit"; trackId: string }
+ | { mode: "auto"; trackType?: TrackType; insertIndex?: number };
+
+export interface InsertElementParams {
+ element: CreateTimelineElement;
+ placement: InsertElementPlacement;
+}
+
+export class InsertElementCommand extends Command {
+ private elementId: string;
+ private savedState: TimelineTrack[] | null = null;
+ private targetTrackId: string | null = null;
+
+ constructor({ element, placement }: InsertElementParams) {
+ super();
+ this.elementId = generateUUID();
+ this.element = element;
+ this.placement = placement;
+ }
+
+ private element: CreateTimelineElement;
+ private placement: InsertElementPlacement;
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ if (!this.savedState) {
+ console.error("Tracks not available");
+ return;
+ }
+
+ if (!this.validateElementBasics({ element: this.element })) {
+ return;
+ }
+
+ const totalElementsInTimeline = this.savedState.reduce(
+ (total, t) => total + t.elements.length,
+ 0,
+ );
+ const isFirstElement = totalElementsInTimeline === 0;
+
+ const newElement = this.buildElement({ element: this.element });
+ const updateResult = this.applyPlacementResult({
+ tracks: this.savedState,
+ element: newElement,
+ });
+
+ if (!updateResult) {
+ return;
+ }
+
+ const { updatedTracks, targetTrackId } = updateResult;
+ this.targetTrackId = targetTrackId;
+
+ const isVisualMedia =
+ newElement.type === "video" || newElement.type === "image";
+
+ if (isFirstElement && isVisualMedia) {
+ const mediaAssets = editor.media.getAssets();
+ const activeProject = editor.project.getActive();
+ const asset = mediaAssets.find(
+ (item: MediaAsset) => item.id === newElement.mediaId,
+ );
+
+ if (asset?.width && asset?.height) {
+ const nextCanvasSize = { width: asset.width, height: asset.height };
+ const shouldSetOriginalCanvasSize =
+ !activeProject?.settings.originalCanvasSize;
+ editor.project.updateSettings({
+ settings: {
+ canvasSize: nextCanvasSize,
+ ...(shouldSetOriginalCanvasSize
+ ? { originalCanvasSize: nextCanvasSize }
+ : {}),
+ },
+ pushHistory: false,
+ });
+ }
+
+ if (asset?.type === "video" && asset?.fps) {
+ editor.project.updateSettings({
+ settings: { fps: floatToFrameRate(asset.fps) },
+ pushHistory: false,
+ });
+ }
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+
+ getElementId(): string {
+ return this.elementId;
+ }
+
+ getTrackId(): string | null {
+ return this.targetTrackId;
+ }
+
+ private buildElement({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): TimelineElement {
+ return {
+ ...element,
+ id: this.elementId,
+ startTime: element.startTime,
+ trimStart: element.trimStart ?? 0,
+ trimEnd: element.trimEnd ?? 0,
+ duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
+ } as TimelineElement;
+ }
+
+ private validateElementBasics({
+ element,
+ }: {
+ element: CreateTimelineElement;
+ }): boolean {
+ if (requiresMediaId({ element }) && !("mediaId" in element)) {
+ console.error("Element requires mediaId");
+ return false;
+ }
+
+ if (
+ element.type === "audio" &&
+ element.sourceType === "library" &&
+ !element.sourceUrl
+ ) {
+ console.error("Library audio element must have sourceUrl");
+ return false;
+ }
+
+ if (element.type === "sticker" && !element.stickerId) {
+ console.error("Sticker element must have stickerId");
+ return false;
+ }
+
+ if (element.type === "graphic") {
+ registerDefaultGraphics();
+ if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
+ console.error("Graphic element must have a valid definitionId");
+ return false;
+ }
+ }
+
+ if (element.type === "text" && !element.content) {
+ console.error("Text element must have content");
+ return false;
+ }
+
+ if (element.type === "effect" && !element.effectType) {
+ console.error("Effect element must have effectType");
+ return false;
+ }
+
+ return true;
+ }
+
+ private applyPlacementResult({
+ tracks,
+ element,
+ }: {
+ tracks: TimelineTrack[];
+ element: TimelineElement;
+ }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null {
+ const placement = this.placement;
+
+ if (
+ placement.mode === "auto" &&
+ placement.trackType &&
+ !canElementGoOnTrack({
+ elementType: element.type,
+ trackType: placement.trackType,
+ })
+ ) {
+ console.error(
+ `${element.type} elements cannot be placed on ${placement.trackType} tracks`,
+ );
+ return null;
+ }
+
+ const placementResult = resolveTrackPlacement({
+ tracks,
+ ...(placement.mode === "auto" && placement.trackType
+ ? { trackType: placement.trackType }
+ : { elementType: element.type }),
+ timeSpans: [
+ {
+ startTime: element.startTime,
+ duration: element.duration,
+ },
+ ],
+ strategy:
+ placement.mode === "explicit"
+ ? { type: "explicit", trackId: placement.trackId }
+ : { type: "firstAvailable" },
+ });
+ if (!placementResult) {
+ if (placement.mode === "explicit") {
+ const targetTrack = tracks.find((track) => track.id === placement.trackId);
+ if (!targetTrack) {
+ console.error("Track not found:", placement.trackId);
+ return null;
+ }
+
+ const validation = validateElementTrackCompatibility({
+ element,
+ track: targetTrack,
+ });
+ console.error(validation.errorMessage);
+ }
+
+ return null;
+ }
+
+ const elementToPlace =
+ placementResult.kind === "existingTrack"
+ ? {
+ ...element,
+ startTime:
+ placementResult.adjustedStartTime ?? element.startTime,
+ }
+ : element;
+
+ return applyPlacement({
+ tracks,
+ placementResult,
+ elements: [elementToPlace],
+ newTrackInsertIndexOverride:
+ placement.mode === "auto" && typeof placement.insertIndex === "number"
+ ? placement.insertIndex
+ : undefined,
+ });
+ }
+}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index 68d2bc7f..f397ac4b 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -1,3 +1,4 @@
+import type { FrameRate } from "opencut-wasm";
import { EXPORT_MIME_TYPES } from "@/constants/export-constants";
export const EXPORT_QUALITY_VALUES = [
@@ -15,7 +16,7 @@ export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number];
export interface ExportOptions {
format: ExportFormat;
quality: ExportQuality;
- fps?: number;
+ fps?: FrameRate;
includeAudio?: boolean;
}
diff --git a/apps/web/src/lib/fps/__tests__/fps.test.ts b/apps/web/src/lib/fps/__tests__/fps.test.ts
index fd082bb2..10eed8d9 100644
--- a/apps/web/src/lib/fps/__tests__/fps.test.ts
+++ b/apps/web/src/lib/fps/__tests__/fps.test.ts
@@ -36,16 +36,16 @@ describe("getRaisedProjectFpsForImportedMedia", () => {
test("raises the project fps to match a higher-fps import", () => {
expect(
getRaisedProjectFpsForImportedMedia({
- currentFps: 30,
+ currentFps: { numerator: 30, denominator: 1 },
importedAssets: [{ type: "video", fps: 60 }],
}),
- ).toBe(60);
+ ).toEqual({ numerator: 60, denominator: 1 });
});
test("does not lower the project fps for lower-fps imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
- currentFps: 60,
+ currentFps: { numerator: 60, denominator: 1 },
importedAssets: [{ type: "video", fps: 10 }],
}),
).toBeNull();
@@ -54,7 +54,7 @@ describe("getRaisedProjectFpsForImportedMedia", () => {
test("ignores non-video imports", () => {
expect(
getRaisedProjectFpsForImportedMedia({
- currentFps: 30,
+ currentFps: { numerator: 30, denominator: 1 },
importedAssets: [
{ type: "image", fps: 60 },
{ type: "audio", fps: 120 },
diff --git a/apps/web/src/lib/fps/constants.ts b/apps/web/src/lib/fps/constants.ts
index 100c043a..30c3d2bc 100644
--- a/apps/web/src/lib/fps/constants.ts
+++ b/apps/web/src/lib/fps/constants.ts
@@ -1,3 +1,5 @@
+import type { FrameRate } from "opencut-wasm";
+
export const FPS_PRESETS = [
{ value: "24", label: "24 fps" },
{ value: "25", label: "25 fps" },
@@ -6,4 +8,4 @@ export const FPS_PRESETS = [
{ value: "120", label: "120 fps" },
] as const;
-export const DEFAULT_FPS = 30;
+export const DEFAULT_FPS: FrameRate = { numerator: 30, denominator: 1 };
diff --git a/apps/web/src/lib/fps/utils.ts b/apps/web/src/lib/fps/utils.ts
index 8e3221a1..932788c4 100644
--- a/apps/web/src/lib/fps/utils.ts
+++ b/apps/web/src/lib/fps/utils.ts
@@ -1,7 +1,61 @@
+import type { FrameRate } from "opencut-wasm";
import type { MediaAsset } from "@/lib/media/types";
type MediaAssetFpsInput = Pick<MediaAsset, "type" | "fps">;
+const STANDARD_FRAME_RATES: Array<{ value: number; rate: FrameRate }> = [
+ { value: 24_000 / 1_001, rate: { numerator: 24_000, denominator: 1_001 } },
+ { value: 24, rate: { numerator: 24, denominator: 1 } },
+ { value: 25, rate: { numerator: 25, denominator: 1 } },
+ { value: 30_000 / 1_001, rate: { numerator: 30_000, denominator: 1_001 } },
+ { value: 30, rate: { numerator: 30, denominator: 1 } },
+ { value: 48, rate: { numerator: 48, denominator: 1 } },
+ { value: 50, rate: { numerator: 50, denominator: 1 } },
+ { value: 60_000 / 1_001, rate: { numerator: 60_000, denominator: 1_001 } },
+ { value: 60, rate: { numerator: 60, denominator: 1 } },
+ { value: 120, rate: { numerator: 120, denominator: 1 } },
+];
+
+const STANDARD_FRAME_RATE_TOLERANCE = 0.01;
+
+export function frameRateToFloat(rate: FrameRate): number {
+ return rate.numerator / rate.denominator;
+}
+
+export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean {
+ return a.numerator === b.numerator && a.denominator === b.denominator;
+}
+
+export function floatToFrameRate(fps: number): FrameRate {
+ const standard = STANDARD_FRAME_RATES.find(
+ (candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE,
+ );
+ if (standard) return standard.rate;
+
+ if (Number.isInteger(fps)) {
+ return { numerator: fps, denominator: 1 };
+ }
+
+ const ARBITRARY_DENOMINATOR = 1_000_000;
+ const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR);
+ const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR);
+ return {
+ numerator: scaledNumerator / divisor,
+ denominator: ARBITRARY_DENOMINATOR / divisor,
+ };
+}
+
+function gcd(left: number, right: number): number {
+ let a = Math.abs(left);
+ let b = Math.abs(right);
+ while (b !== 0) {
+ const remainder = a % b;
+ a = b;
+ b = remainder;
+ }
+ return a || 1;
+}
+
export function getHighestImportedVideoFps({
mediaAssets,
}: {
@@ -24,16 +78,18 @@ export function getRaisedProjectFpsForImportedMedia({
currentFps,
importedAssets,
}: {
- currentFps: number;
+ currentFps: FrameRate;
importedAssets: MediaAssetFpsInput[];
-}): number | null {
+}): FrameRate | null {
const highestImportedVideoFps = getHighestImportedVideoFps({
mediaAssets: importedAssets,
});
- if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) {
+ const currentFpsFloat = frameRateToFloat(currentFps);
+
+ if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFpsFloat) {
return null;
}
- return highestImportedVideoFps;
+ return floatToFrameRate(highestImportedVideoFps);
}
diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts
index d1025aa9..2fbea32e 100644
--- a/apps/web/src/lib/media/audio.ts
+++ b/apps/web/src/lib/media/audio.ts
@@ -21,6 +21,8 @@ import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+
const MAX_AUDIO_CHANNELS = 2;
const EXPORT_SAMPLE_RATE = 44100;
const COARSE_SAMPLE_COUNT = 2048;
@@ -122,10 +124,10 @@ export async function collectAudioElements({
return {
timelineElement: element,
buffer: audioBuffer,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
+ startTime: element.startTime / TICKS_PER_SECOND,
+ duration: element.duration / TICKS_PER_SECOND,
+ trimStart: element.trimStart / TICKS_PER_SECOND,
+ trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume: resolveEffectiveAudioGain({
element,
trackMuted: isTrackMuted,
@@ -152,10 +154,10 @@ export async function collectAudioElements({
return {
timelineElement: element,
buffer: audioBuffer,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
+ startTime: element.startTime / TICKS_PER_SECOND,
+ duration: element.duration / TICKS_PER_SECOND,
+ trimStart: element.trimStart / TICKS_PER_SECOND,
+ trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume: resolveEffectiveAudioGain({
element,
trackMuted: isTrackMuted,
@@ -337,16 +339,16 @@ async function fetchLibraryAudioSource({
type: "audio/mpeg",
});
- return {
- timelineElement: element,
- file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- volume,
- retime: element.retime,
- };
+ return {
+ timelineElement: element,
+ file,
+ startTime: element.startTime / TICKS_PER_SECOND,
+ duration: element.duration / TICKS_PER_SECOND,
+ trimStart: element.trimStart / TICKS_PER_SECOND,
+ trimEnd: element.trimEnd / TICKS_PER_SECOND,
+ volume,
+ retime: element.retime,
+ };
} catch (error) {
console.warn("Failed to fetch library audio:", error);
return null;
@@ -404,10 +406,10 @@ function collectMediaAudioSource({
return {
timelineElement: element,
file: mediaAsset.file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
+ startTime: element.startTime / TICKS_PER_SECOND,
+ duration: element.duration / TICKS_PER_SECOND,
+ trimStart: element.trimStart / TICKS_PER_SECOND,
+ trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume,
retime: element.retime,
};
@@ -429,10 +431,10 @@ function collectMediaAudioClip({
id: element.id,
sourceKey: mediaAsset.id,
file: mediaAsset.file,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
+ startTime: element.startTime / TICKS_PER_SECOND,
+ duration: element.duration / TICKS_PER_SECOND,
+ trimStart: element.trimStart / TICKS_PER_SECOND,
+ trimEnd: element.trimEnd / TICKS_PER_SECOND,
volume,
muted,
retime: element.retime,
@@ -602,7 +604,8 @@ export async function createTimelineAudioBuffer({
if (audioElements.length === 0) return null;
const outputChannels = 2;
- const outputLength = Math.ceil(duration * sampleRate);
+ const durationSeconds = duration / TICKS_PER_SECOND;
+ const outputLength = Math.ceil(durationSeconds * sampleRate);
const outputBuffer = context.createBuffer(
outputChannels,
outputLength,
diff --git a/apps/web/src/lib/project/types.ts b/apps/web/src/lib/project/types.ts
index 8f45db9e..0a454e07 100644
--- a/apps/web/src/lib/project/types.ts
+++ b/apps/web/src/lib/project/types.ts
@@ -1,53 +1,54 @@
-import type { TScene } from "@/lib/timeline/types";
-
-export type TBackground =
- | {
- type: "color";
- color: string;
- }
- | {
- type: "blur";
- blurIntensity: number;
- };
-
-export interface TCanvasSize {
- width: number;
- height: number;
-}
-
-export interface TProjectMetadata {
- id: string;
- name: string;
- thumbnail?: string;
- duration: number;
- createdAt: Date;
- updatedAt: Date;
-}
-
-export interface TProjectSettings {
- fps: number;
- canvasSize: TCanvasSize;
- canvasSizeMode?: "preset" | "custom";
- lastCustomCanvasSize?: TCanvasSize | null;
- originalCanvasSize?: TCanvasSize | null;
- background: TBackground;
-}
-
-export interface TTimelineViewState {
- zoomLevel: number;
- scrollLeft: number;
- playheadTime: number;
-}
-
-export interface TProject {
- metadata: TProjectMetadata;
- scenes: TScene[];
- currentSceneId: string;
- settings: TProjectSettings;
- version: number;
- timelineViewState?: TTimelineViewState;
-}
-
-export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
-export type TSortOrder = "asc" | "desc";
-export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
+import type { FrameRate } from "opencut-wasm";
+import type { TScene } from "@/lib/timeline/types";
+
+export type TBackground =
+ | {
+ type: "color";
+ color: string;
+ }
+ | {
+ type: "blur";
+ blurIntensity: number;
+ };
+
+export interface TCanvasSize {
+ width: number;
+ height: number;
+}
+
+export interface TProjectMetadata {
+ id: string;
+ name: string;
+ thumbnail?: string;
+ duration: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface TProjectSettings {
+ fps: FrameRate;
+ canvasSize: TCanvasSize;
+ canvasSizeMode?: "preset" | "custom";
+ lastCustomCanvasSize?: TCanvasSize | null;
+ originalCanvasSize?: TCanvasSize | null;
+ background: TBackground;
+}
+
+export interface TTimelineViewState {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: number;
+}
+
+export interface TProject {
+ metadata: TProjectMetadata;
+ scenes: TScene[];
+ currentSceneId: string;
+ settings: TProjectSettings;
+ version: number;
+ timelineViewState?: TTimelineViewState;
+}
+
+export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration";
+export type TSortOrder = "asc" | "desc";
+export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`;
diff --git a/apps/web/src/lib/ripple/diff.ts b/apps/web/src/lib/ripple/diff.ts
index 8a9730e1..22b8d996 100644
--- a/apps/web/src/lib/ripple/diff.ts
+++ b/apps/web/src/lib/ripple/diff.ts
@@ -1,4 +1,4 @@
-import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+
import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
import type { RippleAdjustment } from "./apply";
@@ -105,7 +105,7 @@ function collectTrackIntervals({
continue;
}
- if (beforeElement.endTime > afterElement.endTime + TIME_EPSILON_SECONDS) {
+ if (beforeElement.endTime > afterElement.endTime) {
pushInterval({
intervals: vacatedIntervals,
startTime: afterElement.endTime,
@@ -141,7 +141,7 @@ function buildAdjustments({
}): RippleAdjustment[] {
return intervals.flatMap((interval): RippleAdjustment[] => {
const shiftAmount = interval.endTime - interval.startTime;
- if (shiftAmount <= TIME_EPSILON_SECONDS) {
+ if (shiftAmount <= 0) {
return [];
}
@@ -203,7 +203,7 @@ function normalizeIntervals({
const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }];
for (const interval of sortedIntervals.slice(1)) {
const previousInterval = mergedIntervals[mergedIntervals.length - 1];
- if (interval.startTime <= previousInterval.endTime + TIME_EPSILON_SECONDS) {
+ if (interval.startTime <= previousInterval.endTime) {
previousInterval.endTime = Math.max(
previousInterval.endTime,
interval.endTime,
@@ -228,12 +228,10 @@ function subtractSingleInterval({
for (const overlappingInterval of overlappingIntervals) {
remainingIntervals = remainingIntervals.flatMap((remainingInterval) => {
- if (
- overlappingInterval.endTime <=
- remainingInterval.startTime + TIME_EPSILON_SECONDS ||
- overlappingInterval.startTime >=
- remainingInterval.endTime - TIME_EPSILON_SECONDS
- ) {
+ if (
+ overlappingInterval.endTime <= remainingInterval.startTime ||
+ overlappingInterval.startTime >= remainingInterval.endTime
+ ) {
return [remainingInterval];
}
@@ -264,7 +262,7 @@ function pushInterval({
startTime,
endTime,
}: { intervals: Interval[]; startTime: number; endTime: number }): void {
- if (endTime - startTime <= TIME_EPSILON_SECONDS) {
+ if (endTime <= startTime) {
return;
}
diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts
index 28e381c2..9625c482 100644
--- a/apps/web/src/lib/timeline/audio-state.ts
+++ b/apps/web/src/lib/timeline/audio-state.ts
@@ -1,100 +1,102 @@
-import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
-import { resolveNumberAtTime } from "@/lib/animation/resolve";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
-import type { TimelineElement } from "./types";
-const DEFAULT_STEP_SECONDS = 1 / 60;
-
-export type AudioCapableElement = Extract<
- TimelineElement,
- { type: "audio" | "video" }
->;
-
-export function clampDb(value: number): number {
- if (!Number.isFinite(value)) {
- return 0;
- }
-
- return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
-}
-
-export function dBToLinear(db: number): number {
- return 10 ** (clampDb(db) / 20);
-}
-
-export function hasAnimatedVolume({
- element,
-}: {
- element: AudioCapableElement;
-}): boolean {
- return hasKeyframesForPath({
- animations: element.animations,
- propertyPath: "volume",
- });
-}
-
-export function resolveEffectiveAudioGain({
- element,
- trackMuted = false,
- localTime,
-}: {
- element: AudioCapableElement;
- trackMuted?: boolean;
- localTime: number;
-}): number {
- if (trackMuted || element.muted === true) {
- return 0;
- }
-
- const resolvedDb = resolveNumberAtTime({
- baseValue: element.volume ?? 0,
- animations: element.animations,
- propertyPath: "volume",
- localTime,
- });
-
- return dBToLinear(resolvedDb);
-}
-
-export function buildAudioGainAutomation({
- element,
- trackMuted = false,
- fromLocalTime,
- toLocalTime,
- stepSeconds = DEFAULT_STEP_SECONDS,
-}: {
- element: AudioCapableElement;
- trackMuted?: boolean;
- fromLocalTime: number;
- toLocalTime: number;
- stepSeconds?: number;
-}): Array<{ localTime: number; gain: number }> {
- const startTime = Math.max(0, fromLocalTime);
- const endTime = Math.max(startTime, toLocalTime);
- const safeStep =
- Number.isFinite(stepSeconds) && stepSeconds > 0
- ? stepSeconds
- : DEFAULT_STEP_SECONDS;
- const points: Array<{ localTime: number; gain: number }> = [];
-
- for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
- points.push({
- localTime,
- gain: resolveEffectiveAudioGain({
- element,
- trackMuted,
- localTime,
- }),
- });
- }
-
- points.push({
- localTime: endTime,
- gain: resolveEffectiveAudioGain({
- element,
- trackMuted,
- localTime: endTime,
- }),
- });
-
- return points;
-}
+import { hasKeyframesForPath } from "@/lib/animation/keyframe-query";
+import { resolveNumberAtTime } from "@/lib/animation/resolve";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
+import type { TimelineElement } from "./types";
+const DEFAULT_STEP_SECONDS = 1 / 60;
+
+export type AudioCapableElement = Extract<
+ TimelineElement,
+ { type: "audio" | "video" }
+>;
+
+export function clampDb(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+
+ return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value));
+}
+
+export function dBToLinear(db: number): number {
+ return 10 ** (clampDb(db) / 20);
+}
+
+export function hasAnimatedVolume({
+ element,
+}: {
+ element: AudioCapableElement;
+}): boolean {
+ return hasKeyframesForPath({
+ animations: element.animations,
+ propertyPath: "volume",
+ });
+}
+
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+
+export function resolveEffectiveAudioGain({
+ element,
+ trackMuted = false,
+ localTime,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ localTime: number;
+}): number {
+ if (trackMuted || element.muted === true) {
+ return 0;
+ }
+
+ const resolvedDb = resolveNumberAtTime({
+ baseValue: element.volume ?? 0,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime: Math.round(localTime * TICKS_PER_SECOND),
+ });
+
+ return dBToLinear(resolvedDb);
+}
+
+export function buildAudioGainAutomation({
+ element,
+ trackMuted = false,
+ fromLocalTime,
+ toLocalTime,
+ stepSeconds = DEFAULT_STEP_SECONDS,
+}: {
+ element: AudioCapableElement;
+ trackMuted?: boolean;
+ fromLocalTime: number;
+ toLocalTime: number;
+ stepSeconds?: number;
+}): Array<{ localTime: number; gain: number }> {
+ const startTime = Math.max(0, fromLocalTime);
+ const endTime = Math.max(startTime, toLocalTime);
+ const safeStep =
+ Number.isFinite(stepSeconds) && stepSeconds > 0
+ ? stepSeconds
+ : DEFAULT_STEP_SECONDS;
+ const points: Array<{ localTime: number; gain: number }> = [];
+
+ for (let localTime = startTime; localTime < endTime; localTime += safeStep) {
+ points.push({
+ localTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime,
+ }),
+ });
+ }
+
+ points.push({
+ localTime: endTime,
+ gain: resolveEffectiveAudioGain({
+ element,
+ trackMuted,
+ localTime: endTime,
+ }),
+ });
+
+ return points;
+}
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts
index 70d1b5ee..1fbf17bf 100644
--- a/apps/web/src/lib/timeline/bookmarks.ts
+++ b/apps/web/src/lib/timeline/bookmarks.ts
@@ -1,8 +1,7 @@
import type { Bookmark } from "@/lib/timeline";
+import type { FrameRate } from "opencut-wasm";
import { roundToFrame } from "opencut-wasm";
-export const BOOKMARK_TIME_EPSILON = 0.001;
-
function bookmarkTimeEqual({
bookmarkTime,
frameTime,
@@ -10,7 +9,7 @@ function bookmarkTimeEqual({
bookmarkTime: number;
frameTime: number;
}): boolean {
- return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
+ return bookmarkTime === frameTime;
}
export function findBookmarkIndex({
@@ -112,9 +111,9 @@ export function getFrameTime({
fps,
}: {
time: number;
- fps: number;
+ fps: FrameRate;
}): number {
- return roundToFrame({ time, fps });
+ return roundToFrame({ time, rate: fps }) ?? time;
}
export function getBookmarkAtTime({
@@ -141,9 +140,6 @@ export function getBookmarksActiveAtTime({
bookmark.duration != null && bookmark.duration > 0
? start + bookmark.duration
: start;
- return (
- time >= start - BOOKMARK_TIME_EPSILON &&
- time <= end + BOOKMARK_TIME_EPSILON
- );
+ return time >= start && time <= end;
});
}
diff --git a/apps/web/src/lib/timeline/creation.ts b/apps/web/src/lib/timeline/creation.ts
index 335ab912..a339d806 100644
--- a/apps/web/src/lib/timeline/creation.ts
+++ b/apps/web/src/lib/timeline/creation.ts
@@ -1 +1,3 @@
-export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5;
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+
+export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts
index bc968a3c..cf452800 100644
--- a/apps/web/src/lib/timeline/defaults.ts
+++ b/apps/web/src/lib/timeline/defaults.ts
@@ -1,77 +1,77 @@
-import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
-import type { TTimelineViewState } from "@/lib/project/types";
-import type { BlendMode, Transform } from "@/lib/rendering";
-import type { TextElement } from "./types";
-
-const defaultTransform: Transform = {
- scaleX: 1,
- scaleY: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
-};
-
-const defaultOpacity = 1;
-const defaultBlendMode: BlendMode = "normal";
-const defaultVolume = 0;
-
-const defaultTextLetterSpacing = 0;
-const defaultTextLineHeight = 1.2;
-
-const defaultTextBackground = {
- enabled: false,
- color: "#000000",
- cornerRadius: 0,
- paddingX: 30,
- paddingY: 42,
- offsetX: 0,
- offsetY: 0,
-};
-
-const defaultTextElement: Omit<TextElement, "id"> = {
- type: "text",
- name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- background: { ...defaultTextBackground },
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: defaultTextLetterSpacing,
- lineHeight: defaultTextLineHeight,
- duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...defaultTransform,
- position: { ...defaultTransform.position },
- },
- opacity: defaultOpacity,
-};
-
-const defaultTimelineViewState: TTimelineViewState = {
- zoomLevel: 1,
- scrollLeft: 0,
- playheadTime: 0,
-};
-
-export const DEFAULTS = {
- element: {
- transform: defaultTransform,
- opacity: defaultOpacity,
- blendMode: defaultBlendMode,
- volume: defaultVolume,
- },
- text: {
- letterSpacing: defaultTextLetterSpacing,
- lineHeight: defaultTextLineHeight,
- background: defaultTextBackground,
- element: defaultTextElement,
- },
- timeline: {
- viewState: defaultTimelineViewState,
- },
-};
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
+import type { TTimelineViewState } from "@/lib/project/types";
+import type { BlendMode, Transform } from "@/lib/rendering";
+import type { TextElement } from "./types";
+
+const defaultTransform: Transform = {
+ scaleX: 1,
+ scaleY: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+const defaultOpacity = 1;
+const defaultBlendMode: BlendMode = "normal";
+const defaultVolume = 0;
+
+const defaultTextLetterSpacing = 0;
+const defaultTextLineHeight = 1.2;
+
+const defaultTextBackground = {
+ enabled: false,
+ color: "#000000",
+ cornerRadius: 0,
+ paddingX: 30,
+ paddingY: 42,
+ offsetX: 0,
+ offsetY: 0,
+};
+
+const defaultTextElement: Omit<TextElement, "id"> = {
+ type: "text",
+ name: "Text",
+ content: "Default text",
+ fontSize: 15,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ background: { ...defaultTextBackground },
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ duration: DEFAULT_NEW_ELEMENT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...defaultTransform,
+ position: { ...defaultTransform.position },
+ },
+ opacity: defaultOpacity,
+};
+
+const defaultTimelineViewState: TTimelineViewState = {
+ zoomLevel: 1,
+ scrollLeft: 0,
+ playheadTime: 0,
+};
+
+export const DEFAULTS = {
+ element: {
+ transform: defaultTransform,
+ opacity: defaultOpacity,
+ blendMode: defaultBlendMode,
+ volume: defaultVolume,
+ },
+ text: {
+ letterSpacing: defaultTextLetterSpacing,
+ lineHeight: defaultTextLineHeight,
+ background: defaultTextBackground,
+ element: defaultTextElement,
+ },
+ timeline: {
+ viewState: defaultTimelineViewState,
+ },
+};
diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts
index 8efeab79..35d00613 100644
--- a/apps/web/src/lib/timeline/drag-utils.ts
+++ b/apps/web/src/lib/timeline/drag-utils.ts
@@ -1,19 +1,21 @@
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-
-export function getMouseTimeFromClientX({
- clientX,
- containerRect,
- zoomLevel,
- scrollLeft,
-}: {
- clientX: number;
- containerRect: DOMRect;
- zoomLevel: number;
- scrollLeft: number;
-}): number {
- const mouseX = clientX - containerRect.left + scrollLeft;
- return Math.max(
- 0,
- mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- );
-}
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+
+export function getMouseTimeFromClientX({
+ clientX,
+ containerRect,
+ zoomLevel,
+ scrollLeft,
+}: {
+ clientX: number;
+ containerRect: DOMRect;
+ zoomLevel: number;
+ scrollLeft: number;
+}): number {
+ const mouseX = clientX - containerRect.left + scrollLeft;
+ const seconds = Math.max(
+ 0,
+ mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ );
+ return Math.round(seconds * TICKS_PER_SECOND);
+}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index 96588903..6f9e9dbc 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -1,418 +1,418 @@
-import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation";
-import {
- MASKABLE_ELEMENT_TYPES,
- RETIMABLE_ELEMENT_TYPES,
- VISUAL_ELEMENT_TYPES,
- type CreateEffectElement,
- type CreateGraphicElement,
- type CreateTimelineElement,
- type CreateVideoElement,
- type CreateImageElement,
- type CreateStickerElement,
- type CreateUploadAudioElement,
- type CreateLibraryAudioElement,
- type TextBackground,
- type TextElement,
- type TimelineElement,
- type TimelineTrack,
- type AudioElement,
- type VideoElement,
- type ImageElement,
- type MaskableElement,
- type RetimableElement,
- type VisualElement,
- type UploadAudioElement,
-} from "@/lib/timeline";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type { MediaType } from "@/lib/media/types";
-import { buildDefaultEffectInstance } from "@/lib/effects";
-import { buildDefaultGraphicInstance } from "@/lib/graphics";
-import type { ParamValues } from "@/lib/params";
-import { capitalizeFirstLetter } from "@/utils/string";
-
-export function canElementHaveAudio(
- element: TimelineElement,
-): element is AudioElement | VideoElement {
- return element.type === "audio" || element.type === "video";
-}
-
-export function isVisualElement(
- element: TimelineElement,
-): element is VisualElement {
- return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
-}
-
-export function isMaskableElement(
- element: TimelineElement,
-): element is MaskableElement {
- return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
-}
-
-export function isRetimableElement(
- element: TimelineElement,
-): element is RetimableElement {
- return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
-}
-
-export function canElementBeHidden(
- element: TimelineElement,
-): element is VisualElement {
- return isVisualElement(element);
-}
-
-export function hasElementEffects({
- element,
-}: {
- element: TimelineElement;
-}): boolean {
- return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
-}
-
-export function hasMediaId(
- element: TimelineElement,
-): element is UploadAudioElement | VideoElement | ImageElement {
- return "mediaId" in element;
-}
-
-export function requiresMediaId({
- element,
-}: {
- element: CreateTimelineElement;
-}): boolean {
- return (
- element.type === "video" ||
- element.type === "image" ||
- (element.type === "audio" && element.sourceType === "upload")
- );
-}
-
-function buildTextBackground(
- raw: Partial<TextBackground> | undefined,
-): TextBackground {
- const color = raw?.color ?? DEFAULTS.text.element.background.color;
- const enabled = raw?.enabled ?? color !== "transparent";
- return {
- enabled,
- color,
- cornerRadius: raw?.cornerRadius,
- paddingX: raw?.paddingX,
- paddingY: raw?.paddingY,
- offsetX: raw?.offsetX,
- offsetY: raw?.offsetY,
- };
-}
-
-export function buildTextElement({
- raw,
- startTime,
-}: {
- raw: Partial<Omit<TextElement, "type" | "id">>;
- startTime: number;
-}): CreateTimelineElement {
- const t = raw as Partial<TextElement>;
-
- return {
- type: "text",
- name: t.name ?? DEFAULTS.text.element.name,
- content: t.content ?? DEFAULTS.text.element.content,
- duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
- fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
- color: t.color ?? DEFAULTS.text.element.color,
- background: buildTextBackground(t.background),
- textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
- fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
- fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
- textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
- letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
- lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
- transform: t.transform ?? DEFAULTS.text.element.transform,
- opacity: t.opacity ?? DEFAULTS.text.element.opacity,
- blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
- };
-}
-
-export function buildEffectElement({
- effectType,
- startTime,
- duration,
-}: {
- effectType: string;
- startTime: number;
- duration?: number;
-}): CreateEffectElement {
- const instance = buildDefaultEffectInstance({ effectType });
- return {
- type: "effect",
- name: capitalizeFirstLetter({ string: instance.type }),
- effectType,
- params: instance.params,
- duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- };
-}
-
-export function buildStickerElement({
- stickerId,
- name,
- startTime,
- intrinsicWidth,
- intrinsicHeight,
-}: {
- stickerId: string;
- name?: string;
- startTime: number;
- intrinsicWidth?: number;
- intrinsicHeight?: number;
-}): CreateStickerElement {
- const stickerNameFromId =
- stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
- return {
- type: "sticker",
- name: name ?? stickerNameFromId,
- stickerId,
- intrinsicWidth,
- intrinsicHeight,
- duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- };
-}
-
-export function buildGraphicElement({
- definitionId,
- name,
- startTime,
- params,
-}: {
- definitionId: string;
- name?: string;
- startTime: number;
- params?: Partial<ParamValues>;
-}): CreateGraphicElement {
- const instance = buildDefaultGraphicInstance({ definitionId });
- return {
- type: "graphic",
- name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
- definitionId: instance.definitionId,
- params: { ...instance.params, ...(params ?? {}) } as ParamValues,
- duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- };
-}
-
-function buildVideoElement({
- mediaId,
- name,
- duration,
- startTime,
-}: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
-}): CreateVideoElement {
- return {
- type: "video",
- mediaId,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- sourceDuration: duration,
- muted: false,
- isSourceAudioEnabled: true,
- hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- volume: DEFAULTS.element.volume,
- };
-}
-
-function buildImageElement({
- mediaId,
- name,
- duration,
- startTime,
-}: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
-}): CreateImageElement {
- return {
- type: "image",
- mediaId,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- hidden: false,
- transform: {
- ...DEFAULTS.element.transform,
- position: { ...DEFAULTS.element.transform.position },
- },
- opacity: DEFAULTS.element.opacity,
- blendMode: DEFAULTS.element.blendMode,
- };
-}
-
-function buildUploadAudioElement({
- mediaId,
- name,
- duration,
- startTime,
- buffer,
-}: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
-}): CreateUploadAudioElement {
- const element: CreateUploadAudioElement = {
- type: "audio",
- sourceType: "upload",
- mediaId,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
- };
- if (buffer) {
- element.buffer = buffer;
- }
- return element;
-}
-
-export function buildElementFromMedia({
- mediaId,
- mediaType,
- name,
- duration,
- startTime,
- buffer,
-}: {
- mediaId: string;
- mediaType: MediaType;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
-}): CreateTimelineElement {
- switch (mediaType) {
- case "audio":
- return buildUploadAudioElement({
- mediaId,
- name,
- duration,
- startTime,
- buffer,
- });
- case "video":
- return buildVideoElement({ mediaId, name, duration, startTime });
- case "image":
- return buildImageElement({ mediaId, name, duration, startTime });
- }
-}
-
-export function buildLibraryAudioElement({
- sourceUrl,
- name,
- duration,
- startTime,
- buffer,
-}: {
- sourceUrl: string;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
-}): CreateLibraryAudioElement {
- const element: CreateLibraryAudioElement = {
- type: "audio",
- sourceType: "library",
- sourceUrl,
- name,
- duration,
- startTime,
- trimStart: 0,
- trimEnd: 0,
- sourceDuration: duration,
- volume: DEFAULTS.element.volume,
- muted: false,
- };
- if (buffer) {
- element.buffer = buffer;
- }
- return element;
-}
-
-export function getElementsAtTime({
- tracks,
- time,
-}: {
- tracks: TimelineTrack[];
- time: number;
-}): { trackId: string; elementId: string }[] {
- const result: { trackId: string; elementId: string }[] = [];
-
- for (const track of tracks) {
- for (const element of track.elements) {
- const elementStart = element.startTime;
- const elementEnd = element.startTime + element.duration;
-
- if (time > elementStart && time < elementEnd) {
- result.push({ trackId: track.id, elementId: element.id });
- }
- }
- }
-
- return result;
-}
-
-export function getElementFontFamilies({
- tracks,
-}: {
- tracks: TimelineTrack[];
-}): string[] {
- const families = new Set<string>();
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.type === "text" && element.fontFamily) {
- families.add(element.fontFamily);
- }
- }
- }
- return [...families];
-}
+import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation";
+import {
+ MASKABLE_ELEMENT_TYPES,
+ RETIMABLE_ELEMENT_TYPES,
+ VISUAL_ELEMENT_TYPES,
+ type CreateEffectElement,
+ type CreateGraphicElement,
+ type CreateTimelineElement,
+ type CreateVideoElement,
+ type CreateImageElement,
+ type CreateStickerElement,
+ type CreateUploadAudioElement,
+ type CreateLibraryAudioElement,
+ type TextBackground,
+ type TextElement,
+ type TimelineElement,
+ type TimelineTrack,
+ type AudioElement,
+ type VideoElement,
+ type ImageElement,
+ type MaskableElement,
+ type RetimableElement,
+ type VisualElement,
+ type UploadAudioElement,
+} from "@/lib/timeline";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import type { MediaType } from "@/lib/media/types";
+import { buildDefaultEffectInstance } from "@/lib/effects";
+import { buildDefaultGraphicInstance } from "@/lib/graphics";
+import type { ParamValues } from "@/lib/params";
+import { capitalizeFirstLetter } from "@/utils/string";
+
+export function canElementHaveAudio(
+ element: TimelineElement,
+): element is AudioElement | VideoElement {
+ return element.type === "audio" || element.type === "video";
+}
+
+export function isVisualElement(
+ element: TimelineElement,
+): element is VisualElement {
+ return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isMaskableElement(
+ element: TimelineElement,
+): element is MaskableElement {
+ return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function isRetimableElement(
+ element: TimelineElement,
+): element is RetimableElement {
+ return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type);
+}
+
+export function canElementBeHidden(
+ element: TimelineElement,
+): element is VisualElement {
+ return isVisualElement(element);
+}
+
+export function hasElementEffects({
+ element,
+}: {
+ element: TimelineElement;
+}): boolean {
+ return isVisualElement(element) && (element.effects?.length ?? 0) > 0;
+}
+
+export function hasMediaId(
+ element: TimelineElement,
+): element is UploadAudioElement | VideoElement | ImageElement {
+ return "mediaId" in element;
+}
+
+export function requiresMediaId({
+ element,
+}: {
+ element: CreateTimelineElement;
+}): boolean {
+ return (
+ element.type === "video" ||
+ element.type === "image" ||
+ (element.type === "audio" && element.sourceType === "upload")
+ );
+}
+
+function buildTextBackground(
+ raw: Partial<TextBackground> | undefined,
+): TextBackground {
+ const color = raw?.color ?? DEFAULTS.text.element.background.color;
+ const enabled = raw?.enabled ?? color !== "transparent";
+ return {
+ enabled,
+ color,
+ cornerRadius: raw?.cornerRadius,
+ paddingX: raw?.paddingX,
+ paddingY: raw?.paddingY,
+ offsetX: raw?.offsetX,
+ offsetY: raw?.offsetY,
+ };
+}
+
+export function buildTextElement({
+ raw,
+ startTime,
+}: {
+ raw: Partial<Omit<TextElement, "type" | "id">>;
+ startTime: number;
+}): CreateTimelineElement {
+ const t = raw as Partial<TextElement>;
+
+ return {
+ type: "text",
+ name: t.name ?? DEFAULTS.text.element.name,
+ content: t.content ?? DEFAULTS.text.element.content,
+ duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize,
+ fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily,
+ color: t.color ?? DEFAULTS.text.element.color,
+ background: buildTextBackground(t.background),
+ textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign,
+ fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight,
+ fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle,
+ textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration,
+ letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing,
+ lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight,
+ transform: t.transform ?? DEFAULTS.text.element.transform,
+ opacity: t.opacity ?? DEFAULTS.text.element.opacity,
+ blendMode: t.blendMode ?? DEFAULTS.element.blendMode,
+ };
+}
+
+export function buildEffectElement({
+ effectType,
+ startTime,
+ duration,
+}: {
+ effectType: string;
+ startTime: number;
+ duration?: number;
+}): CreateEffectElement {
+ const instance = buildDefaultEffectInstance({ effectType });
+ return {
+ type: "effect",
+ name: capitalizeFirstLetter({ string: instance.type }),
+ effectType,
+ params: instance.params,
+ duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ };
+}
+
+export function buildStickerElement({
+ stickerId,
+ name,
+ startTime,
+ intrinsicWidth,
+ intrinsicHeight,
+}: {
+ stickerId: string;
+ name?: string;
+ startTime: number;
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+}): CreateStickerElement {
+ const stickerNameFromId =
+ stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
+ return {
+ type: "sticker",
+ name: name ?? stickerNameFromId,
+ stickerId,
+ intrinsicWidth,
+ intrinsicHeight,
+ duration: DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+export function buildGraphicElement({
+ definitionId,
+ name,
+ startTime,
+ params,
+}: {
+ definitionId: string;
+ name?: string;
+ startTime: number;
+ params?: Partial<ParamValues>;
+}): CreateGraphicElement {
+ const instance = buildDefaultGraphicInstance({ definitionId });
+ return {
+ type: "graphic",
+ name: name ?? capitalizeFirstLetter({ string: instance.definitionId }),
+ definitionId: instance.definitionId,
+ params: { ...instance.params, ...(params ?? {}) } as ParamValues,
+ duration: DEFAULT_NEW_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildVideoElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateVideoElement {
+ return {
+ type: "video",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ muted: false,
+ isSourceAudioEnabled: true,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ volume: DEFAULTS.element.volume,
+ };
+}
+
+function buildImageElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+}): CreateImageElement {
+ return {
+ type: "image",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ hidden: false,
+ transform: {
+ ...DEFAULTS.element.transform,
+ position: { ...DEFAULTS.element.transform.position },
+ },
+ opacity: DEFAULTS.element.opacity,
+ blendMode: DEFAULTS.element.blendMode,
+ };
+}
+
+function buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateUploadAudioElement {
+ const element: CreateUploadAudioElement = {
+ type: "audio",
+ sourceType: "upload",
+ mediaId,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function buildElementFromMedia({
+ mediaId,
+ mediaType,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ mediaType: MediaType;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateTimelineElement {
+ switch (mediaType) {
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+ });
+ case "video":
+ return buildVideoElement({ mediaId, name, duration, startTime });
+ case "image":
+ return buildImageElement({ mediaId, name, duration, startTime });
+ }
+}
+
+export function buildLibraryAudioElement({
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ sourceUrl: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateLibraryAudioElement {
+ const element: CreateLibraryAudioElement = {
+ type: "audio",
+ sourceType: "library",
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ sourceDuration: duration,
+ volume: DEFAULTS.element.volume,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function getElementsAtTime({
+ tracks,
+ time,
+}: {
+ tracks: TimelineTrack[];
+ time: number;
+}): { trackId: string; elementId: string }[] {
+ const result: { trackId: string; elementId: string }[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ const elementStart = element.startTime;
+ const elementEnd = element.startTime + element.duration;
+
+ if (time > elementStart && time < elementEnd) {
+ result.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ return result;
+}
+
+export function getElementFontFamilies({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): string[] {
+ const families = new Set<string>();
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.type === "text" && element.fontFamily) {
+ families.add(element.fontFamily);
+ }
+ }
+ }
+ return [...families];
+}
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts
index e93b5ab5..10f258aa 100644
--- a/apps/web/src/lib/timeline/pixel-utils.ts
+++ b/apps/web/src/lib/timeline/pixel-utils.ts
@@ -1,79 +1,80 @@
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-
-export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
-
-function getDevicePixelRatio({
- devicePixelRatio,
-}: {
- devicePixelRatio?: number;
-}): number {
- if (
- typeof devicePixelRatio === "number" &&
- Number.isFinite(devicePixelRatio) &&
- devicePixelRatio > 0
- ) {
- return devicePixelRatio;
- }
-
- if (typeof window === "undefined") {
- return 1;
- }
-
- if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
- return window.devicePixelRatio;
- }
-
- return 1;
-}
-
-export function getTimelinePixelsPerSecond({
- zoomLevel,
-}: {
- zoomLevel: number;
-}): number {
- return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
-}
-
-export function timelineTimeToPixels({
- time,
- zoomLevel,
-}: {
- time: number;
- zoomLevel: number;
-}): number {
- return time * getTimelinePixelsPerSecond({ zoomLevel });
-}
-
-export function snapPixelToDeviceGrid({
- pixel,
- devicePixelRatio,
-}: {
- pixel: number;
- devicePixelRatio?: number;
-}): number {
- const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
- return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
-}
-
-export function timelineTimeToSnappedPixels({
- time,
- zoomLevel,
- devicePixelRatio,
-}: {
- time: number;
- zoomLevel: number;
- devicePixelRatio?: number;
-}): number {
- const rawPixel = timelineTimeToPixels({ time, zoomLevel });
- return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
-}
-
-export function getCenteredLineLeft({
- centerPixel,
- lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
-}: {
- centerPixel: number;
- lineWidthPx?: number;
-}): number {
- return centerPixel - lineWidthPx / 2;
-}
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+
+export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2;
+
+function getDevicePixelRatio({
+ devicePixelRatio,
+}: {
+ devicePixelRatio?: number;
+}): number {
+ if (
+ typeof devicePixelRatio === "number" &&
+ Number.isFinite(devicePixelRatio) &&
+ devicePixelRatio > 0
+ ) {
+ return devicePixelRatio;
+ }
+
+ if (typeof window === "undefined") {
+ return 1;
+ }
+
+ if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) {
+ return window.devicePixelRatio;
+ }
+
+ return 1;
+}
+
+export function getTimelinePixelsPerSecond({
+ zoomLevel,
+}: {
+ zoomLevel: number;
+}): number {
+ return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+}
+
+export function timelineTimeToPixels({
+ time,
+ zoomLevel,
+}: {
+ time: number;
+ zoomLevel: number;
+}): number {
+ return (time / TICKS_PER_SECOND) * getTimelinePixelsPerSecond({ zoomLevel });
+}
+
+export function snapPixelToDeviceGrid({
+ pixel,
+ devicePixelRatio,
+}: {
+ pixel: number;
+ devicePixelRatio?: number;
+}): number {
+ const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio });
+ return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio;
+}
+
+export function timelineTimeToSnappedPixels({
+ time,
+ zoomLevel,
+ devicePixelRatio,
+}: {
+ time: number;
+ zoomLevel: number;
+ devicePixelRatio?: number;
+}): number {
+ const rawPixel = timelineTimeToPixels({ time, zoomLevel });
+ return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio });
+}
+
+export function getCenteredLineLeft({
+ centerPixel,
+ lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX,
+}: {
+ centerPixel: number;
+ lineWidthPx?: number;
+}): number {
+ return centerPixel - lineWidthPx / 2;
+}
diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts
index 732d9ea4..ea2982aa 100644
--- a/apps/web/src/lib/timeline/ruler-utils.ts
+++ b/apps/web/src/lib/timeline/ruler-utils.ts
@@ -1,4 +1,6 @@
+import type { FrameRate } from "opencut-wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { frameRateToFloat } from "@/lib/fps/utils";
/**
* frame intervals for labels - starts at 2 so there's always at least
@@ -54,15 +56,16 @@ export function getRulerConfig({
fps,
}: {
zoomLevel: number;
- fps: number;
+ fps: FrameRate;
}): RulerConfig {
+ const fpsFloat = frameRateToFloat(fps);
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const pixelsPerFrame = pixelsPerSecond / fps;
+ const pixelsPerFrame = pixelsPerSecond / fpsFloat;
const labelIntervalSeconds = findOptimalInterval({
pixelsPerFrame,
pixelsPerSecond,
- fps,
+ fps: fpsFloat,
minSpacingPx: MIN_LABEL_SPACING_PX,
frameIntervals: LABEL_FRAME_INTERVALS,
});
@@ -70,7 +73,7 @@ export function getRulerConfig({
const rawTickIntervalSeconds = findOptimalInterval({
pixelsPerFrame,
pixelsPerSecond,
- fps,
+ fps: fpsFloat,
minSpacingPx: MIN_TICK_SPACING_PX,
frameIntervals: TICK_FRAME_INTERVALS,
});
@@ -81,7 +84,7 @@ export function getRulerConfig({
labelIntervalSeconds,
pixelsPerFrame,
pixelsPerSecond,
- fps,
+ fps: fpsFloat,
});
return { labelIntervalSeconds, tickIntervalSeconds };
@@ -197,13 +200,13 @@ export function formatRulerLabel({
fps,
}: {
timeInSeconds: number;
- fps: number;
+ fps: FrameRate;
}): string {
if (isSecondBoundary({ timeInSeconds })) {
return formatTimestamp({ timeInSeconds });
}
- const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps });
+ const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) });
return `${frameWithinSecond}f`;
}
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
index ef5d6e11..2e7bdcec 100644
--- a/apps/web/src/lib/timeline/snap-utils.ts
+++ b/apps/web/src/lib/timeline/snap-utils.ts
@@ -1,172 +1,172 @@
-import type { Bookmark, TimelineTrack } from "@/lib/timeline";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
-import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
-import { getElementKeyframes } from "@/lib/animation";
-
-export interface SnapPoint {
- time: number;
- type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
- elementId?: string;
- trackId?: string;
-}
-
-export interface SnapResult {
- snappedTime: number;
- snapPoint: SnapPoint | null;
- snapDistance: number;
-}
-
-const DEFAULT_SNAP_THRESHOLD_PX = 10;
-
-export function findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId,
- bookmarks = [],
- excludeBookmarkTime,
- enableElementSnapping = true,
- enablePlayheadSnapping = true,
- enableBookmarkSnapping = true,
- enableKeyframeSnapping = true,
-}: {
- tracks: Array<TimelineTrack>;
- playheadTime: number;
- excludeElementId?: string;
- bookmarks?: Array<Bookmark>;
- excludeBookmarkTime?: number;
- enableElementSnapping?: boolean;
- enablePlayheadSnapping?: boolean;
- enableBookmarkSnapping?: boolean;
- enableKeyframeSnapping?: boolean;
-}): SnapPoint[] {
- const snapPoints: SnapPoint[] = [];
-
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.id === excludeElementId) continue;
-
- if (enableElementSnapping) {
- snapPoints.push(
- {
- time: element.startTime,
- type: "element-start",
- elementId: element.id,
- trackId: track.id,
- },
- {
- time: element.startTime + element.duration,
- type: "element-end",
- elementId: element.id,
- trackId: track.id,
- },
- );
- }
-
- if (enableKeyframeSnapping) {
- for (const keyframe of getElementKeyframes({
- animations: element.animations,
- })) {
- snapPoints.push({
- time: element.startTime + keyframe.time,
- type: "keyframe",
- elementId: element.id,
- trackId: track.id,
- });
- }
- }
- }
- }
-
- if (enablePlayheadSnapping) {
- snapPoints.push({ time: playheadTime, type: "playhead" });
- }
-
- if (enableBookmarkSnapping) {
- for (const bookmark of bookmarks) {
- if (
- excludeBookmarkTime != null &&
- Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON
- ) {
- continue;
- }
- snapPoints.push({ time: bookmark.time, type: "bookmark" });
- }
- }
-
- return snapPoints;
-}
-
-export function snapToNearestPoint({
- targetTime,
- snapPoints,
- zoomLevel,
- snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
-}: {
- targetTime: number;
- snapPoints: Array<SnapPoint>;
- zoomLevel: number;
- snapThreshold?: number;
-}): SnapResult {
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const thresholdInSeconds = snapThreshold / pixelsPerSecond;
-
- let closestSnapPoint: SnapPoint | null = null;
- let closestDistance = Infinity;
-
- for (const snapPoint of snapPoints) {
- const distance = Math.abs(targetTime - snapPoint.time);
- if (distance < thresholdInSeconds && distance < closestDistance) {
- closestDistance = distance;
- closestSnapPoint = snapPoint;
- }
- }
-
- return {
- snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
- snapPoint: closestSnapPoint,
- snapDistance: closestDistance,
- };
-}
-
-export function snapElementEdge({
- targetTime,
- elementDuration,
- tracks,
- playheadTime,
- zoomLevel,
- excludeElementId,
- snapToStart = true,
- bookmarks = [],
-}: {
- targetTime: number;
- elementDuration: number;
- tracks: Array<TimelineTrack>;
- playheadTime: number;
- zoomLevel: number;
- excludeElementId?: string;
- snapToStart?: boolean;
- bookmarks?: Array<Bookmark>;
-}): SnapResult {
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId,
- bookmarks,
- });
-
- const effectiveTargetTime = snapToStart
- ? targetTime
- : targetTime + elementDuration;
-
- const snapResult = snapToNearestPoint({
- targetTime: effectiveTargetTime,
- snapPoints,
- zoomLevel,
- });
-
- if (!snapToStart && snapResult.snapPoint) {
- snapResult.snappedTime = snapResult.snappedTime - elementDuration;
- }
-
- return snapResult;
-}
+import type { Bookmark, TimelineTrack } from "@/lib/timeline";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
+import { getElementKeyframes } from "@/lib/animation";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
+
+export interface SnapPoint {
+ time: number;
+ type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe";
+ elementId?: string;
+ trackId?: string;
+}
+
+export interface SnapResult {
+ snappedTime: number;
+ snapPoint: SnapPoint | null;
+ snapDistance: number;
+}
+
+const DEFAULT_SNAP_THRESHOLD_PX = 10;
+
+export function findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks = [],
+ excludeBookmarkTime,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
+ enableKeyframeSnapping = true,
+}: {
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ excludeElementId?: string;
+ bookmarks?: Array<Bookmark>;
+ excludeBookmarkTime?: number;
+ enableElementSnapping?: boolean;
+ enablePlayheadSnapping?: boolean;
+ enableBookmarkSnapping?: boolean;
+ enableKeyframeSnapping?: boolean;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.id === excludeElementId) continue;
+
+ if (enableElementSnapping) {
+ snapPoints.push(
+ {
+ time: element.startTime,
+ type: "element-start",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ {
+ time: element.startTime + element.duration,
+ type: "element-end",
+ elementId: element.id,
+ trackId: track.id,
+ },
+ );
+ }
+
+ if (enableKeyframeSnapping) {
+ for (const keyframe of getElementKeyframes({
+ animations: element.animations,
+ })) {
+ snapPoints.push({
+ time: element.startTime + keyframe.time,
+ type: "keyframe",
+ elementId: element.id,
+ trackId: track.id,
+ });
+ }
+ }
+ }
+ }
+
+ if (enablePlayheadSnapping) {
+ snapPoints.push({ time: playheadTime, type: "playhead" });
+ }
+
+ if (enableBookmarkSnapping) {
+ for (const bookmark of bookmarks) {
+ if (
+ excludeBookmarkTime != null &&
+ bookmark.time === excludeBookmarkTime
+ ) {
+ continue;
+ }
+ snapPoints.push({ time: bookmark.time, type: "bookmark" });
+ }
+ }
+
+ return snapPoints;
+}
+
+export function snapToNearestPoint({
+ targetTime,
+ snapPoints,
+ zoomLevel,
+ snapThreshold = DEFAULT_SNAP_THRESHOLD_PX,
+}: {
+ targetTime: number;
+ snapPoints: Array<SnapPoint>;
+ zoomLevel: number;
+ snapThreshold?: number;
+}): SnapResult {
+ const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
+ const thresholdInTicks = (snapThreshold / pixelsPerSecond) * TICKS_PER_SECOND;
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestDistance = Infinity;
+
+ for (const snapPoint of snapPoints) {
+ const distance = Math.abs(targetTime - snapPoint.time);
+ if (distance < thresholdInTicks && distance < closestDistance) {
+ closestDistance = distance;
+ closestSnapPoint = snapPoint;
+ }
+ }
+
+ return {
+ snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime,
+ snapPoint: closestSnapPoint,
+ snapDistance: closestDistance,
+ };
+}
+
+export function snapElementEdge({
+ targetTime,
+ elementDuration,
+ tracks,
+ playheadTime,
+ zoomLevel,
+ excludeElementId,
+ snapToStart = true,
+ bookmarks = [],
+}: {
+ targetTime: number;
+ elementDuration: number;
+ tracks: Array<TimelineTrack>;
+ playheadTime: number;
+ zoomLevel: number;
+ excludeElementId?: string;
+ snapToStart?: boolean;
+ bookmarks?: Array<Bookmark>;
+}): SnapResult {
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId,
+ bookmarks,
+ });
+
+ const effectiveTargetTime = snapToStart
+ ? targetTime
+ : targetTime + elementDuration;
+
+ const snapResult = snapToNearestPoint({
+ targetTime: effectiveTargetTime,
+ snapPoints,
+ zoomLevel,
+ });
+
+ if (!snapToStart && snapResult.snapPoint) {
+ snapResult.snappedTime = snapResult.snappedTime - elementDuration;
+ }
+
+ return snapResult;
+}
diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts
index cef99abc..61d09a9d 100644
--- a/apps/web/src/lib/timeline/update-pipeline.ts
+++ b/apps/web/src/lib/timeline/update-pipeline.ts
@@ -8,7 +8,7 @@ import { enforceMainTrackStart } from "@/lib/timeline/placement";
import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
import { isRetimableElement } from "@/lib/timeline";
-type ElementUpdateField = keyof TimelineElement;
+type ElementUpdateField = keyof TimelineElement | string;
export interface ElementUpdateContext {
tracks: TimelineTrack[];
diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts
index c06957d0..680f8d0d 100644
--- a/apps/web/src/lib/timeline/zoom-utils.ts
+++ b/apps/web/src/lib/timeline/zoom-utils.ts
@@ -2,6 +2,7 @@ import {
BASE_TIMELINE_PIXELS_PER_SECOND,
TIMELINE_ZOOM_MAX,
} from "@/lib/timeline/scale";
+import { TICKS_PER_SECOND } from "@/lib/wasm";
const PADDING_MAX_RATIO = 0.75;
const PADDING_MIN_RATIO = 0.15;
@@ -14,12 +15,12 @@ export function getTimelineZoomMin({
duration: number;
containerWidth: number | null | undefined;
}): number {
- const safeDuration = Math.max(duration, 1);
+ const safeDurationSeconds = Math.max(duration / TICKS_PER_SECOND, 1);
const safeContainerWidth = containerWidth ?? 1000;
const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
const zoomToFit =
- availableWidth / (safeDuration * BASE_TIMELINE_PIXELS_PER_SECOND);
+ availableWidth / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND);
return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
}
diff --git a/apps/web/src/lib/wasm/constants.ts b/apps/web/src/lib/wasm/constants.ts
new file mode 100644
index 00000000..ababe6b3
--- /dev/null
+++ b/apps/web/src/lib/wasm/constants.ts
@@ -0,0 +1,3 @@
+import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm";
+
+export const TICKS_PER_SECOND = _TICKS_PER_SECOND();
diff --git a/apps/web/src/lib/wasm/index.ts b/apps/web/src/lib/wasm/index.ts
new file mode 100644
index 00000000..b04bfcf7
--- /dev/null
+++ b/apps/web/src/lib/wasm/index.ts
@@ -0,0 +1 @@
+export * from "./constants";