summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/timeline')
-rw-r--r--apps/web/src/lib/timeline/audio-constants.ts2
-rw-r--r--apps/web/src/lib/timeline/audio-display.ts64
-rw-r--r--apps/web/src/lib/timeline/audio-separation/index.ts143
-rw-r--r--apps/web/src/lib/timeline/audio-state.ts116
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts145
-rw-r--r--apps/web/src/lib/timeline/creation.ts3
-rw-r--r--apps/web/src/lib/timeline/defaults.ts77
-rw-r--r--apps/web/src/lib/timeline/drag-utils.ts21
-rw-r--r--apps/web/src/lib/timeline/drag.ts42
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts426
-rw-r--r--apps/web/src/lib/timeline/group-move/build-group.ts87
-rw-r--r--apps/web/src/lib/timeline/group-move/index.ts12
-rw-r--r--apps/web/src/lib/timeline/group-move/resolve-move.ts470
-rw-r--r--apps/web/src/lib/timeline/group-move/snap.ts71
-rw-r--r--apps/web/src/lib/timeline/group-move/track-placement.ts83
-rw-r--r--apps/web/src/lib/timeline/group-move/types.ts36
-rw-r--r--apps/web/src/lib/timeline/group-resize/compute-resize.ts249
-rw-r--r--apps/web/src/lib/timeline/group-resize/index.ts9
-rw-r--r--apps/web/src/lib/timeline/group-resize/types.ts36
-rw-r--r--apps/web/src/lib/timeline/index.ts29
-rw-r--r--apps/web/src/lib/timeline/pixel-utils.ts80
-rw-r--r--apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts661
-rw-r--r--apps/web/src/lib/timeline/placement/apply.ts158
-rw-r--r--apps/web/src/lib/timeline/placement/compatibility.ts51
-rw-r--r--apps/web/src/lib/timeline/placement/index.ts12
-rw-r--r--apps/web/src/lib/timeline/placement/insert-index.ts88
-rw-r--r--apps/web/src/lib/timeline/placement/main-track.ts54
-rw-r--r--apps/web/src/lib/timeline/placement/overlap.ts44
-rw-r--r--apps/web/src/lib/timeline/placement/resolve.ts277
-rw-r--r--apps/web/src/lib/timeline/placement/track-factory.ts123
-rw-r--r--apps/web/src/lib/timeline/placement/types.ts39
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts257
-rw-r--r--apps/web/src/lib/timeline/scale.ts3
-rw-r--r--apps/web/src/lib/timeline/snap-utils.ts182
-rw-r--r--apps/web/src/lib/timeline/track-capabilities.ts20
-rw-r--r--apps/web/src/lib/timeline/track-element-update.ts147
-rw-r--r--apps/web/src/lib/timeline/tracks.ts9
-rw-r--r--apps/web/src/lib/timeline/types.ts314
-rw-r--r--apps/web/src/lib/timeline/update-pipeline.ts211
-rw-r--r--apps/web/src/lib/timeline/zoom-utils.ts90
40 files changed, 0 insertions, 4941 deletions
diff --git a/apps/web/src/lib/timeline/audio-constants.ts b/apps/web/src/lib/timeline/audio-constants.ts
deleted file mode 100644
index f5bb8a3e..00000000
--- a/apps/web/src/lib/timeline/audio-constants.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const VOLUME_DB_MIN = -60;
-export const VOLUME_DB_MAX = 20;
diff --git a/apps/web/src/lib/timeline/audio-display.ts b/apps/web/src/lib/timeline/audio-display.ts
deleted file mode 100644
index 9958c472..00000000
--- a/apps/web/src/lib/timeline/audio-display.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { clampDb } from "./audio-state";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants";
-
-const SLIDER_CURVE_EXPONENT = 2;
-const MIN_DISPLAY_DB = -40;
-const WAVEFORM_BAR_EXPONENT = 1.5;
-const MIN_LINEAR_GAIN = 10 ** (VOLUME_DB_MIN / 20);
-const MAX_LINEAR_GAIN = 10 ** (VOLUME_DB_MAX / 20);
-const LINEAR_GAIN_RANGE = MAX_LINEAR_GAIN - MIN_LINEAR_GAIN;
-
-function getNormalizedGainFromDb({ db }: { db: number }): number {
- const clampedDb = clampDb(db);
- const linearGain = 10 ** (clampedDb / 20);
- return (linearGain - MIN_LINEAR_GAIN) / LINEAR_GAIN_RANGE;
-}
-
-/**
- * Maps the clip's volume setting to the line position. The curve is defined in
- * linear gain space so dragging near 0 dB is precise while mute compresses into
- * the bottom of the clip.
- */
-export function getLinePosFromDb({ db }: { db: number }): number {
- const normalizedGain = Math.max(
- 0,
- Math.min(1, getNormalizedGainFromDb({ db })),
- );
- const progress = normalizedGain ** (1 / SLIDER_CURVE_EXPONENT);
- return (1 - progress) * 100;
-}
-
-/**
- * Inverse of getLinePosFromDb. Converts a drag position back into the clip's
- * volume setting without depending on the underlying audio content.
- */
-export function getDbFromLinePos({ percent }: { percent: number }): number {
- const clampedPercent = Math.max(0, Math.min(100, percent));
- const progress = 1 - clampedPercent / 100;
- const normalizedGain = progress ** SLIDER_CURVE_EXPONENT;
- const linearGain = MIN_LINEAR_GAIN + normalizedGain * LINEAR_GAIN_RANGE;
- return clampDb(20 * Math.log10(linearGain));
-}
-
-/**
- * Maps an output amplitude (raw sample amplitude × gain) to a visible waveform
- * height fraction using a dB scale. Agnostic to whether the input is peak or
- * RMS — the caller decides which measure feeds this function; the mapping
- * curve is the same either way.
- *
- * The log scale keeps quiet content visible while reserving the top of the
- * element for amplitudes that approach 0 dBFS.
- */
-export function getBarFractionFromOutputAmplitude({
- outputAmplitude,
-}: {
- outputAmplitude: number;
-}): number {
- if (outputAmplitude <= 0) return 0;
- const db = 20 * Math.log10(outputAmplitude);
- if (db <= MIN_DISPLAY_DB) return 0;
- return Math.min(
- 1,
- ((db - MIN_DISPLAY_DB) / -MIN_DISPLAY_DB) ** WAVEFORM_BAR_EXPONENT,
- );
-}
diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts
deleted file mode 100644
index 849dbda3..00000000
--- a/apps/web/src/lib/timeline/audio-separation/index.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { cloneAnimations } from "@/lib/animation";
-import type { ElementAnimations } from "@/lib/animation/types";
-import type { MediaAsset } from "@/lib/media/types";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type {
- CreateUploadAudioElement,
- TimelineElement,
- AudioElement,
- VideoElement,
-} from "../types";
-
-type MediaAudioState = Pick<MediaAsset, "hasAudio">;
-
-export function isSourceAudioEnabled({
- element,
-}: {
- element: VideoElement;
-}): boolean {
- return element.isSourceAudioEnabled !== false;
-}
-
-export function isSourceAudioSeparated({
- element,
-}: {
- element: VideoElement;
-}): boolean {
- return !isSourceAudioEnabled({ element });
-}
-
-export function canExtractSourceAudio(
- element: TimelineElement,
- mediaAsset: MediaAudioState | null | undefined,
-): element is VideoElement {
- return (
- element.type === "video" &&
- isSourceAudioEnabled({ element }) &&
- !!mediaAsset &&
- mediaAsset.hasAudio !== false
- );
-}
-
-export function canRecoverSourceAudio(
- element: TimelineElement,
-): element is VideoElement {
- return element.type === "video" && isSourceAudioSeparated({ element });
-}
-
-export function canToggleSourceAudio(
- element: TimelineElement,
- mediaAsset: MediaAudioState | null | undefined,
-): element is VideoElement {
- return (
- canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset)
- );
-}
-
-export function doesElementHaveEnabledAudio({
- element,
- mediaAsset,
-}: {
- element: AudioElement | VideoElement;
- mediaAsset?: MediaAudioState | null;
-}): boolean {
- if (element.type === "audio") {
- return true;
- }
-
- return (
- !!mediaAsset &&
- mediaAsset.hasAudio !== false &&
- isSourceAudioEnabled({ element })
- );
-}
-
-export function buildSeparatedAudioElement({
- sourceElement,
-}: {
- sourceElement: VideoElement;
-}): CreateUploadAudioElement {
- return {
- type: "audio",
- sourceType: "upload",
- mediaId: sourceElement.mediaId,
- name: sourceElement.name,
- duration: sourceElement.duration,
- startTime: sourceElement.startTime,
- trimStart: sourceElement.trimStart,
- trimEnd: sourceElement.trimEnd,
- sourceDuration: sourceElement.sourceDuration,
- volume: sourceElement.volume ?? DEFAULTS.element.volume,
- muted: sourceElement.muted ?? false,
- retime: sourceElement.retime
- ? {
- rate: sourceElement.retime.rate,
- maintainPitch: sourceElement.retime.maintainPitch,
- }
- : undefined,
- animations: cloneVolumeAnimations({
- animations: sourceElement.animations,
- }),
- };
-}
-
-export function getSourceAudioActionLabel({
- element,
-}: {
- element: VideoElement;
-}): "Extract audio" | "Recover audio" {
- return isSourceAudioSeparated({ element })
- ? "Recover audio"
- : "Extract audio";
-}
-
-function cloneVolumeAnimations({
- animations,
-}: {
- animations: ElementAnimations | undefined;
-}): ElementAnimations | undefined {
- const volumeBinding = animations?.bindings.volume;
- if (!volumeBinding) {
- return undefined;
- }
-
- const subsetChannels = Object.fromEntries(
- volumeBinding.components.flatMap((component) => {
- const channel = animations?.channels[component.channelId];
- return channel ? [[component.channelId, channel] as const] : [];
- }),
- );
- if (Object.keys(subsetChannels).length === 0) {
- return undefined;
- }
-
- return cloneAnimations({
- animations: {
- bindings: {
- volume: volumeBinding,
- },
- channels: subsetChannels,
- },
- shouldRegenerateKeyframeIds: true,
- });
-}
diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts
deleted file mode 100644
index 165ece94..00000000
--- a/apps/web/src/lib/timeline/audio-state.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-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 buildWaveformGainSamples({
- element,
- count,
-}: {
- element: AudioCapableElement;
- count: number;
-}): number[] {
- const durationSeconds = element.duration / TICKS_PER_SECOND;
- return Array.from({ length: count }, (_, i) => {
- const localTime = ((i + 0.5) / count) * durationSeconds;
- return resolveEffectiveAudioGain({ element, localTime });
- });
-}
-
-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
deleted file mode 100644
index 1fbf17bf..00000000
--- a/apps/web/src/lib/timeline/bookmarks.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import type { Bookmark } from "@/lib/timeline";
-import type { FrameRate } from "opencut-wasm";
-import { roundToFrame } from "opencut-wasm";
-
-function bookmarkTimeEqual({
- bookmarkTime,
- frameTime,
-}: {
- bookmarkTime: number;
- frameTime: number;
-}): boolean {
- return bookmarkTime === frameTime;
-}
-
-export function findBookmarkIndex({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): number {
- return bookmarks.findIndex((bookmark) =>
- bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function isBookmarkAtTime({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): boolean {
- return bookmarks.some((bookmark) =>
- bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function toggleBookmarkInArray({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark[] {
- const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
-
- if (bookmarkIndex !== -1) {
- return bookmarks.filter((_, index) => index !== bookmarkIndex);
- }
-
- const newBookmarks = [...bookmarks, { time: frameTime }];
- return newBookmarks.slice().sort((a, b) => a.time - b.time);
-}
-
-export function removeBookmarkFromArray({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark[] {
- return bookmarks.filter(
- (bookmark) =>
- !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
- );
-}
-
-export function updateBookmarkInArray({
- bookmarks,
- frameTime,
- updates,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
- updates: Partial<Omit<Bookmark, "time">>;
-}): Bookmark[] {
- const index = findBookmarkIndex({ bookmarks, frameTime });
- if (index === -1) {
- return bookmarks;
- }
-
- const updated = { ...bookmarks[index], ...updates };
- const result = [...bookmarks];
- result[index] = updated;
- return result;
-}
-
-export function moveBookmarkInArray({
- bookmarks,
- fromTime,
- toTime,
-}: {
- bookmarks: Bookmark[];
- fromTime: number;
- toTime: number;
-}): Bookmark[] {
- const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
- if (index === -1) {
- return bookmarks;
- }
-
- const updated = { ...bookmarks[index], time: toTime };
- const result = [...bookmarks];
- result[index] = updated;
- return result.slice().sort((a, b) => a.time - b.time);
-}
-
-export function getFrameTime({
- time,
- fps,
-}: {
- time: number;
- fps: FrameRate;
-}): number {
- return roundToFrame({ time, rate: fps }) ?? time;
-}
-
-export function getBookmarkAtTime({
- bookmarks,
- frameTime,
-}: {
- bookmarks: Bookmark[];
- frameTime: number;
-}): Bookmark | null {
- const index = findBookmarkIndex({ bookmarks, frameTime });
- return index === -1 ? null : bookmarks[index];
-}
-
-export function getBookmarksActiveAtTime({
- bookmarks,
- time,
-}: {
- bookmarks: Bookmark[];
- time: number;
-}): Bookmark[] {
- return bookmarks.filter((bookmark) => {
- const start = bookmark.time;
- const end =
- bookmark.duration != null && bookmark.duration > 0
- ? start + bookmark.duration
- : start;
- return time >= start && time <= end;
- });
-}
diff --git a/apps/web/src/lib/timeline/creation.ts b/apps/web/src/lib/timeline/creation.ts
deleted file mode 100644
index 19f46115..00000000
--- a/apps/web/src/lib/timeline/creation.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { TICKS_PER_SECOND } from "@/lib/wasm/ticks";
-
-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
deleted file mode 100644
index cf452800..00000000
--- a/apps/web/src/lib/timeline/defaults.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-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
deleted file mode 100644
index 35d00613..00000000
--- a/apps/web/src/lib/timeline/drag-utils.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-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/drag.ts b/apps/web/src/lib/timeline/drag.ts
deleted file mode 100644
index 6e74e17c..00000000
--- a/apps/web/src/lib/timeline/drag.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { MaskableElement, VisualElement } from "./types";
-import type { ParamValues } from "@/lib/params";
-
-interface BaseDragData {
- id: string;
- name: string;
-}
-
-export interface MediaDragData extends BaseDragData {
- type: "media";
- mediaType: "image" | "video" | "audio";
- targetElementTypes?: MaskableElement["type"][];
-}
-
-export interface TextDragData extends BaseDragData {
- type: "text";
- content: string;
-}
-
-export interface StickerDragData extends BaseDragData {
- type: "sticker";
- stickerId: string;
-}
-
-export interface GraphicDragData extends BaseDragData {
- type: "graphic";
- definitionId: string;
- params: Partial<ParamValues>;
-}
-
-export interface EffectDragData extends BaseDragData {
- type: "effect";
- effectType: string;
- targetElementTypes: VisualElement["type"][];
-}
-
-export type TimelineDragData =
- | MediaDragData
- | TextDragData
- | StickerDragData
- | GraphicDragData
- | EffectDragData;
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
deleted file mode 100644
index e7ec4065..00000000
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ /dev/null
@@ -1,426 +0,0 @@
-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 SceneTracks,
- type TimelineElement,
- 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: SceneTracks;
- time: number;
-}): { trackId: string; elementId: string }[] {
- const result: { trackId: string; elementId: string }[] = [];
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
-
- for (const track of orderedTracks) {
- 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: SceneTracks;
-}): string[] {
- const families = new Set<string>();
- for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) {
- for (const element of track.elements) {
- if (element.type === "text" && element.fontFamily) {
- families.add(element.fontFamily);
- }
- if ("masks" in element) {
- for (const mask of element.masks ?? []) {
- if (mask.type === "text" && mask.params.fontFamily) {
- families.add(mask.params.fontFamily);
- }
- }
- }
- }
- }
- return [...families];
-}
diff --git a/apps/web/src/lib/timeline/group-move/build-group.ts b/apps/web/src/lib/timeline/group-move/build-group.ts
deleted file mode 100644
index 16d720eb..00000000
--- a/apps/web/src/lib/timeline/group-move/build-group.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import type { ElementRef, SceneTracks } from "@/lib/timeline";
-import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update";
-import type { GroupMember, MoveGroup } from "./types";
-import { getTrackPlacementById } from "./track-placement";
-
-export function buildMoveGroup({
- anchorRef,
- selectedElements,
- tracks,
-}: {
- anchorRef: ElementRef;
- selectedElements: ElementRef[];
- tracks: SceneTracks;
-}): MoveGroup | null {
- const anchorTrack = findTrackInSceneTracks({
- tracks,
- trackId: anchorRef.trackId,
- });
- const anchorElement = anchorTrack?.elements.find(
- (element) => element.id === anchorRef.elementId,
- );
- const anchorPlacement = getTrackPlacementById({
- tracks,
- trackId: anchorRef.trackId,
- });
- if (!anchorTrack || !anchorElement || !anchorPlacement) {
- return null;
- }
-
- const seen = new Set<string>();
- const orderedRefs = [anchorRef, ...selectedElements].filter((elementRef) => {
- if (seen.has(elementRef.elementId)) {
- return false;
- }
-
- seen.add(elementRef.elementId);
- return true;
- });
-
- const members = orderedRefs.flatMap((elementRef): GroupMember[] => {
- const track = findTrackInSceneTracks({
- tracks,
- trackId: elementRef.trackId,
- });
- const element = track?.elements.find(
- (trackElement) => trackElement.id === elementRef.elementId,
- );
- const placement = getTrackPlacementById({
- tracks,
- trackId: elementRef.trackId,
- });
- if (!track || !element || !placement) {
- return [];
- }
-
- return [
- {
- trackId: track.id,
- elementId: element.id,
- elementType: element.type,
- duration: element.duration,
- timeOffset: element.startTime - anchorElement.startTime,
- trackSection: placement.section,
- sectionIndex: placement.sectionIndex,
- displayIndex: placement.displayIndex,
- },
- ];
- });
-
- if (members.length === 0) {
- return null;
- }
-
- const anchor = members.find(
- (member) =>
- member.trackId === anchorRef.trackId &&
- member.elementId === anchorRef.elementId,
- );
- if (!anchor) {
- return null;
- }
-
- return {
- anchor,
- members,
- };
-}
diff --git a/apps/web/src/lib/timeline/group-move/index.ts b/apps/web/src/lib/timeline/group-move/index.ts
deleted file mode 100644
index 82bd3895..00000000
--- a/apps/web/src/lib/timeline/group-move/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export { buildMoveGroup } from "./build-group";
-export { resolveGroupMove } from "./resolve-move";
-export { snapGroupEdges } from "./snap";
-
-export type {
- GroupMember,
- GroupMoveResult,
- GroupTrackSection,
- MoveGroup,
- PlannedElementMove,
- PlannedTrackCreation,
-} from "./types";
diff --git a/apps/web/src/lib/timeline/group-move/resolve-move.ts b/apps/web/src/lib/timeline/group-move/resolve-move.ts
deleted file mode 100644
index fbe4cbda..00000000
--- a/apps/web/src/lib/timeline/group-move/resolve-move.ts
+++ /dev/null
@@ -1,470 +0,0 @@
-import type { SceneTracks } from "@/lib/timeline";
-import { getTrackTypeForElementType } from "@/lib/timeline/placement/compatibility";
-import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap";
-import type {
- GroupMoveResult,
- MoveGroup,
- PlannedElementMove,
- PlannedTrackCreation,
-} from "./types";
-import {
- getDisplayTracks,
- getTrackPlacementByDisplayIndex,
- getTrackPlacementById,
-} from "./track-placement";
-
-type GroupMoveTarget =
- | {
- kind: "existingTrack";
- anchorTargetTrackId: string;
- }
- | {
- kind: "newTracks";
- anchorInsertIndex: number;
- newTrackIds: string[];
- };
-
-export function resolveGroupMove({
- group,
- tracks,
- anchorStartTime,
- target,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- target: GroupMoveTarget;
-}): GroupMoveResult | null {
- if (target.kind === "newTracks") {
- return resolveNewTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorInsertIndex: target.anchorInsertIndex,
- newTrackIds: target.newTrackIds,
- });
- }
-
- return resolveExistingTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorTargetTrackId: target.anchorTargetTrackId,
- });
-}
-
-function resolveExistingTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorTargetTrackId,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- anchorTargetTrackId: string;
-}): GroupMoveResult | null {
- const anchorTargetPlacement = getTrackPlacementById({
- tracks,
- trackId: anchorTargetTrackId,
- });
- if (!anchorTargetPlacement) {
- return null;
- }
-
- const targetTrackIdsByElementId = resolveExistingTrackIdsByElementId({
- group,
- tracks,
- anchorTargetDisplayIndex: anchorTargetPlacement.displayIndex,
- });
- if (!targetTrackIdsByElementId) {
- return null;
- }
-
- const clampedAnchorStartTime = clampAnchorStartTime({
- group,
- tracks,
- anchorStartTime,
- targetTrackIdsByElementId,
- });
-
- const moves = group.members.map((member) => ({
- sourceTrackId: member.trackId,
- targetTrackId:
- targetTrackIdsByElementId.get(member.elementId) ?? member.trackId,
- elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
- }));
-
- if (!canApplyMovesToExistingTracks({ tracks, moves })) {
- return null;
- }
-
- return {
- moves,
- createTracks: [],
- targetSelection: moves.map(({ elementId, targetTrackId }) => ({
- trackId: targetTrackId,
- elementId,
- })),
- };
-}
-
-function resolveNewTrackMove({
- group,
- tracks,
- anchorStartTime,
- anchorInsertIndex,
- newTrackIds,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- anchorInsertIndex: number;
- newTrackIds: string[];
-}): GroupMoveResult | null {
- const sortedMembers = [...group.members].sort(
- (leftMember, rightMember) =>
- leftMember.displayIndex - rightMember.displayIndex,
- );
- const anchorMemberIndex = sortedMembers.findIndex(
- (member) => member.elementId === group.anchor.elementId,
- );
- if (anchorMemberIndex < 0 || newTrackIds.length < sortedMembers.length) {
- return null;
- }
-
- const hasAudioMember = sortedMembers.some(
- (member) => member.trackSection === "audio",
- );
- const hasNonAudioMember = sortedMembers.some(
- (member) => member.trackSection !== "audio",
- );
- if (hasAudioMember && hasNonAudioMember) {
- return null;
- }
-
- const clampedAnchorStartTime = clampAnchorStartTime({
- group,
- tracks,
- anchorStartTime,
- targetTrackIdsByElementId: new Map(),
- });
- const blockStartIndex = hasAudioMember
- ? clampAudioInsertIndex({
- tracks,
- insertIndex: anchorInsertIndex - anchorMemberIndex,
- })
- : Math.max(
- 0,
- Math.min(anchorInsertIndex - anchorMemberIndex, tracks.overlay.length),
- );
-
- const createTracks: PlannedTrackCreation[] = sortedMembers.map(
- (member, memberIndex) => ({
- id: newTrackIds[memberIndex],
- type: getTrackTypeForElementType({
- elementType: member.elementType,
- }),
- index: blockStartIndex + memberIndex,
- }),
- );
- const moves = sortedMembers.map((member, memberIndex) => ({
- sourceTrackId: member.trackId,
- targetTrackId: newTrackIds[memberIndex],
- elementId: member.elementId,
- newStartTime: clampedAnchorStartTime + member.timeOffset,
- }));
-
- return {
- moves,
- createTracks,
- targetSelection: moves.map(({ elementId, targetTrackId }) => ({
- trackId: targetTrackId,
- elementId,
- })),
- };
-}
-
-function clampAudioInsertIndex({
- tracks,
- insertIndex,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
-}): number {
- const minimumAudioInsertIndex = tracks.overlay.length + 1;
- return Math.max(
- minimumAudioInsertIndex,
- Math.min(insertIndex, minimumAudioInsertIndex + tracks.audio.length),
- );
-}
-
-function resolveExistingTrackIdsByElementId({
- group,
- tracks,
- anchorTargetDisplayIndex,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorTargetDisplayIndex: number;
-}): Map<string, string> | null {
- const sortedMembers = [...group.members].sort(
- (leftMember, rightMember) =>
- leftMember.displayIndex - rightMember.displayIndex,
- );
- const anchorMemberIndex = sortedMembers.findIndex(
- (member) => member.elementId === group.anchor.elementId,
- );
- if (anchorMemberIndex < 0) {
- return null;
- }
-
- const targetTrackIdsByElementId = new Map<string, string>();
- const usedTrackIds = new Set<string>();
- const anchorPlacement = getTrackPlacementByDisplayIndex({
- tracks,
- displayIndex: anchorTargetDisplayIndex,
- });
- if (!anchorPlacement) {
- return null;
- }
-
- targetTrackIdsByElementId.set(
- group.anchor.elementId,
- anchorPlacement.trackId,
- );
- usedTrackIds.add(anchorPlacement.trackId);
-
- let upperBoundaryIndex = anchorTargetDisplayIndex;
- for (
- let memberIndex = anchorMemberIndex - 1;
- memberIndex >= 0;
- memberIndex -= 1
- ) {
- const member = sortedMembers[memberIndex];
- const targetPlacement = findCompatibleTrackPlacement({
- tracks,
- requiredTrackType: getTrackTypeForElementType({
- elementType: member.elementType,
- }),
- startDisplayIndex: upperBoundaryIndex - 1,
- step: -1,
- usedTrackIds,
- });
- if (!targetPlacement) {
- return null;
- }
-
- targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
- usedTrackIds.add(targetPlacement.trackId);
- upperBoundaryIndex = targetPlacement.displayIndex;
- }
-
- let lowerBoundaryIndex = anchorTargetDisplayIndex;
- for (
- let memberIndex = anchorMemberIndex + 1;
- memberIndex < sortedMembers.length;
- memberIndex += 1
- ) {
- const member = sortedMembers[memberIndex];
- const targetPlacement = findCompatibleTrackPlacement({
- tracks,
- requiredTrackType: getTrackTypeForElementType({
- elementType: member.elementType,
- }),
- startDisplayIndex: lowerBoundaryIndex + 1,
- step: 1,
- usedTrackIds,
- });
- if (!targetPlacement) {
- return null;
- }
-
- targetTrackIdsByElementId.set(member.elementId, targetPlacement.trackId);
- usedTrackIds.add(targetPlacement.trackId);
- lowerBoundaryIndex = targetPlacement.displayIndex;
- }
-
- return targetTrackIdsByElementId;
-}
-
-function findCompatibleTrackPlacement({
- tracks,
- requiredTrackType,
- startDisplayIndex,
- step,
- usedTrackIds,
-}: {
- tracks: SceneTracks;
- requiredTrackType: ReturnType<typeof getTrackTypeForElementType>;
- startDisplayIndex: number;
- step: -1 | 1;
- usedTrackIds: Set<string>;
-}) {
- for (
- let displayIndex = startDisplayIndex;
- displayIndex >= 0 &&
- displayIndex < tracks.overlay.length + 1 + tracks.audio.length;
- displayIndex += step
- ) {
- const placement = getTrackPlacementByDisplayIndex({
- tracks,
- displayIndex,
- });
- if (!placement) {
- continue;
- }
-
- if (
- placement.trackType === requiredTrackType &&
- !usedTrackIds.has(placement.trackId)
- ) {
- return placement;
- }
- }
-
- return null;
-}
-
-function clampAnchorStartTime({
- group,
- tracks,
- anchorStartTime,
- targetTrackIdsByElementId,
-}: {
- group: MoveGroup;
- tracks: SceneTracks;
- anchorStartTime: number;
- targetTrackIdsByElementId: Map<string, string>;
-}): number {
- const minimumAnchorStartTime = Math.max(
- 0,
- ...group.members.map((member) => -member.timeOffset),
- );
- let clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- anchorStartTime,
- );
-
- const memberOnMainTrack = group.members.find(
- (member) =>
- targetTrackIdsByElementId.get(member.elementId) === tracks.main.id,
- );
- if (!memberOnMainTrack) {
- return clampedAnchorStartTime;
- }
-
- const movingElementIds = new Set(
- group.members.map((member) => member.elementId),
- );
- const requestedMainStartTime =
- clampedAnchorStartTime + memberOnMainTrack.timeOffset;
- const earliestStationaryMainStartTime = tracks.main.elements
- .filter((element) => !movingElementIds.has(element.id))
- .reduce<number | null>((earliestStartTime, element) => {
- if (earliestStartTime == null || element.startTime < earliestStartTime) {
- return element.startTime;
- }
-
- return earliestStartTime;
- }, null);
- if (
- earliestStationaryMainStartTime == null ||
- requestedMainStartTime <= earliestStationaryMainStartTime
- ) {
- clampedAnchorStartTime = Math.max(
- minimumAnchorStartTime,
- -memberOnMainTrack.timeOffset,
- );
- }
-
- return clampedAnchorStartTime;
-}
-
-function canApplyMovesToExistingTracks({
- tracks,
- moves,
-}: {
- tracks: SceneTracks;
- moves: PlannedElementMove[];
-}): boolean {
- const movingElementIds = new Set(moves.map((move) => move.elementId));
- const sourceElements = new Map(
- getDisplayTracks({ tracks }).flatMap((track) =>
- track.elements.map((element) => [element.id, element] as const),
- ),
- );
- const movesByTargetTrackId = new Map<string, PlannedElementMove[]>();
- for (const move of moves) {
- const targetMoves = movesByTargetTrackId.get(move.targetTrackId) ?? [];
- targetMoves.push(move);
- movesByTargetTrackId.set(move.targetTrackId, targetMoves);
- }
-
- for (const [targetTrackId, targetMoves] of movesByTargetTrackId) {
- const targetPlacement = getTrackPlacementById({
- tracks,
- trackId: targetTrackId,
- });
- if (!targetPlacement) {
- return false;
- }
-
- const targetTrack = getDisplayTracks({ tracks })[
- targetPlacement.displayIndex
- ];
- if (!targetTrack) {
- return false;
- }
-
- const timeSpans = targetMoves.map((move) => {
- const sourceElement = sourceElements.get(move.elementId);
- return {
- startTime: move.newStartTime,
- duration: sourceElement?.duration ?? 0,
- };
- });
- if (hasOverlappingTimeSpans({ timeSpans })) {
- return false;
- }
-
- if (
- !canPlaceTimeSpansOnTrack({
- track: {
- elements: targetTrack.elements.filter(
- (element) => !movingElementIds.has(element.id),
- ),
- },
- timeSpans,
- })
- ) {
- return false;
- }
- }
-
- return true;
-}
-
-function hasOverlappingTimeSpans({
- timeSpans,
-}: {
- timeSpans: Array<{ startTime: number; duration: number }>;
-}): boolean {
- const sortedSpans = [...timeSpans].sort(
- (leftSpan, rightSpan) => leftSpan.startTime - rightSpan.startTime,
- );
-
- for (let spanIndex = 1; spanIndex < sortedSpans.length; spanIndex += 1) {
- const previousSpan = sortedSpans[spanIndex - 1];
- const currentSpan = sortedSpans[spanIndex];
- if (
- previousSpan.startTime + previousSpan.duration >
- currentSpan.startTime
- ) {
- return true;
- }
- }
-
- return false;
-}
diff --git a/apps/web/src/lib/timeline/group-move/snap.ts b/apps/web/src/lib/timeline/group-move/snap.ts
deleted file mode 100644
index 3502ae30..00000000
--- a/apps/web/src/lib/timeline/group-move/snap.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import type { SceneTracks } from "@/lib/timeline";
-import {
- findSnapPoints,
- snapToNearestPoint,
- type SnapPoint,
-} from "@/lib/timeline/snap-utils";
-import type { MoveGroup } from "./types";
-
-export function snapGroupEdges({
- group,
- anchorStartTime,
- tracks,
- playheadTime,
- zoomLevel,
-}: {
- group: MoveGroup;
- anchorStartTime: number;
- tracks: SceneTracks;
- playheadTime: number;
- zoomLevel: number;
-}): {
- snappedAnchorStartTime: number;
- snapPoint: SnapPoint | null;
-} {
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementIds: new Set(group.members.map((member) => member.elementId)),
- });
-
- let closestSnapDistance = Infinity;
- let snappedAnchorStartTime = anchorStartTime;
- let snapPoint: SnapPoint | null = null;
-
- for (const member of group.members) {
- const memberStartTime = anchorStartTime + member.timeOffset;
- const memberStartSnap = snapToNearestPoint({
- targetTime: memberStartTime,
- snapPoints,
- zoomLevel,
- });
- if (
- memberStartSnap.snapPoint &&
- memberStartSnap.snapDistance < closestSnapDistance
- ) {
- closestSnapDistance = memberStartSnap.snapDistance;
- snappedAnchorStartTime = memberStartSnap.snappedTime - member.timeOffset;
- snapPoint = memberStartSnap.snapPoint;
- }
-
- const memberEndSnap = snapToNearestPoint({
- targetTime: memberStartTime + member.duration,
- snapPoints,
- zoomLevel,
- });
- if (
- memberEndSnap.snapPoint &&
- memberEndSnap.snapDistance < closestSnapDistance
- ) {
- closestSnapDistance = memberEndSnap.snapDistance;
- snappedAnchorStartTime =
- memberEndSnap.snappedTime - member.duration - member.timeOffset;
- snapPoint = memberEndSnap.snapPoint;
- }
- }
-
- return {
- snappedAnchorStartTime,
- snapPoint,
- };
-}
diff --git a/apps/web/src/lib/timeline/group-move/track-placement.ts b/apps/web/src/lib/timeline/group-move/track-placement.ts
deleted file mode 100644
index d301e5ef..00000000
--- a/apps/web/src/lib/timeline/group-move/track-placement.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import type { SceneTracks, TimelineTrack } from "@/lib/timeline";
-import type { GroupTrackSection } from "./types";
-
-export interface TrackPlacement {
- trackId: string;
- trackType: TimelineTrack["type"];
- section: GroupTrackSection;
- sectionIndex: number;
- displayIndex: number;
-}
-
-export function getDisplayTracks({
- tracks,
-}: {
- tracks: SceneTracks;
-}): TimelineTrack[] {
- return [...tracks.overlay, tracks.main, ...tracks.audio];
-}
-
-export function getTrackPlacementById({
- tracks,
- trackId,
-}: {
- tracks: SceneTracks;
- trackId: string;
-}): TrackPlacement | null {
- if (tracks.main.id === trackId) {
- return {
- trackId,
- trackType: tracks.main.type,
- section: "main",
- sectionIndex: -1,
- displayIndex: tracks.overlay.length,
- };
- }
-
- const overlayTrackIndex = tracks.overlay.findIndex(
- (track) => track.id === trackId,
- );
- if (overlayTrackIndex >= 0) {
- return {
- trackId,
- trackType: tracks.overlay[overlayTrackIndex].type,
- section: "overlay",
- sectionIndex: overlayTrackIndex,
- displayIndex: overlayTrackIndex,
- };
- }
-
- const audioTrackIndex = tracks.audio.findIndex(
- (track) => track.id === trackId,
- );
- if (audioTrackIndex >= 0) {
- return {
- trackId,
- trackType: tracks.audio[audioTrackIndex].type,
- section: "audio",
- sectionIndex: audioTrackIndex,
- displayIndex: tracks.overlay.length + 1 + audioTrackIndex,
- };
- }
-
- return null;
-}
-
-export function getTrackPlacementByDisplayIndex({
- tracks,
- displayIndex,
-}: {
- tracks: SceneTracks;
- displayIndex: number;
-}): TrackPlacement | null {
- const displayTracks = getDisplayTracks({ tracks });
- const track = displayTracks[displayIndex];
- if (!track) {
- return null;
- }
-
- return getTrackPlacementById({
- tracks,
- trackId: track.id,
- });
-}
diff --git a/apps/web/src/lib/timeline/group-move/types.ts b/apps/web/src/lib/timeline/group-move/types.ts
deleted file mode 100644
index 5ac1e207..00000000
--- a/apps/web/src/lib/timeline/group-move/types.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { ElementRef, ElementType, TrackType } from "@/lib/timeline";
-
-export type GroupTrackSection = "overlay" | "main" | "audio";
-
-export interface GroupMember extends ElementRef {
- elementType: ElementType;
- duration: number;
- timeOffset: number;
- trackSection: GroupTrackSection;
- sectionIndex: number;
- displayIndex: number;
-}
-
-export interface MoveGroup {
- anchor: GroupMember;
- members: GroupMember[];
-}
-
-export interface PlannedTrackCreation {
- id: string;
- type: TrackType;
- index: number;
-}
-
-export interface PlannedElementMove {
- sourceTrackId: string;
- targetTrackId: string;
- elementId: string;
- newStartTime: number;
-}
-
-export interface GroupMoveResult {
- moves: PlannedElementMove[];
- createTracks: PlannedTrackCreation[];
- targetSelection: ElementRef[];
-}
diff --git a/apps/web/src/lib/timeline/group-resize/compute-resize.ts b/apps/web/src/lib/timeline/group-resize/compute-resize.ts
deleted file mode 100644
index 5730b117..00000000
--- a/apps/web/src/lib/timeline/group-resize/compute-resize.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-import { roundToFrame } from "opencut-wasm";
-import {
- getSourceSpanAtClipTime,
- getTimelineDurationForSourceSpan,
-} from "@/lib/retime";
-import { TICKS_PER_SECOND } from "@/lib/wasm";
-import type {
- ComputeGroupResizeArgs,
- GroupResizeMember,
- GroupResizeResult,
- GroupResizeUpdate,
- ResizeSide,
-} from "./types";
-
-export function computeGroupResize({
- members,
- side,
- deltaTime,
- fps,
-}: ComputeGroupResizeArgs): GroupResizeResult {
- const minDuration = Math.round(
- (TICKS_PER_SECOND * fps.denominator) / fps.numerator,
- );
- const minimumDeltaTime = Math.max(
- ...members.map((member) =>
- getMinimumAllowedDeltaTime({
- member,
- side,
- minDuration,
- }),
- ),
- );
- const maximumDeltaTime = Math.min(
- ...members.map((member) =>
- getMaximumAllowedDeltaTime({
- member,
- side,
- minDuration,
- }),
- ),
- );
- const clampedDeltaTime =
- minimumDeltaTime > maximumDeltaTime
- ? minimumDeltaTime
- : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
-
- // Snap the drag delta to a frame exactly once, then derive every patch
- // field from that single snapped value. This keeps the invariant
- // `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
- // delta is added on one side of the element and removed from the other,
- // so the rounding cancels by construction. Per-field rounding (the old
- // approach) couldn't preserve this because the individual rounds don't
- // compose when `sourceDuration` isn't frame-aligned.
- const snappedDeltaTime =
- roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
- // Re-clamp after rounding. Bounds derived from other elements are
- // frame-aligned, so this is normally a no-op; at the source-extent limit
- // the bound may not be frame-aligned, and honouring the bound takes
- // precedence over frame alignment (you can't extend past real content).
- const finalDeltaTime =
- minimumDeltaTime > maximumDeltaTime
- ? minimumDeltaTime
- : Math.min(
- maximumDeltaTime,
- Math.max(minimumDeltaTime, snappedDeltaTime),
- );
-
- return {
- deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
- updates: members.map((member) =>
- buildResizeUpdate({
- member,
- side,
- deltaTime: finalDeltaTime,
- }),
- ),
- };
-}
-
-function buildResizeUpdate({
- member,
- side,
- deltaTime,
-}: {
- member: GroupResizeMember;
- side: ResizeSide;
- deltaTime: number;
-}): GroupResizeUpdate {
- const sourceDelta = getSourceDeltaForClipDelta({
- member,
- clipDelta: deltaTime,
- });
-
- if (side === "left") {
- return {
- trackId: member.trackId,
- elementId: member.elementId,
- patch: {
- trimStart: Math.max(0, member.trimStart + sourceDelta),
- trimEnd: member.trimEnd,
- startTime: member.startTime + deltaTime,
- duration: member.duration - deltaTime,
- },
- };
- }
-
- return {
- trackId: member.trackId,
- elementId: member.elementId,
- patch: {
- trimStart: member.trimStart,
- trimEnd: Math.max(0, member.trimEnd - sourceDelta),
- startTime: member.startTime,
- duration: member.duration + deltaTime,
- },
- };
-}
-
-function getMinimumAllowedDeltaTime({
- member,
- side,
- minDuration,
-}: {
- member: GroupResizeMember;
- side: ResizeSide;
- minDuration: number;
-}): number {
- if (side === "right") {
- return minDuration - member.duration;
- }
-
- const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
- ? member.leftNeighborBound - member.startTime
- : -member.startTime;
- if (member.sourceDuration == null) {
- return leftNeighborFloor;
- }
-
- const maximumSourceExtension =
- getDurationForVisibleSourceSpan({
- member,
- sourceSpan:
- getVisibleSourceSpanForDuration({
- member,
- duration: member.duration,
- }) + member.trimStart,
- }) - member.duration;
- return Math.max(leftNeighborFloor, -maximumSourceExtension);
-}
-
-function getMaximumAllowedDeltaTime({
- member,
- side,
- minDuration,
-}: {
- member: GroupResizeMember;
- side: ResizeSide;
- minDuration: number;
-}): number {
- if (side === "left") {
- return member.duration - minDuration;
- }
-
- const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
- ? member.rightNeighborBound - (member.startTime + member.duration)
- : Infinity;
- if (member.sourceDuration == null) {
- return rightNeighborCeiling;
- }
-
- const maximumVisibleSourceSpan =
- getSourceDuration({ member }) - member.trimStart;
- const maximumDuration = getDurationForVisibleSourceSpan({
- member,
- sourceSpan: maximumVisibleSourceSpan,
- });
- return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
-}
-
-function getSourceDeltaForClipDelta({
- member,
- clipDelta,
-}: {
- member: GroupResizeMember;
- clipDelta: number;
-}): number {
- if (!member.retime) {
- return clipDelta;
- }
-
- return clipDelta >= 0
- ? getSourceSpanAtClipTime({
- clipTime: clipDelta,
- retime: member.retime,
- })
- : -getSourceSpanAtClipTime({
- clipTime: Math.abs(clipDelta),
- retime: member.retime,
- });
-}
-
-function getVisibleSourceSpanForDuration({
- member,
- duration,
-}: {
- member: GroupResizeMember;
- duration: number;
-}): number {
- if (!member.retime) {
- return duration;
- }
-
- return getSourceSpanAtClipTime({
- clipTime: duration,
- retime: member.retime,
- });
-}
-
-function getDurationForVisibleSourceSpan({
- member,
- sourceSpan,
-}: {
- member: GroupResizeMember;
- sourceSpan: number;
-}): number {
- if (!member.retime) {
- return sourceSpan;
- }
-
- return getTimelineDurationForSourceSpan({
- sourceSpan,
- retime: member.retime,
- });
-}
-
-function getSourceDuration({ member }: { member: GroupResizeMember }): number {
- if (typeof member.sourceDuration === "number") {
- return member.sourceDuration;
- }
-
- return (
- member.trimStart +
- getVisibleSourceSpanForDuration({
- member,
- duration: member.duration,
- }) +
- member.trimEnd
- );
-}
diff --git a/apps/web/src/lib/timeline/group-resize/index.ts b/apps/web/src/lib/timeline/group-resize/index.ts
deleted file mode 100644
index bc0a7b35..00000000
--- a/apps/web/src/lib/timeline/group-resize/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export { computeGroupResize } from "./compute-resize";
-
-export type {
- ComputeGroupResizeArgs,
- GroupResizeMember,
- GroupResizeResult,
- GroupResizeUpdate,
- ResizeSide,
-} from "./types";
diff --git a/apps/web/src/lib/timeline/group-resize/types.ts b/apps/web/src/lib/timeline/group-resize/types.ts
deleted file mode 100644
index d5f00c1b..00000000
--- a/apps/web/src/lib/timeline/group-resize/types.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { FrameRate } from "opencut-wasm";
-import type { ElementRef, RetimeConfig } from "@/lib/timeline/types";
-
-export type ResizeSide = "left" | "right";
-
-export interface GroupResizeMember extends ElementRef {
- startTime: number;
- duration: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
- retime?: RetimeConfig;
- leftNeighborBound: number;
- rightNeighborBound: number;
-}
-
-export interface GroupResizeUpdate extends ElementRef {
- patch: {
- trimStart: number;
- trimEnd: number;
- startTime: number;
- duration: number;
- };
-}
-
-export interface GroupResizeResult {
- deltaTime: number;
- updates: GroupResizeUpdate[];
-}
-
-export interface ComputeGroupResizeArgs {
- members: GroupResizeMember[];
- side: ResizeSide;
- deltaTime: number;
- fps: FrameRate;
-}
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
deleted file mode 100644
index 5ee079b1..00000000
--- a/apps/web/src/lib/timeline/index.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { SceneTracks } from "./types";
-
-export * from "./types";
-export * from "./drag";
-export * from "./track-capabilities";
-export * from "./track-element-update";
-export * from "./element-utils";
-export * from "./audio-separation";
-export * from "./zoom-utils";
-export * from "./ruler-utils";
-export * from "./pixel-utils";
-
-export function calculateTotalDuration({
- tracks,
-}: {
- tracks: SceneTracks;
-}): number {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (orderedTracks.length === 0) return 0;
-
- const trackEndTimes = orderedTracks.map((track) =>
- track.elements.reduce((maxEnd, element) => {
- const elementEnd = element.startTime + element.duration;
- return Math.max(maxEnd, elementEnd);
- }, 0),
- );
-
- return Math.max(...trackEndTimes, 0);
-}
diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts
deleted file mode 100644
index 10f258aa..00000000
--- a/apps/web/src/lib/timeline/pixel-utils.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-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/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
deleted file mode 100644
index 7cadb9b7..00000000
--- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts
+++ /dev/null
@@ -1,661 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type {
- AudioElement,
- AudioTrack,
- GraphicElement,
- GraphicTrack,
- OverlayTrack,
- SceneTracks,
- TextElement,
- TextTrack,
- TimelineTrack,
- VideoElement,
- VideoTrack,
-} from "@/lib/timeline";
-import type { Transform } from "@/lib/rendering";
-import { resolveTrackPlacement } from "@/lib/timeline/placement";
-
-function buildTransform(): Transform {
- return {
- scaleX: 1,
- scaleY: 1,
- position: { x: 0, y: 0 },
- rotate: 0,
- };
-}
-
-type TestElement = AudioElement | GraphicElement | TextElement | VideoElement;
-
-function buildElement(params: {
- id: string;
- type: "audio";
- startTime: number;
- duration: number;
-}): AudioElement;
-function buildElement(params: {
- id: string;
- type: "graphic";
- startTime: number;
- duration: number;
-}): GraphicElement;
-function buildElement(params: {
- id: string;
- type: "text";
- startTime: number;
- duration: number;
-}): TextElement;
-function buildElement(params: {
- id: string;
- type: "video";
- startTime: number;
- duration: number;
-}): VideoElement;
-function buildElement({
- id,
- type,
- startTime,
- duration,
-}: {
- id: string;
- type: TestElement["type"];
- startTime: number;
- duration: number;
-}): TestElement {
- switch (type) {
- case "audio":
- return {
- id,
- type: "audio",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- volume: 1,
- sourceType: "upload",
- mediaId: `media-${id}`,
- } satisfies AudioElement;
- case "graphic":
- return {
- id,
- type: "graphic",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- definitionId: `graphic-${id}`,
- params: {},
- transform: buildTransform(),
- opacity: 1,
- } satisfies GraphicElement;
- case "text":
- return {
- id,
- type: "text",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- content: id,
- fontSize: 32,
- fontFamily: "sans-serif",
- color: "#ffffff",
- background: {
- enabled: false,
- color: "#000000",
- },
- textAlign: "left",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- transform: buildTransform(),
- opacity: 1,
- } satisfies TextElement;
- case "video":
- return {
- id,
- type: "video",
- name: id,
- startTime,
- duration,
- trimStart: 0,
- trimEnd: 0,
- mediaId: `media-${id}`,
- transform: buildTransform(),
- opacity: 1,
- } satisfies VideoElement;
- }
-
- throw new Error(`Unsupported test element type: ${type}`);
-}
-
-type BuildTrackParams =
- | {
- id: string;
- type: "audio";
- elements?: AudioTrack["elements"];
- }
- | {
- id: string;
- type: "graphic";
- elements?: GraphicTrack["elements"];
- }
- | {
- id: string;
- type: "text";
- elements?: TextTrack["elements"];
- }
- | {
- id: string;
- type: "video";
- elements?: VideoTrack["elements"];
- };
-
-function buildTrack(params: {
- id: string;
- type: "audio";
- elements?: AudioTrack["elements"];
-}): AudioTrack;
-function buildTrack(params: {
- id: string;
- type: "graphic";
- elements?: GraphicTrack["elements"];
-}): GraphicTrack;
-function buildTrack(params: {
- id: string;
- type: "text";
- elements?: TextTrack["elements"];
-}): TextTrack;
-function buildTrack(params: {
- id: string;
- type: "video";
- elements?: VideoTrack["elements"];
-}): VideoTrack;
-function buildTrack(params: BuildTrackParams): TimelineTrack {
- const { id, type } = params;
-
- switch (type) {
- case "audio":
- return {
- id,
- type: "audio",
- name: id,
- elements: params.elements ?? [],
- muted: false,
- };
- case "graphic":
- return {
- id,
- type: "graphic",
- name: id,
- elements: params.elements ?? [],
- hidden: false,
- };
- case "text":
- return {
- id,
- type: "text",
- name: id,
- elements: params.elements ?? [],
- hidden: false,
- };
- case "video":
- return {
- id,
- type: "video",
- name: id,
- elements: params.elements ?? [],
- muted: false,
- hidden: false,
- };
- }
-
- throw new Error(`Unsupported test track type: ${type}`);
-}
-
-function buildTimeSpan({
- startTime,
- duration,
- excludeElementId,
-}: {
- startTime: number;
- duration: number;
- excludeElementId?: string;
-}) {
- return { startTime, duration, excludeElementId };
-}
-
-function buildSceneTracks({
- overlay = [],
- main,
- audio = [],
-}: {
- overlay?: Array<OverlayTrack>;
- main?: VideoTrack;
- audio?: Array<AudioTrack>;
-}): SceneTracks {
- return {
- overlay,
- main:
- main ??
- buildTrack({
- id: "video-main",
- type: "video",
- }),
- audio,
- };
-}
-
-describe("resolveTrackPlacement", () => {
- test("explicit returns the requested compatible track", () => {
- const tracks = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 2, duration: 3 })],
- strategy: { type: "explicit", trackId: "text-1" },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "text-1",
- trackIndex: 0,
- trackType: "text",
- });
- });
-
- test("explicit rejects missing and incompatible tracks", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-1", type: "video" }),
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: { type: "explicit", trackId: "missing" },
- }),
- ).toBeNull();
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: { type: "explicit", trackId: "video-1" },
- }),
- ).toBeNull();
- });
-
- test("firstAvailable picks the first compatible track without overlap", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({
- id: "text-1",
- type: "text",
- elements: [
- buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- buildTrack({ id: "text-2", type: "text" }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 2, duration: 1 })],
- strategy: { type: "firstAvailable" },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "text-2",
- trackIndex: 1,
- trackType: "text",
- });
- });
-
- test("firstAvailable creates a new track when all compatible tracks are full", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({
- id: "graphic-1",
- type: "graphic",
- elements: [
- buildElement({
- id: "a",
- type: "graphic",
- startTime: 0,
- duration: 5,
- }),
- ],
- }),
- ],
- main: buildTrack({
- id: "video-main",
- type: "video",
- }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "graphic",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
- strategy: { type: "firstAvailable" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "graphic",
- insertIndex: 0,
- insertPosition: null,
- });
- });
-
- test("preferIndex uses the preferred track when it fits", () => {
- const tracks = buildSceneTracks({
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [buildTimeSpan({ startTime: 3, duration: 2 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "below",
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 1,
- insertPosition: "below",
- });
- });
-
- test("preferIndex creates a new overlay track above the main track", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "graphic",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 2 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 1,
- hoverDirection: "below",
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "graphic",
- insertIndex: 0,
- insertPosition: "above",
- });
- });
-
- test("preferIndex keeps audio tracks below the main track", () => {
- const tracks = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "above",
- createNewTrackOnly: true,
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: "below",
- });
- });
-
- test("aboveSource tries the track above source, then any compatible track", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({ id: "text-top", type: "text" }),
- buildTrack({
- id: "text-middle",
- type: "text",
- elements: [
- buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- buildTrack({ id: "text-source", type: "text" }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
- strategy: { type: "aboveSource", sourceTrackIndex: 2 },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "text-top",
- trackIndex: 0,
- trackType: "text",
- });
- });
-
- test("aboveSource creates a new overlay track in the overlay zone when none fit", () => {
- const tracks = buildSceneTracks({
- overlay: [
- buildTrack({
- id: "text-top",
- type: "text",
- elements: [
- buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- buildTrack({
- id: "text-source",
- type: "text",
- elements: [
- buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }),
- ],
- }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "text",
- timeSpans: [buildTimeSpan({ startTime: 1, duration: 1 })],
- strategy: { type: "aboveSource", sourceTrackIndex: 1 },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "text",
- insertIndex: 0,
- insertPosition: null,
- });
- });
-
- test("alwaysNew honors highest and default insertion rules", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "highest" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 1,
- insertPosition: null,
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "default" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: null,
- });
- });
-
- test("batch time spans reject tracks when any span overlaps", () => {
- const tracks = buildSceneTracks({
- audio: [
- buildTrack({
- id: "audio-1",
- type: "audio",
- elements: [
- buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }),
- buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }),
- ],
- }),
- ],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [
- buildTimeSpan({ startTime: 2.5, duration: 1 }),
- buildTimeSpan({ startTime: 5.5, duration: 1 }),
- ],
- strategy: { type: "firstAvailable" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 1,
- insertPosition: null,
- });
- });
-
- test("handles main-only timelines, single tracks, and track-type derivation", () => {
- expect(
- resolveTrackPlacement({
- tracks: buildSceneTracks({}),
- elementType: "video",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "below",
- createNewTrackOnly: true,
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "video",
- insertIndex: 0,
- insertPosition: "above",
- });
-
- expect(
- resolveTrackPlacement({
- tracks: buildSceneTracks({
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- }),
- elementType: "audio",
- timeSpans: [],
- strategy: { type: "alwaysNew", position: "default" },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: null,
- });
- });
-
- test("existingTrack on main video includes adjustedStartTime when start snaps", () => {
- const tracks = buildSceneTracks({
- main: buildTrack({
- id: "video-main",
- type: "video",
- elements: [
- buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }),
- ],
- }),
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "video",
- timeSpans: [buildTimeSpan({ startTime: 2, duration: 2 })],
- strategy: { type: "explicit", trackId: "video-main" },
- }),
- ).toEqual({
- kind: "existingTrack",
- trackId: "video-main",
- trackIndex: 0,
- trackType: "video",
- adjustedStartTime: 0,
- });
- });
-
- test("preferIndex uses vertical drag direction when hovered track is incompatible", () => {
- const tracks = buildSceneTracks({
- overlay: [buildTrack({ id: "text-1", type: "text" })],
- main: buildTrack({ id: "video-main", type: "video" }),
- audio: [buildTrack({ id: "audio-1", type: "audio" })],
- });
-
- expect(
- resolveTrackPlacement({
- tracks,
- elementType: "audio",
- timeSpans: [buildTimeSpan({ startTime: 0, duration: 1 })],
- strategy: {
- type: "preferIndex",
- trackIndex: 0,
- hoverDirection: "above",
- verticalDragDirection: "down",
- },
- }),
- ).toEqual({
- kind: "newTrack",
- trackType: "audio",
- insertIndex: 2,
- insertPosition: "below",
- });
- });
-});
diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts
deleted file mode 100644
index 639b1b42..00000000
--- a/apps/web/src/lib/timeline/placement/apply.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import type {
- AudioTrack,
- EffectTrack,
- GraphicTrack,
- OverlayTrack,
- SceneTracks,
- TextTrack,
- TimelineElement,
- TimelineTrack,
- VideoTrack,
-} from "@/lib/timeline";
-import { generateUUID } from "@/utils/id";
-import { buildEmptyTrack } from "./track-factory";
-import type { PlacementResult } from "./types";
-import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update";
-
-export function applyPlacement({
- tracks,
- placementResult,
- elements,
- newTrackInsertIndexOverride,
-}: {
- tracks: SceneTracks;
- placementResult: PlacementResult;
- elements: TimelineElement[];
- newTrackInsertIndexOverride?: number;
-}): { updatedTracks: SceneTracks; targetTrackId: string } | null {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- if (placementResult.kind === "existingTrack") {
- const targetTrack = orderedTracks[placementResult.trackIndex];
- if (!targetTrack) {
- return null;
- }
-
- const updatedTracks = updateTrackInSceneTracks({
- tracks,
- trackId: targetTrack.id,
- update: (track) => ({
- ...track,
- elements: [...track.elements, ...elements],
- }),
- });
-
- return { updatedTracks, targetTrackId: targetTrack.id };
- }
-
- const newTrackId = generateUUID();
- const insertIndex =
- newTrackInsertIndexOverride ?? placementResult.insertIndex;
- const updatedTracks =
- placementResult.trackType === "audio"
- ? {
- ...tracks,
- audio: insertIntoAudioTracks({
- tracks,
- insertIndex,
- track: buildPlacedAudioTrack({
- id: newTrackId,
- elements,
- }),
- }),
- }
- : {
- ...tracks,
- overlay: insertIntoOverlayTracks({
- tracks,
- insertIndex,
- track: buildPlacedOverlayTrack({
- id: newTrackId,
- type: placementResult.trackType,
- elements,
- }),
- }),
- };
- return { updatedTracks, targetTrackId: newTrackId };
-}
-
-function insertIntoOverlayTracks({
- tracks,
- insertIndex,
- track,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- track: OverlayTrack;
-}): OverlayTrack[] {
- const normalizedInsertIndex = Math.max(
- 0,
- Math.min(insertIndex, tracks.overlay.length),
- );
- const nextTracks = [...tracks.overlay];
- nextTracks.splice(normalizedInsertIndex, 0, track);
- return nextTracks;
-}
-
-function insertIntoAudioTracks({
- tracks,
- insertIndex,
- track,
-}: {
- tracks: SceneTracks;
- insertIndex: number;
- track: AudioTrack;
-}): AudioTrack[] {
- const audioInsertIndex = Math.max(
- 0,
- Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length),
- );
- const nextTracks = [...tracks.audio];
- nextTracks.splice(audioInsertIndex, 0, track);
- return nextTracks;
-}
-
-function buildPlacedAudioTrack({
- id,
- elements,
-}: {
- id: string;
- elements: TimelineElement[];
-}): AudioTrack {
- return {
- ...buildEmptyTrack({ id, type: "audio" }),
- elements: elements as AudioTrack["elements"],
- };
-}
-
-function buildPlacedOverlayTrack({
- id,
- type,
- elements,
-}: {
- id: string;
- type: Exclude<OverlayTrack["type"], "audio">;
- elements: TimelineElement[];
-}): OverlayTrack {
- switch (type) {
- case "video":
- return {
- ...buildEmptyTrack({ id, type: "video" }),
- elements: elements as VideoTrack["elements"],
- };
- case "text":
- return {
- ...buildEmptyTrack({ id, type: "text" }),
- elements: elements as TextTrack["elements"],
- };
- case "graphic":
- return {
- ...buildEmptyTrack({ id, type: "graphic" }),
- elements: elements as GraphicTrack["elements"],
- };
- case "effect":
- return {
- ...buildEmptyTrack({ id, type: "effect" }),
- elements: elements as EffectTrack["elements"],
- };
- }
-}
diff --git a/apps/web/src/lib/timeline/placement/compatibility.ts b/apps/web/src/lib/timeline/placement/compatibility.ts
deleted file mode 100644
index de1baff3..00000000
--- a/apps/web/src/lib/timeline/placement/compatibility.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { ElementType, TrackType } from "@/lib/timeline";
-
-const ELEMENT_TRACK_MAP: Record<ElementType, TrackType> = {
- audio: "audio",
- text: "text",
- sticker: "graphic",
- graphic: "graphic",
- effect: "effect",
- video: "video",
- image: "video",
-};
-
-export function getTrackTypeForElementType({
- elementType,
-}: {
- elementType: ElementType;
-}): TrackType {
- return ELEMENT_TRACK_MAP[elementType];
-}
-
-export function canElementGoOnTrack({
- elementType,
- trackType,
-}: {
- elementType: ElementType;
- trackType: TrackType;
-}): boolean {
- return getTrackTypeForElementType({ elementType }) === trackType;
-}
-
-export function validateElementTrackCompatibility({
- element,
- track,
-}: {
- element: { type: ElementType };
- track: { type: TrackType };
-}): { isValid: boolean; errorMessage?: string } {
- const isValid = canElementGoOnTrack({
- elementType: element.type,
- trackType: track.type,
- });
-
- if (!isValid) {
- return {
- isValid: false,
- errorMessage: `${element.type} elements cannot be placed on ${track.type} tracks`,
- };
- }
-
- return { isValid: true };
-}
diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts
deleted file mode 100644
index 530cb69f..00000000
--- a/apps/web/src/lib/timeline/placement/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export { applyPlacement } from "./apply";
-export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility";
-export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index";
-export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track";
-export { resolveTrackPlacement } from "./resolve";
-export { buildEmptyTrack } from "./track-factory";
-export type {
- PlacementResult,
- PlacementStrategy,
- PlacementSubject,
- PlacementTimeSpan,
-} from "./types";
diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts
deleted file mode 100644
index 04fa5a4a..00000000
--- a/apps/web/src/lib/timeline/placement/insert-index.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import type { SceneTracks, TrackType } from "@/lib/timeline";
-
-export function getDefaultInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
-}): number {
- if (trackType === "audio") {
- return tracks.overlay.length + 1 + tracks.audio.length;
- }
-
- if (trackType === "effect") {
- return 0;
- }
-
- return tracks.overlay.length;
-}
-
-export function getHighestInsertIndexForTrack({
- tracks,
- trackType,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
-}): number {
- if (trackType === "audio") {
- return tracks.overlay.length + 1;
- }
-
- return 0;
-}
-
-export function resolvePreferredNewTrackPlacement({
- tracks,
- trackType,
- preferredIndex,
- direction,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
- preferredIndex: number;
- direction: "above" | "below";
-}): { insertIndex: number; insertPosition: "above" | "below" | null } {
- const trackCount = tracks.overlay.length + 1 + tracks.audio.length;
- if (trackCount === 0) {
- return {
- insertIndex: 0,
- insertPosition: trackType === "audio" ? "below" : null,
- };
- }
-
- const safePreferredIndex = Math.min(
- Math.max(preferredIndex, 0),
- trackCount - 1,
- );
- const mainTrackIndex = tracks.overlay.length;
-
- if (trackType === "audio") {
- if (safePreferredIndex <= mainTrackIndex) {
- return {
- insertIndex: mainTrackIndex + 1,
- insertPosition: "below",
- };
- }
-
- return {
- insertIndex:
- direction === "above" ? safePreferredIndex : safePreferredIndex + 1,
- insertPosition: direction,
- };
- }
-
- const insertIndex =
- direction === "above" ? safePreferredIndex : safePreferredIndex + 1;
- if (mainTrackIndex >= 0 && insertIndex > mainTrackIndex) {
- return {
- insertIndex: mainTrackIndex,
- insertPosition: "above",
- };
- }
-
- return {
- insertIndex,
- insertPosition: direction,
- };
-}
diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts
deleted file mode 100644
index 7e206517..00000000
--- a/apps/web/src/lib/timeline/placement/main-track.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline";
-
-export const MAIN_TRACK_NAME = "Main Track";
-
-export function getEarliestMainTrackElement({
- mainTrack,
- excludeElementId,
-}: {
- mainTrack: VideoTrack;
- excludeElementId?: string;
-}): TimelineElement | null {
- const elements = mainTrack.elements.filter((element) => {
- return !excludeElementId || element.id !== excludeElementId;
- });
- if (elements.length === 0) {
- return null;
- }
-
- return elements.reduce((earliestElement, element) => {
- return element.startTime < earliestElement.startTime
- ? element
- : earliestElement;
- });
-}
-
-export function enforceMainTrackStart({
- tracks,
- targetTrackId,
- requestedStartTime,
- excludeElementId,
-}: {
- tracks: SceneTracks;
- targetTrackId: string;
- requestedStartTime: number;
- excludeElementId?: string;
-}): number {
- if (tracks.main.id !== targetTrackId) {
- return requestedStartTime;
- }
-
- const earliestElement = getEarliestMainTrackElement({
- mainTrack: tracks.main,
- excludeElementId,
- });
- if (!earliestElement) {
- return 0;
- }
-
- if (requestedStartTime <= earliestElement.startTime) {
- return 0;
- }
-
- return requestedStartTime;
-}
diff --git a/apps/web/src/lib/timeline/placement/overlap.ts b/apps/web/src/lib/timeline/placement/overlap.ts
deleted file mode 100644
index 5e37c052..00000000
--- a/apps/web/src/lib/timeline/placement/overlap.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { TimelineElement } from "@/lib/timeline";
-import type { PlacementTimeSpan } from "./types";
-
-interface TrackWithElements {
- elements: TimelineElement[];
-}
-
-function wouldElementOverlap({
- elements,
- startTime,
- endTime,
- excludeElementId,
-}: {
- elements: TimelineElement[];
- startTime: number;
- endTime: number;
- excludeElementId?: string;
-}): boolean {
- return elements.some((element) => {
- if (excludeElementId && element.id === excludeElementId) {
- return false;
- }
-
- const elementEnd = element.startTime + element.duration;
- return startTime < elementEnd && endTime > element.startTime;
- });
-}
-
-export function canPlaceTimeSpansOnTrack({
- track,
- timeSpans,
-}: {
- track: TrackWithElements;
- timeSpans: PlacementTimeSpan[];
-}): boolean {
- return timeSpans.every(({ startTime, duration, excludeElementId }) => {
- return !wouldElementOverlap({
- elements: track.elements,
- startTime,
- endTime: startTime + duration,
- excludeElementId,
- });
- });
-}
diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts
deleted file mode 100644
index 67943912..00000000
--- a/apps/web/src/lib/timeline/placement/resolve.ts
+++ /dev/null
@@ -1,277 +0,0 @@
-import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline";
-import {
- getDefaultInsertIndexForTrack,
- getHighestInsertIndexForTrack,
- resolvePreferredNewTrackPlacement,
-} from "./insert-index";
-import { getTrackTypeForElementType } from "./compatibility";
-import { enforceMainTrackStart } from "./main-track";
-import { canPlaceTimeSpansOnTrack } from "./overlap";
-import type {
- PlacementResult,
- PlacementStrategy,
- PlacementSubject,
- PlacementTimeSpan,
-} from "./types";
-
-type ResolveTrackPlacementParams = PlacementSubject & {
- tracks: SceneTracks;
- timeSpans: PlacementTimeSpan[];
- strategy: PlacementStrategy;
-};
-
-function buildExistingTrackResult({
- track,
- trackIndex,
- tracks,
- timeSpans,
-}: {
- track: TimelineTrack;
- trackIndex: number;
- tracks: SceneTracks;
- timeSpans: PlacementTimeSpan[];
-}): PlacementResult {
- const firstSpan = timeSpans[0];
- const requestedStartTime = firstSpan?.startTime ?? 0;
- const adjustedStartTime = enforceMainTrackStart({
- tracks,
- targetTrackId: track.id,
- requestedStartTime,
- excludeElementId: firstSpan?.excludeElementId,
- });
- return {
- kind: "existingTrack",
- trackId: track.id,
- trackIndex,
- trackType: track.type,
- ...(adjustedStartTime !== requestedStartTime ? { adjustedStartTime } : {}),
- };
-}
-
-function buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition,
-}: {
- trackType: TrackType;
- insertIndex: number;
- insertPosition: "above" | "below" | null;
-}): PlacementResult {
- return {
- kind: "newTrack",
- trackType,
- insertIndex,
- insertPosition,
- };
-}
-
-function findFirstAvailableTrackIndex({
- tracks,
- trackType,
- timeSpans,
-}: {
- tracks: TimelineTrack[];
- trackType: TrackType;
- timeSpans: PlacementTimeSpan[];
-}): number {
- return tracks.findIndex((track) => {
- return (
- track.type === trackType &&
- canPlaceTimeSpansOnTrack({
- track,
- timeSpans,
- })
- );
- });
-}
-
-function resolveAlwaysNewTrack({
- tracks,
- trackType,
- position,
-}: {
- tracks: SceneTracks;
- trackType: TrackType;
- position: "highest" | "default";
-}): PlacementResult {
- const insertIndex =
- position === "highest"
- ? getHighestInsertIndexForTrack({
- tracks,
- trackType,
- })
- : getDefaultInsertIndexForTrack({
- tracks,
- trackType,
- });
-
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition: null,
- });
-}
-
-function getInsertDirection({
- hoverDirection,
- verticalDragDirection,
-}: {
- hoverDirection: "above" | "below";
- verticalDragDirection?: "up" | "down" | null;
-}): "above" | "below" {
- if (verticalDragDirection === "up") {
- return "above";
- }
-
- if (verticalDragDirection === "down") {
- return "below";
- }
-
- return hoverDirection;
-}
-
-export function resolveTrackPlacement({
- tracks,
- ...placement
-}: ResolveTrackPlacementParams): PlacementResult | null {
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const trackType =
- "trackType" in placement
- ? placement.trackType
- : getTrackTypeForElementType({
- elementType: placement.elementType,
- });
- const { timeSpans, strategy } = placement;
-
- if (strategy.type === "explicit") {
- const trackIndex = orderedTracks.findIndex(
- (track) => track.id === strategy.trackId,
- );
- if (trackIndex < 0) {
- return null;
- }
-
- const track = orderedTracks[trackIndex];
- if (track.type !== trackType) {
- return null;
- }
-
- return buildExistingTrackResult({
- track,
- trackIndex,
- tracks,
- timeSpans,
- });
- }
-
- if (strategy.type === "firstAvailable") {
- const existingTrackIndex = findFirstAvailableTrackIndex({
- tracks: orderedTracks,
- trackType,
- timeSpans,
- });
- if (existingTrackIndex >= 0) {
- return buildExistingTrackResult({
- track: orderedTracks[existingTrackIndex],
- trackIndex: existingTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- return resolveAlwaysNewTrack({
- tracks,
- trackType,
- position: "highest",
- });
- }
-
- if (strategy.type === "preferIndex") {
- const preferredTrack = orderedTracks[strategy.trackIndex];
- const isPreferredTrackCompatible =
- !!preferredTrack && preferredTrack.type === trackType;
- const canUseExistingTrack =
- !strategy.createNewTrackOnly &&
- isPreferredTrackCompatible &&
- canPlaceTimeSpansOnTrack({
- track: preferredTrack,
- timeSpans,
- });
- if (canUseExistingTrack) {
- return buildExistingTrackResult({
- track: preferredTrack,
- trackIndex: strategy.trackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const { insertIndex, insertPosition } = resolvePreferredNewTrackPlacement({
- tracks,
- trackType,
- preferredIndex: strategy.trackIndex,
- direction: getInsertDirection({
- hoverDirection: strategy.hoverDirection,
- verticalDragDirection: !isPreferredTrackCompatible
- ? strategy.verticalDragDirection
- : null,
- }),
- });
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition,
- });
- }
-
- if (strategy.type === "aboveSource") {
- const aboveTrackIndex = strategy.sourceTrackIndex - 1;
- const aboveTrack = orderedTracks[aboveTrackIndex];
- if (
- aboveTrack &&
- aboveTrack.type === trackType &&
- canPlaceTimeSpansOnTrack({
- track: aboveTrack,
- timeSpans,
- })
- ) {
- return buildExistingTrackResult({
- track: aboveTrack,
- trackIndex: aboveTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const firstAvailableTrackIndex = findFirstAvailableTrackIndex({
- tracks: orderedTracks,
- trackType,
- timeSpans,
- });
- if (firstAvailableTrackIndex >= 0) {
- return buildExistingTrackResult({
- track: orderedTracks[firstAvailableTrackIndex],
- trackIndex: firstAvailableTrackIndex,
- tracks,
- timeSpans,
- });
- }
-
- const insertIndex = getHighestInsertIndexForTrack({
- tracks,
- trackType,
- });
-
- return buildNewTrackResult({
- trackType,
- insertIndex,
- insertPosition: null,
- });
- }
-
- return resolveAlwaysNewTrack({
- tracks,
- trackType,
- position: strategy.position,
- });
-}
diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts
deleted file mode 100644
index b73c5155..00000000
--- a/apps/web/src/lib/timeline/placement/track-factory.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks";
-import type {
- AudioTrack,
- EffectTrack,
- GraphicTrack,
- TextTrack,
- TrackType,
- TimelineTrack,
- VideoTrack,
-} from "@/lib/timeline";
-
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "video";
- name?: string;
-}): VideoTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "text";
- name?: string;
-}): TextTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "audio";
- name?: string;
-}): AudioTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "graphic";
- name?: string;
-}): GraphicTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: "effect";
- name?: string;
-}): EffectTrack;
-
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: TrackType;
- name?: string;
-}): TimelineTrack;
-export function buildEmptyTrack({
- id,
- type,
- name,
-}: {
- id: string;
- type: TrackType;
- name?: string;
-}): TimelineTrack {
- const trackName = name ?? DEFAULT_TRACK_NAMES[type];
-
- switch (type) {
- case "video":
- return {
- id,
- name: trackName,
- type: "video",
- elements: [],
- hidden: false,
- muted: false,
- };
- case "text":
- return {
- id,
- name: trackName,
- type: "text",
- elements: [],
- hidden: false,
- };
- case "graphic":
- return {
- id,
- name: trackName,
- type: "graphic",
- elements: [],
- hidden: false,
- };
- case "audio":
- return {
- id,
- name: trackName,
- type: "audio",
- elements: [],
- muted: false,
- };
- case "effect":
- return {
- id,
- name: trackName,
- type: "effect",
- elements: [],
- hidden: false,
- };
- default:
- throw new Error(`Unsupported track type: ${type}`);
- }
-}
diff --git a/apps/web/src/lib/timeline/placement/types.ts b/apps/web/src/lib/timeline/placement/types.ts
deleted file mode 100644
index 559e85cc..00000000
--- a/apps/web/src/lib/timeline/placement/types.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { ElementType, TrackType } from "@/lib/timeline";
-
-export interface PlacementTimeSpan {
- startTime: number;
- duration: number;
- excludeElementId?: string;
-}
-
-export type PlacementSubject =
- | { elementType: ElementType }
- | { trackType: TrackType };
-
-export type PlacementStrategy =
- | { type: "explicit"; trackId: string }
- | { type: "firstAvailable" }
- | {
- type: "preferIndex";
- trackIndex: number;
- hoverDirection: "above" | "below";
- verticalDragDirection?: "up" | "down" | null;
- createNewTrackOnly?: boolean;
- }
- | { type: "aboveSource"; sourceTrackIndex: number }
- | { type: "alwaysNew"; position: "highest" | "default" };
-
-export type PlacementResult =
- | {
- kind: "existingTrack";
- trackId: string;
- trackIndex: number;
- trackType: TrackType;
- adjustedStartTime?: number;
- }
- | {
- kind: "newTrack";
- insertIndex: number;
- insertPosition: "above" | "below" | null;
- trackType: TrackType;
- };
diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts
deleted file mode 100644
index ea2982aa..00000000
--- a/apps/web/src/lib/timeline/ruler-utils.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-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
- * one tick between labels even at max zoom.
- * pattern: 2, 3, 5, 10, 15 (matches CapCut)
- */
-const LABEL_FRAME_INTERVALS = [2, 3, 5, 10, 15] as const;
-
-/**
- * frame intervals for ticks - can go down to 1 for max granularity.
- */
-const TICK_FRAME_INTERVALS = [1, 2, 3, 5, 10, 15] as const;
-
-/**
- * second intervals for when we're zoomed out past frame-level detail.
- */
-const SECOND_MULTIPLIERS = [
- 1, 2, 3, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
-] as const;
-
-/**
- * minimum pixel spacing between labels to keep them readable
- */
-const MIN_LABEL_SPACING_PX = 120;
-
-/**
- * minimum pixel spacing between ticks. much denser than labels.
- */
-const MIN_TICK_SPACING_PX = 18;
-
-export interface RulerConfig {
- /** time interval in seconds between each label */
- labelIntervalSeconds: number;
- /** time interval in seconds between each tick */
- tickIntervalSeconds: number;
-}
-
-/**
- * determines the optimal label and tick intervals based on zoom level and FPS.
- *
- * labels and ticks scale independently:
- * - labels need wide spacing (~50px) to stay readable
- * - ticks can be denser (~8px) to show finer subdivisions
- *
- * example at different zoom levels:
- * - very zoomed in: labels every 2f, ticks every 1f
- * - zoomed in: labels every 10f, ticks every 1f
- * - zoomed out: labels every 15f, ticks every 3f
- * - very zoomed out: labels every 1s, ticks every 5f
- */
-export function getRulerConfig({
- zoomLevel,
- fps,
-}: {
- zoomLevel: number;
- fps: FrameRate;
-}): RulerConfig {
- const fpsFloat = frameRateToFloat(fps);
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
- const pixelsPerFrame = pixelsPerSecond / fpsFloat;
-
- const labelIntervalSeconds = findOptimalInterval({
- pixelsPerFrame,
- pixelsPerSecond,
- fps: fpsFloat,
- minSpacingPx: MIN_LABEL_SPACING_PX,
- frameIntervals: LABEL_FRAME_INTERVALS,
- });
-
- const rawTickIntervalSeconds = findOptimalInterval({
- pixelsPerFrame,
- pixelsPerSecond,
- fps: fpsFloat,
- minSpacingPx: MIN_TICK_SPACING_PX,
- frameIntervals: TICK_FRAME_INTERVALS,
- });
-
- // ensure tick interval divides evenly into label interval so labels always land on ticks
- const tickIntervalSeconds = ensureTickDividesLabel({
- tickIntervalSeconds: rawTickIntervalSeconds,
- labelIntervalSeconds,
- pixelsPerFrame,
- pixelsPerSecond,
- fps: fpsFloat,
- });
-
- return { labelIntervalSeconds, tickIntervalSeconds };
-}
-
-/**
- * adjusts tick interval to ensure it divides evenly into the label interval.
- * this guarantees labels always land on tick positions.
- */
-function ensureTickDividesLabel({
- tickIntervalSeconds,
- labelIntervalSeconds,
- pixelsPerFrame,
- pixelsPerSecond,
- fps,
-}: {
- tickIntervalSeconds: number;
- labelIntervalSeconds: number;
- pixelsPerFrame: number;
- pixelsPerSecond: number;
- fps: number;
-}): number {
- const labelFrames = Math.round(labelIntervalSeconds * fps);
- const tickFrames = Math.round(tickIntervalSeconds * fps);
-
- // if tick already divides label evenly, we're good
- if (labelFrames % tickFrames === 0) {
- return tickIntervalSeconds;
- }
-
- // find the smallest tick interval that divides the label interval and has adequate spacing
- for (const candidateFrames of TICK_FRAME_INTERVALS) {
- if (labelFrames % candidateFrames === 0) {
- const candidateSpacing = pixelsPerFrame * candidateFrames;
- // accept if spacing meets minimum threshold
- if (candidateSpacing >= MIN_TICK_SPACING_PX) {
- return candidateFrames / fps;
- }
- }
- }
-
- // try second-level tick intervals that divide the label interval cleanly
- for (const candidateSeconds of SECOND_MULTIPLIERS) {
- const ratio = labelIntervalSeconds / candidateSeconds;
- const isDivisor = Math.abs(ratio - Math.round(ratio)) < 0.0001;
- if (isDivisor) {
- const candidateSpacing = pixelsPerSecond * candidateSeconds;
- if (candidateSpacing >= MIN_TICK_SPACING_PX) {
- return candidateSeconds;
- }
- }
- }
-
- // fallback: use the label interval itself (no intermediate ticks)
- return labelIntervalSeconds;
-}
-
-function findOptimalInterval({
- pixelsPerFrame,
- pixelsPerSecond,
- fps,
- minSpacingPx,
- frameIntervals,
-}: {
- pixelsPerFrame: number;
- pixelsPerSecond: number;
- fps: number;
- minSpacingPx: number;
- frameIntervals: readonly number[];
-}): number {
- // try frame-level intervals first
- for (const frameInterval of frameIntervals) {
- const pixelSpacing = pixelsPerFrame * frameInterval;
- if (pixelSpacing >= minSpacingPx) {
- return frameInterval / fps;
- }
- }
-
- // then try second-level intervals
- for (const secondMultiplier of SECOND_MULTIPLIERS) {
- const pixelSpacing = pixelsPerSecond * secondMultiplier;
- if (pixelSpacing >= minSpacingPx) {
- return secondMultiplier;
- }
- }
-
- return 60;
-}
-
-/**
- * checks if a time should have a label based on the label interval.
- */
-export function shouldShowLabel({
- time,
- labelIntervalSeconds,
-}: {
- time: number;
- labelIntervalSeconds: number;
-}): boolean {
- const epsilon = 0.0001;
- const remainder = time % labelIntervalSeconds;
- return remainder < epsilon || remainder > labelIntervalSeconds - epsilon;
-}
-
-/**
- * formats a ruler tick label.
- *
- * - on second boundaries: "MM:SS" (e.g., "00:00", "01:30")
- * - between seconds: "Xf" (e.g., "5f", "15f")
- */
-export function formatRulerLabel({
- timeInSeconds,
- fps,
-}: {
- timeInSeconds: number;
- fps: FrameRate;
-}): string {
- if (isSecondBoundary({ timeInSeconds })) {
- return formatTimestamp({ timeInSeconds });
- }
-
- const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) });
- return `${frameWithinSecond}f`;
-}
-
-/**
- * checks if a time falls exactly on a second boundary.
- */
-function isSecondBoundary({
- timeInSeconds,
-}: {
- timeInSeconds: number;
-}): boolean {
- const epsilon = 0.0001;
- const remainder = timeInSeconds % 1;
- return remainder < epsilon || remainder > 1 - epsilon;
-}
-
-/**
- * gets the frame number within the current second.
- */
-function getFrameWithinSecond({
- timeInSeconds,
- fps,
-}: {
- timeInSeconds: number;
- fps: number;
-}): number {
- const fractionalPart = timeInSeconds % 1;
- return Math.round(fractionalPart * fps);
-}
-
-/**
- * formats a timestamp as MM:SS or H:MM:SS when >= 1 hour.
- */
-function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
- const totalSeconds = Math.round(timeInSeconds);
- const hours = Math.floor(totalSeconds / 3600);
- const minutes = Math.floor((totalSeconds % 3600) / 60);
- const seconds = totalSeconds % 60;
-
- const mm = minutes.toString().padStart(2, "0");
- const ss = seconds.toString().padStart(2, "0");
-
- if (hours > 0) {
- return `${hours}:${mm}:${ss}`;
- }
-
- return `${mm}:${ss}`;
-}
diff --git a/apps/web/src/lib/timeline/scale.ts b/apps/web/src/lib/timeline/scale.ts
deleted file mode 100644
index 7770f59f..00000000
--- a/apps/web/src/lib/timeline/scale.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const BASE_TIMELINE_PIXELS_PER_SECOND = 50;
-export const TIMELINE_ZOOM_MIN = 0.1;
-export const TIMELINE_ZOOM_MAX = 100;
diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts
deleted file mode 100644
index b2be95a9..00000000
--- a/apps/web/src/lib/timeline/snap-utils.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import type { Bookmark, SceneTracks } 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,
- excludeElementIds,
- bookmarks = [],
- excludeBookmarkTime,
- enableElementSnapping = true,
- enablePlayheadSnapping = true,
- enableBookmarkSnapping = true,
- enableKeyframeSnapping = true,
-}: {
- tracks: SceneTracks;
- playheadTime: number;
- excludeElementId?: string;
- excludeElementIds?: Set<string>;
- bookmarks?: Array<Bookmark>;
- excludeBookmarkTime?: number;
- enableElementSnapping?: boolean;
- enablePlayheadSnapping?: boolean;
- enableBookmarkSnapping?: boolean;
- enableKeyframeSnapping?: boolean;
-}): SnapPoint[] {
- const snapPoints: SnapPoint[] = [];
- const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
- const blockedElementIds = new Set(excludeElementIds);
- if (excludeElementId) {
- blockedElementIds.add(excludeElementId);
- }
-
- for (const track of orderedTracks) {
- for (const element of track.elements) {
- if (blockedElementIds.has(element.id)) 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,
- excludeElementIds,
- snapToStart = true,
- bookmarks = [],
-}: {
- targetTime: number;
- elementDuration: number;
- tracks: SceneTracks;
- playheadTime: number;
- zoomLevel: number;
- excludeElementId?: string;
- excludeElementIds?: Set<string>;
- snapToStart?: boolean;
- bookmarks?: Array<Bookmark>;
-}): SnapResult {
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId,
- excludeElementIds,
- 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/track-capabilities.ts b/apps/web/src/lib/timeline/track-capabilities.ts
deleted file mode 100644
index 08ee841c..00000000
--- a/apps/web/src/lib/timeline/track-capabilities.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type {
- TimelineTrack,
- VideoTrack,
- AudioTrack,
- GraphicTrack,
- TextTrack,
- EffectTrack,
-} from "@/lib/timeline";
-
-export function canTrackHaveAudio(
- track: TimelineTrack,
-): track is VideoTrack | AudioTrack {
- return track.type === "audio" || track.type === "video";
-}
-
-export function canTrackBeHidden(
- track: TimelineTrack,
-): track is VideoTrack | TextTrack | GraphicTrack | EffectTrack {
- return track.type !== "audio";
-}
diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts
deleted file mode 100644
index f1c2028e..00000000
--- a/apps/web/src/lib/timeline/track-element-update.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline";
-
-export function findTrackInSceneTracks({
- tracks,
- trackId,
-}: {
- tracks: SceneTracks;
- trackId: string;
-}): TimelineTrack | null {
- if (tracks.main.id === trackId) {
- return tracks.main;
- }
-
- return (
- tracks.overlay.find((track) => track.id === trackId) ??
- tracks.audio.find((track) => track.id === trackId) ??
- null
- );
-}
-
-export function updateTrackInSceneTracks({
- tracks,
- trackId,
- update,
-}: {
- tracks: SceneTracks;
- trackId: string;
- update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack;
-}): SceneTracks {
- if (tracks.main.id === trackId) {
- return {
- ...tracks,
- main: update(tracks.main),
- };
- }
-
- const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
- if (overlayTrackIndex >= 0) {
- return {
- ...tracks,
- overlay: tracks.overlay.map((track, index) =>
- index === overlayTrackIndex ? update(track) : track,
- ),
- };
- }
-
- const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
- if (audioTrackIndex >= 0) {
- return {
- ...tracks,
- audio: tracks.audio.map((track, index) =>
- index === audioTrackIndex ? update(track) : track,
- ),
- };
- }
-
- return tracks;
-}
-
-function updateElementInTrack<TTrack extends TimelineTrack>({
- track,
- elementId,
- update,
- elementPredicate,
-}: {
- track: TTrack;
- elementId: string;
- update: (element: TimelineElement) => TimelineElement;
- elementPredicate?: (element: TimelineElement) => boolean;
-}): TTrack {
- const nextElements = track.elements.map((element) => {
- if (element.id !== elementId) {
- return element;
- }
- if (elementPredicate && !elementPredicate(element)) {
- return element;
- }
- return update(element);
- });
-
- return {
- ...track,
- elements: nextElements,
- } as TTrack;
-}
-
-export function updateElementInSceneTracks({
- tracks,
- trackId,
- elementId,
- update,
- elementPredicate,
-}: {
- tracks: SceneTracks;
- trackId: string;
- elementId: string;
- update: (element: TimelineElement) => TimelineElement;
- elementPredicate?: (element: TimelineElement) => boolean;
-}): SceneTracks {
- if (tracks.main.id === trackId) {
- return {
- ...tracks,
- main: updateElementInTrack({
- track: tracks.main,
- elementId,
- update,
- elementPredicate,
- }),
- };
- }
-
- const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId);
- if (overlayTrackIndex >= 0) {
- return {
- ...tracks,
- overlay: tracks.overlay.map((track, index) =>
- index === overlayTrackIndex
- ? updateElementInTrack({
- track,
- elementId,
- update,
- elementPredicate,
- })
- : track,
- ),
- };
- }
-
- const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId);
- if (audioTrackIndex >= 0) {
- return {
- ...tracks,
- audio: tracks.audio.map((track, index) =>
- index === audioTrackIndex
- ? updateElementInTrack({
- track,
- elementId,
- update,
- elementPredicate,
- })
- : track,
- ),
- };
- }
-
- return tracks;
-}
diff --git a/apps/web/src/lib/timeline/tracks.ts b/apps/web/src/lib/timeline/tracks.ts
deleted file mode 100644
index 181b9fcf..00000000
--- a/apps/web/src/lib/timeline/tracks.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import type { TrackType } from "@/lib/timeline";
-
-export const DEFAULT_TRACK_NAMES: Record<TrackType, string> = {
- video: "Video track",
- text: "Text track",
- audio: "Audio track",
- graphic: "Graphic track",
- effect: "Effect track",
-} as const;
diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts
deleted file mode 100644
index ca9ac999..00000000
--- a/apps/web/src/lib/timeline/types.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-import type { ElementAnimations } from "@/lib/animation/types";
-import type { Effect } from "@/lib/effects/types";
-import type { Mask } from "@/lib/masks/types";
-import type { ParamValues } from "@/lib/params";
-import type { BlendMode, Transform } from "@/lib/rendering";
-
-export type ElementRef = {
- trackId: string;
- elementId: string;
-};
-
-export interface Bookmark {
- time: number;
- note?: string;
- color?: string;
- duration?: number;
-}
-
-export interface TScene {
- id: string;
- name: string;
- isMain: boolean;
- tracks: SceneTracks;
- bookmarks: Bookmark[];
- createdAt: Date;
- updatedAt: Date;
-}
-
-export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
-
-interface BaseTrack {
- id: string;
- name: string;
-}
-
-export interface VideoTrack extends BaseTrack {
- type: "video";
- elements: (VideoElement | ImageElement)[];
- muted: boolean;
- hidden: boolean;
-}
-
-export interface TextTrack extends BaseTrack {
- type: "text";
- elements: TextElement[];
- hidden: boolean;
-}
-
-export interface AudioTrack extends BaseTrack {
- type: "audio";
- elements: AudioElement[];
- muted: boolean;
-}
-
-export interface GraphicTrack extends BaseTrack {
- type: "graphic";
- elements: (StickerElement | GraphicElement)[];
- hidden: boolean;
-}
-
-export interface EffectTrack extends BaseTrack {
- type: "effect";
- elements: EffectElement[];
- hidden: boolean;
-}
-
-export type TimelineTrack =
- | VideoTrack
- | TextTrack
- | AudioTrack
- | GraphicTrack
- | EffectTrack;
-
-export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
-
-export interface SceneTracks {
- overlay: OverlayTrack[];
- main: VideoTrack;
- audio: AudioTrack[];
-}
-
-export interface RetimeConfig {
- rate: number;
- maintainPitch?: boolean;
-}
-
-interface BaseAudioElement extends BaseTimelineElement {
- type: "audio";
- volume: number;
- muted?: boolean;
- buffer?: AudioBuffer;
- retime?: RetimeConfig;
-}
-
-export interface UploadAudioElement extends BaseAudioElement {
- sourceType: "upload";
- mediaId: string;
-}
-
-export interface LibraryAudioElement extends BaseAudioElement {
- sourceType: "library";
- sourceUrl: string;
-}
-
-export type AudioElement = UploadAudioElement | LibraryAudioElement;
-
-interface BaseTimelineElement {
- id: string;
- name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
- animations?: ElementAnimations;
-}
-
-export interface VideoElement extends BaseTimelineElement {
- type: "video";
- mediaId: string;
- volume?: number;
- muted?: boolean;
- isSourceAudioEnabled?: boolean;
- hidden?: boolean;
- retime?: RetimeConfig;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface ImageElement extends BaseTimelineElement {
- type: "image";
- mediaId: string;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface TextBackground {
- enabled: boolean;
- color: string;
- cornerRadius?: number;
- paddingX?: number;
- paddingY?: number;
- offsetX?: number;
- offsetY?: number;
-}
-
-export interface TextElement extends BaseTimelineElement {
- type: "text";
- content: string;
- fontSize: number;
- fontFamily: string;
- color: string;
- background: TextBackground;
- textAlign: "left" | "center" | "right";
- fontWeight: "normal" | "bold";
- fontStyle: "normal" | "italic";
- textDecoration: "none" | "underline" | "line-through";
- letterSpacing?: number;
- lineHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface StickerElement extends BaseTimelineElement {
- type: "sticker";
- stickerId: string;
- /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
- intrinsicWidth?: number;
- intrinsicHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface GraphicElement extends BaseTimelineElement {
- type: "graphic";
- definitionId: string;
- params: ParamValues;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface EffectElement extends BaseTimelineElement {
- type: "effect";
- effectType: string;
- params: ParamValues;
-}
-
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
-
-export type TimelineElement =
- | AudioElement
- | VideoElement
- | ImageElement
- | TextElement
- | StickerElement
- | GraphicElement
- | EffectElement;
-
-export type ElementType = TimelineElement["type"];
-
-function elementTypes<T extends ElementType[]>(...types: T): T {
- return types;
-}
-
-export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
-
-export type MaskableElement = Extract<
- TimelineElement,
- { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
->;
-
-export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
-
-export type RetimableElement = Extract<
- TimelineElement,
- { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
->;
-
-export const VISUAL_ELEMENT_TYPES = elementTypes(
- "video",
- "image",
- "text",
- "sticker",
- "graphic",
-);
-
-export type VisualElement = Extract<
- TimelineElement,
- { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
->;
-
-export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
-export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit<VideoElement, "id">;
-export type CreateImageElement = Omit<ImageElement, "id">;
-export type CreateTextElement = Omit<TextElement, "id">;
-export type CreateStickerElement = Omit<StickerElement, "id">;
-export type CreateGraphicElement = Omit<GraphicElement, "id">;
-export type CreateEffectElement = Omit<EffectElement, "id">;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | CreateGraphicElement
- | CreateEffectElement;
-
-export interface ElementDragState {
- isDragging: boolean;
- elementId: string | null;
- dragElementIds: string[];
- dragTimeOffsets: Record<string, number>;
- trackId: string | null;
- startMouseX: number;
- startMouseY: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
- currentMouseY: number;
-}
-
-export interface DropTarget {
- trackIndex: number;
- isNewTrack: boolean;
- insertPosition: "above" | "below" | null;
- xPosition: number;
- targetElement: { elementId: string; trackId: string } | null;
-}
-
-export interface ComputeDropTargetParams {
- elementType: ElementType;
- mouseX: number;
- mouseY: number;
- tracks: SceneTracks;
- playheadTime: number;
- isExternalDrop: boolean;
- elementDuration: number;
- pixelsPerSecond: number;
- zoomLevel: number;
- verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: number;
- excludeElementId?: string;
- targetElementTypes?: string[];
-}
-
-export interface ClipboardItem {
- trackId: string;
- trackType: TrackType;
- element: CreateTimelineElement;
-}
diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts
deleted file mode 100644
index 2be73441..00000000
--- a/apps/web/src/lib/timeline/update-pipeline.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { clampAnimationsToDuration } from "@/lib/animation";
-import {
- clampRetimeRate,
- getSourceSpanAtClipTime,
- getTimelineDurationForSourceSpan,
-} from "@/lib/retime";
-import type { RetimeConfig, SceneTracks, TimelineElement } from "@/lib/timeline";
-import { isRetimableElement } from "@/lib/timeline";
-
-type ElementUpdateField = keyof TimelineElement | string;
-
-export interface ElementUpdateContext {
- tracks: SceneTracks;
- trackId: string;
-}
-
-interface ElementUpdateRuleResult {
- element: TimelineElement;
- changedFields?: ElementUpdateField[];
-}
-
-interface ElementUpdateRuleParams {
- element: TimelineElement;
- originalElement: TimelineElement;
- patch: Partial<TimelineElement>;
- context: ElementUpdateContext;
-}
-
-interface ElementUpdateRule {
- triggers: ElementUpdateField[];
- apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult;
-}
-
-const deriveRules: ElementUpdateRule[] = [
- {
- triggers: ["retime"],
- apply: ({ element, originalElement, patch }) => {
- if (!("retime" in patch) || !isRetimableElement(element)) {
- return { element };
- }
-
- const nextRetime = patch.retime
- ? {
- ...patch.retime,
- rate: clampRetimeRate({ rate: patch.retime.rate }),
- }
- : undefined;
-
- const sourceDuration = getSourceDuration({
- trimStart: originalElement.trimStart,
- trimEnd: originalElement.trimEnd,
- duration: originalElement.duration,
- sourceDuration: isRetimableElement(originalElement)
- ? originalElement.sourceDuration
- : undefined,
- retime: isRetimableElement(originalElement)
- ? originalElement.retime
- : undefined,
- });
- const visibleSourceSpan = Math.max(
- 0,
- sourceDuration - element.trimStart - element.trimEnd,
- );
- const nextDuration = getTimelineDurationForSourceSpan({
- sourceSpan: visibleSourceSpan,
- retime: nextRetime,
- });
-
- return {
- element: {
- ...element,
- retime: nextRetime,
- duration: nextDuration,
- },
- changedFields: ["retime", "duration"],
- };
- },
- },
-];
-
-const enforceRules: ElementUpdateRule[] = [
- {
- triggers: ["duration"],
- apply: ({ element }) => ({
- element: {
- ...element,
- animations: clampAnimationsToDuration({
- animations: element.animations,
- duration: element.duration,
- }),
- },
- }),
- },
- {
- triggers: ["startTime"],
- apply: ({ element, context }) => {
- const requestedStartTime = Math.max(0, element.startTime);
- if (context.trackId !== context.tracks.main.id) {
- return {
- element: {
- ...element,
- startTime: requestedStartTime,
- },
- };
- }
-
- const earliestElement = context.tracks.main.elements
- .filter((candidate) => candidate.id !== element.id)
- .reduce<TimelineElement | null>((earliest, candidate) => {
- if (!earliest || candidate.startTime < earliest.startTime) {
- return candidate;
- }
- return earliest;
- }, null);
-
- return {
- element: {
- ...element,
- startTime:
- !earliestElement || requestedStartTime <= earliestElement.startTime
- ? 0
- : requestedStartTime,
- },
- };
- },
- },
-];
-
-export function applyElementUpdate({
- element,
- patch,
- context,
-}: {
- element: TimelineElement;
- patch: Partial<TimelineElement>;
- context: ElementUpdateContext;
-}): TimelineElement {
- let nextElement = { ...element, ...patch } as TimelineElement;
- const changedFields = new Set(
- Object.keys(patch) as ElementUpdateField[],
- );
-
- for (const rule of deriveRules) {
- if (!shouldApplyRule({ rule, changedFields })) {
- continue;
- }
-
- const result = rule.apply({
- element: nextElement,
- originalElement: element,
- patch,
- context,
- });
- nextElement = result.element;
- for (const field of result.changedFields ?? []) {
- changedFields.add(field);
- }
- }
-
- for (const rule of enforceRules) {
- if (!shouldApplyRule({ rule, changedFields })) {
- continue;
- }
-
- nextElement = rule.apply({
- element: nextElement,
- originalElement: element,
- patch,
- context,
- }).element;
- }
-
- return nextElement;
-}
-
-function shouldApplyRule({
- rule,
- changedFields,
-}: {
- rule: ElementUpdateRule;
- changedFields: Set<ElementUpdateField>;
-}): boolean {
- return rule.triggers.some((trigger) => changedFields.has(trigger));
-}
-
-function getSourceDuration({
- trimStart,
- trimEnd,
- duration,
- sourceDuration,
- retime,
-}: {
- trimStart: number;
- trimEnd: number;
- duration: number;
- sourceDuration?: number;
- retime?: RetimeConfig;
-}): number {
- if (typeof sourceDuration === "number") {
- return sourceDuration;
- }
-
- return (
- trimStart +
- getSourceSpanAtClipTime({
- clipTime: duration,
- retime,
- }) +
- trimEnd
- );
-}
diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts
deleted file mode 100644
index 680f8d0d..00000000
--- a/apps/web/src/lib/timeline/zoom-utils.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-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;
-const PADDING_MIN_AT_ZOOM_PERCENT = 0.2;
-
-export function getTimelineZoomMin({
- duration,
- containerWidth,
-}: {
- duration: number;
- containerWidth: number | null | undefined;
-}): number {
- 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 / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND);
-
- return Math.min(TIMELINE_ZOOM_MAX, zoomToFit);
-}
-
-export function getTimelinePaddingPx({
- containerWidth,
- zoomLevel,
- minZoom,
-}: {
- containerWidth: number;
- zoomLevel: number;
- minZoom: number;
-}): number {
- const zoomPercent = getZoomPercent({ zoomLevel, minZoom });
- const paddingTransitionPercent = Math.min(
- zoomPercent / PADDING_MIN_AT_ZOOM_PERCENT,
- 1,
- );
- const paddingRatio =
- PADDING_MAX_RATIO -
- (PADDING_MAX_RATIO - PADDING_MIN_RATIO) * paddingTransitionPercent;
-
- return containerWidth * paddingRatio;
-}
-
-export function getZoomPercent({
- zoomLevel,
- minZoom,
-}: {
- zoomLevel: number;
- minZoom: number;
-}): number {
- return (zoomLevel - minZoom) / (TIMELINE_ZOOM_MAX - minZoom);
-}
-
-/**
- * convert linear slider position (0-1) to exponential zoom level.
- * at low slider values, zoom changes are small. at high values, changes are large.
- */
-export function sliderToZoom({
- sliderPosition,
- minZoom,
- maxZoom = TIMELINE_ZOOM_MAX,
-}: {
- sliderPosition: number;
- minZoom: number;
- maxZoom?: number;
-}): number {
- const clampedPosition = Math.max(0, Math.min(1, sliderPosition));
- return minZoom * (maxZoom / minZoom) ** clampedPosition;
-}
-
-/**
- * convert exponential zoom level to linear slider position (0-1).
- */
-export function zoomToSlider({
- zoomLevel,
- minZoom,
- maxZoom = TIMELINE_ZOOM_MAX,
-}: {
- zoomLevel: number;
- minZoom: number;
- maxZoom?: number;
-}): number {
- const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoomLevel));
- return Math.log(clampedZoom / minZoom) / Math.log(maxZoom / minZoom);
-}