diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/timeline | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/timeline')
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); -} |
