summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline
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/timeline
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/timeline')
-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
11 files changed, 892 insertions, 885 deletions
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);
}