summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/lib/timeline
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/lib/timeline')
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts61
-rw-r--r--apps/web/src/lib/timeline/drop-utils.ts234
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts304
-rw-r--r--apps/web/src/lib/timeline/index.ts23
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts228
-rw-r--r--apps/web/src/lib/timeline/track-utils.ts244
-rw-r--r--apps/web/src/lib/timeline/zoom-utils.ts53
7 files changed, 1147 insertions, 0 deletions
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts
new file mode 100644
index 00000000..0b06c2f6
--- /dev/null
+++ b/apps/web/src/lib/timeline/bookmarks.ts
@@ -0,0 +1,61 @@
+import { roundToFrame } from "@/lib/time";
+
+export function findBookmarkIndex({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): number {
+ return bookmarks.findIndex(
+ (bookmark) => Math.abs(bookmark - frameTime) < 0.001,
+ );
+}
+
+export function isBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): boolean {
+ return bookmarks.some((bookmark) => Math.abs(bookmark - frameTime) < 0.001);
+}
+
+export function toggleBookmarkInArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): number[] {
+ const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
+
+ if (bookmarkIndex !== -1) {
+ return bookmarks.filter((_, i) => i !== bookmarkIndex);
+ }
+
+ return [...bookmarks, frameTime].sort((a, b) => a - b);
+}
+
+export function removeBookmarkFromArray({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: number[];
+ frameTime: number;
+}): number[] {
+ return bookmarks.filter(
+ (bookmark) => Math.abs(bookmark - frameTime) >= 0.001,
+ );
+}
+
+export function getFrameTime({
+ time,
+ fps,
+}: {
+ time: number;
+ fps: number;
+}): number {
+ return roundToFrame({ time, fps });
+}
diff --git a/apps/web/src/lib/timeline/drop-utils.ts b/apps/web/src/lib/timeline/drop-utils.ts
new file mode 100644
index 00000000..8c22194a
--- /dev/null
+++ b/apps/web/src/lib/timeline/drop-utils.ts
@@ -0,0 +1,234 @@
+import type { TimelineTrack, ElementType } from "@/types/timeline";
+import { TRACK_HEIGHTS, TRACK_GAP } from "@/constants/timeline-constants";
+import { wouldElementOverlap } from "./element-utils";
+import type { ComputeDropTargetParams, DropTarget } from "@/types/timeline";
+import { isMainTrack } from "./track-utils";
+
+function getTrackAtY({
+ mouseY,
+ tracks,
+ verticalDragDirection,
+}: {
+ mouseY: number;
+ tracks: TimelineTrack[];
+ verticalDragDirection?: "up" | "down" | null;
+}): { trackIndex: number; relativeY: number } | null {
+ let cumulativeHeight = 0;
+
+ for (let i = 0; i < tracks.length; i++) {
+ const trackHeight = TRACK_HEIGHTS[tracks[i].type];
+ const trackTop = cumulativeHeight;
+ const trackBottom = trackTop + trackHeight;
+
+ if (mouseY >= trackTop && mouseY < trackBottom) {
+ return {
+ trackIndex: i,
+ relativeY: mouseY - trackTop,
+ };
+ }
+
+ if (i < tracks.length - 1 && verticalDragDirection) {
+ const gapTop = trackBottom;
+ const gapBottom = gapTop + TRACK_GAP;
+ if (mouseY >= gapTop && mouseY < gapBottom) {
+ const isDraggingUp = verticalDragDirection === "up";
+ return {
+ trackIndex: isDraggingUp ? i : i + 1,
+ relativeY: isDraggingUp ? trackHeight - 1 : 0,
+ };
+ }
+ }
+
+ cumulativeHeight += trackHeight + TRACK_GAP;
+ }
+
+ return null;
+}
+
+function isCompatible({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TimelineTrack["type"];
+}): boolean {
+ if (elementType === "text") return trackType === "text";
+ if (elementType === "audio") return trackType === "audio";
+ if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "video" || elementType === "image") {
+ return trackType === "video";
+ }
+ return false;
+}
+
+function getMainTrackIndex({ tracks }: { tracks: TimelineTrack[] }): number {
+ return tracks.findIndex((track) => isMainTrack(track));
+}
+
+function findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex,
+ insertAbove,
+}: {
+ elementType: ElementType;
+ tracks: TimelineTrack[];
+ preferredIndex: number;
+ insertAbove: boolean;
+}): { index: number; position: "above" | "below" } {
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (elementType === "audio") {
+ if (preferredIndex <= mainTrackIndex) {
+ return { index: mainTrackIndex + 1, position: "below" };
+ }
+ return {
+ index: insertAbove ? preferredIndex : preferredIndex + 1,
+ position: insertAbove ? "above" : "below",
+ };
+ }
+
+ const overlayInsertIndex = insertAbove ? preferredIndex : preferredIndex + 1;
+
+ if (mainTrackIndex >= 0 && overlayInsertIndex > mainTrackIndex) {
+ return { index: mainTrackIndex, position: "above" };
+ }
+
+ return {
+ index: overlayInsertIndex,
+ position: insertAbove ? "above" : "below",
+ };
+}
+
+export function computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime,
+ isExternalDrop,
+ elementDuration,
+ pixelsPerSecond,
+ zoomLevel,
+ verticalDragDirection,
+ startTimeOverride,
+ excludeElementId,
+}: ComputeDropTargetParams): DropTarget {
+ const xPosition =
+ typeof startTimeOverride === "number"
+ ? startTimeOverride
+ : isExternalDrop
+ ? playheadTime
+ : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel));
+
+ const mainTrackIndex = getMainTrackIndex({ tracks });
+
+ if (tracks.length === 0) {
+ if (elementType === "audio") {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ };
+ }
+ return { trackIndex: 0, isNewTrack: true, insertPosition: null, xPosition };
+ }
+
+ const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection });
+
+ if (!trackAtMouse) {
+ const isAboveAllTracks = mouseY < 0;
+
+ if (elementType === "audio") {
+ return {
+ trackIndex: tracks.length,
+ isNewTrack: true,
+ insertPosition: "below",
+ xPosition,
+ };
+ }
+
+ if (isAboveAllTracks) {
+ return {
+ trackIndex: 0,
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ };
+ }
+
+ return {
+ trackIndex: Math.max(0, mainTrackIndex),
+ isNewTrack: true,
+ insertPosition: "above",
+ xPosition,
+ };
+ }
+
+ const { trackIndex, relativeY } = trackAtMouse;
+ const track = tracks[trackIndex];
+ const trackHeight = TRACK_HEIGHTS[track.type];
+ const isInUpperHalf = relativeY < trackHeight / 2;
+
+ const isTrackCompatible = isCompatible({
+ elementType,
+ trackType: track.type,
+ });
+
+ const endTime = xPosition + elementDuration;
+ const hasOverlap = wouldElementOverlap({
+ elements: track.elements,
+ startTime: xPosition,
+ endTime,
+ excludeElementId,
+ });
+
+ if (isTrackCompatible && !hasOverlap) {
+ return {
+ trackIndex,
+ isNewTrack: false,
+ insertPosition: null,
+ xPosition,
+ };
+ }
+
+ let insertAbove = isInUpperHalf;
+ if (!isTrackCompatible && verticalDragDirection) {
+ insertAbove = verticalDragDirection === "up";
+ }
+
+ const { index, position } = findInsertIndex({
+ elementType,
+ tracks,
+ preferredIndex: trackIndex,
+ insertAbove,
+ });
+
+ return {
+ trackIndex: index,
+ isNewTrack: true,
+ insertPosition: position,
+ xPosition,
+ };
+}
+
+export function getDropLineY({
+ dropTarget,
+ tracks,
+}: {
+ dropTarget: DropTarget;
+ tracks: TimelineTrack[];
+}): number {
+ const safeTrackIndex = Math.min(
+ Math.max(dropTarget.trackIndex, 0),
+ tracks.length,
+ );
+ let y = 0;
+
+ for (let i = 0; i < safeTrackIndex; i++) {
+ y += TRACK_HEIGHTS[tracks[i].type] + TRACK_GAP;
+ }
+
+ return y;
+}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
new file mode 100644
index 00000000..a167e548
--- /dev/null
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -0,0 +1,304 @@
+import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type {
+ CreateTimelineElement,
+ CreateVideoElement,
+ CreateImageElement,
+ CreateStickerElement,
+ CreateUploadAudioElement,
+ CreateLibraryAudioElement,
+ TextElement,
+ TimelineElement,
+ TimelineTrack,
+ AudioElement,
+ VideoElement,
+ ImageElement,
+ StickerElement,
+ UploadAudioElement,
+} from "@/types/timeline";
+
+export function canElementHaveAudio(
+ element: TimelineElement,
+): element is AudioElement | VideoElement {
+ return element.type === "audio" || element.type === "video";
+}
+
+export function canElementBeHidden(
+ element: TimelineElement,
+): element is VideoElement | ImageElement | TextElement | StickerElement {
+ return element.type !== "audio";
+}
+
+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")
+ );
+}
+
+export function checkElementOverlaps({
+ elements,
+}: {
+ elements: TimelineElement[];
+}): boolean {
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime,
+ );
+
+ for (let i = 0; i < sortedElements.length - 1; i++) {
+ const current = sortedElements[i];
+ const next = sortedElements[i + 1];
+
+ const currentEnd = current.startTime + current.duration;
+
+ if (currentEnd > next.startTime) return true;
+ }
+
+ return false;
+}
+
+export function resolveElementOverlaps({
+ elements,
+}: {
+ elements: TimelineElement[];
+}): TimelineElement[] {
+ const sortedElements = [...elements].sort(
+ (a, b) => a.startTime - b.startTime,
+ );
+ const resolvedElements: TimelineElement[] = [];
+
+ for (let i = 0; i < sortedElements.length; i++) {
+ const current = { ...sortedElements[i] };
+
+ if (resolvedElements.length > 0) {
+ const previous = resolvedElements[resolvedElements.length - 1];
+ const previousEnd = previous.startTime + previous.duration;
+
+ if (current.startTime < previousEnd) {
+ current.startTime = previousEnd;
+ }
+ }
+
+ resolvedElements.push(current);
+ }
+
+ return resolvedElements;
+}
+
+export function wouldElementOverlap({
+ elements,
+ startTime,
+ endTime,
+ excludeElementId,
+}: {
+ elements: TimelineElement[];
+ startTime: number;
+ endTime: number;
+ excludeElementId?: string;
+}): boolean {
+ return elements.some((el) => {
+ if (excludeElementId && el.id === excludeElementId) return false;
+ const elEnd = el.startTime + el.duration;
+ return startTime < elEnd && endTime > el.startTime;
+ });
+}
+
+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 ?? DEFAULT_TEXT_ELEMENT.name,
+ content: t.content ?? DEFAULT_TEXT_ELEMENT.content,
+ duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize:
+ typeof t.fontSize === "number"
+ ? t.fontSize
+ : DEFAULT_TEXT_ELEMENT.fontSize,
+ fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily,
+ color: t.color ?? DEFAULT_TEXT_ELEMENT.color,
+ backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor,
+ textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign,
+ fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
+ fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
+ textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
+ transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform,
+ opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity,
+ };
+}
+
+export function buildStickerElement({
+ iconName,
+ startTime,
+}: {
+ iconName: string;
+ startTime: number;
+}): CreateStickerElement {
+ return {
+ type: "sticker",
+ name: iconName.split(":")[1] || iconName,
+ iconName,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export 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,
+ muted: false,
+ hidden: false,
+ transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export 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: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
+ opacity: 1,
+ };
+}
+
+export 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,
+ volume: 1,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+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,
+ volume: 1,
+ muted: false,
+ };
+ if (buffer) {
+ element.buffer = buffer;
+ }
+ return element;
+}
+
+export function getElementsAtTime({
+ tracks,
+ time,
+}: {
+ tracks: TimelineTrack[];
+ time: number;
+}): { trackId: string; elementId: string }[] {
+ const result: { trackId: string; elementId: string }[] = [];
+
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ const elementStart = element.startTime;
+ const elementEnd = element.startTime + element.duration;
+
+ if (time > elementStart && time < elementEnd) {
+ result.push({ trackId: track.id, elementId: element.id });
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
new file mode 100644
index 00000000..f3ace94f
--- /dev/null
+++ b/apps/web/src/lib/timeline/index.ts
@@ -0,0 +1,23 @@
+import type { TimelineTrack } from "@/types/timeline";
+
+export * from "./track-utils";
+export * from "./element-utils";
+export * from "./zoom-utils";
+export * from "./ruler-utils";
+
+export function calculateTotalDuration({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): number {
+ if (tracks.length === 0) return 0;
+
+ const trackEndTimes = tracks.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/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts
new file mode 100644
index 00000000..d0a67857
--- /dev/null
+++ b/apps/web/src/lib/timeline/ruler-utils.ts
@@ -0,0 +1,228 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+/**
+ * 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] 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: number;
+}): RulerConfig {
+ const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
+ const pixelsPerFrame = pixelsPerSecond / fps;
+
+ const labelIntervalSeconds = findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps,
+ minSpacingPx: MIN_LABEL_SPACING_PX,
+ frameIntervals: LABEL_FRAME_INTERVALS,
+ });
+
+ const rawTickIntervalSeconds = findOptimalInterval({
+ pixelsPerFrame,
+ pixelsPerSecond,
+ fps,
+ 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,
+ fps,
+ });
+
+ 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,
+ fps,
+}: {
+ tickIntervalSeconds: number;
+ labelIntervalSeconds: number;
+ pixelsPerFrame: 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;
+ }
+ }
+ }
+
+ // 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: number;
+}): string {
+ if (isSecondBoundary({ timeInSeconds })) {
+ return formatTimestamp({ timeInSeconds });
+ }
+
+ const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, 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.
+ */
+function formatTimestamp({ timeInSeconds }: { timeInSeconds: number }): string {
+ const totalSeconds = Math.round(timeInSeconds);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
+}
diff --git a/apps/web/src/lib/timeline/track-utils.ts b/apps/web/src/lib/timeline/track-utils.ts
new file mode 100644
index 00000000..b6261569
--- /dev/null
+++ b/apps/web/src/lib/timeline/track-utils.ts
@@ -0,0 +1,244 @@
+import type {
+ TrackType,
+ TimelineTrack,
+ ElementType,
+ VideoTrack,
+ AudioTrack,
+ StickerTrack,
+ TextTrack,
+} from "@/types/timeline";
+import {
+ TRACK_COLORS,
+ TRACK_HEIGHTS,
+ TRACK_GAP,
+} from "@/constants/timeline-constants";
+import { generateUUID } from "@/utils/id";
+
+export function canTracktHaveAudio(
+ track: TimelineTrack,
+): track is VideoTrack | AudioTrack {
+ return track.type === "audio" || track.type === "video";
+}
+
+export function canTrackBeHidden(
+ track: TimelineTrack,
+): track is VideoTrack | TextTrack | StickerTrack {
+ return track.type !== "audio";
+}
+
+export function getTrackColor({ type }: { type: TrackType }) {
+ return TRACK_COLORS[type];
+}
+
+export function getTrackClasses({ type }: { type: TrackType }) {
+ const colors = TRACK_COLORS[type];
+ return `${colors.background}`.trim();
+}
+
+export function getTrackHeight({ type }: { type: TrackType }): number {
+ return TRACK_HEIGHTS[type];
+}
+
+export function getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+}: {
+ tracks: Array<{ type: TrackType }>;
+ trackIndex: number;
+}): number {
+ return tracks
+ .slice(0, trackIndex)
+ .reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }) + TRACK_GAP,
+ 0,
+ );
+}
+
+export function getTotalTracksHeight({
+ tracks,
+}: {
+ tracks: Array<{ type: TrackType }>;
+}): number {
+ const tracksHeight = tracks.reduce(
+ (sum, track) => sum + getTrackHeight({ type: track.type }),
+ 0,
+ );
+ const gapsHeight = Math.max(0, tracks.length - 1) * TRACK_GAP;
+ return tracksHeight + gapsHeight;
+}
+
+export function buildEmptyTrack({
+ id,
+ type,
+ name,
+}: {
+ id: string;
+ type: TrackType;
+ name?: string;
+}): TimelineTrack {
+ const trackName =
+ name ??
+ (type === "video"
+ ? "Video track"
+ : type === "text"
+ ? "Text track"
+ : type === "audio"
+ ? "Audio track"
+ : type === "sticker"
+ ? "Sticker track"
+ : "Track");
+
+ switch (type) {
+ case "video":
+ return {
+ id,
+ name: trackName,
+ type: "video",
+ elements: [],
+ hidden: false,
+ muted: false,
+ isMain: false,
+ };
+ case "text":
+ return {
+ id,
+ name: trackName,
+ type: "text",
+ elements: [],
+ hidden: false,
+ };
+ case "sticker":
+ return {
+ id,
+ name: trackName,
+ type: "sticker",
+ elements: [],
+ hidden: false,
+ };
+ case "audio":
+ return {
+ id,
+ name: trackName,
+ type: "audio",
+ elements: [],
+ muted: false,
+ };
+ default:
+ throw new Error(`Unsupported track type: ${type}`);
+ }
+}
+
+export function getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ if (trackType === "audio") {
+ return tracks.length;
+ }
+
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+ if (mainTrackIndex >= 0) {
+ return mainTrackIndex;
+ }
+
+ const firstAudioTrackIndex = tracks.findIndex(
+ (track) => track.type === "audio",
+ );
+ if (firstAudioTrackIndex >= 0) {
+ return firstAudioTrackIndex;
+ }
+
+ return tracks.length;
+}
+
+export function getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+}: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
+}): number {
+ const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track));
+
+ if (trackType === "audio") {
+ return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length;
+ }
+
+ return 0;
+}
+
+export function isMainTrack(track: TimelineTrack): track is VideoTrack {
+ return track.type === "video" && track.isMain === true;
+}
+
+export function getMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack | null {
+ return tracks.find((track) => isMainTrack(track)) ?? null;
+}
+
+export function ensureMainTrack({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): TimelineTrack[] {
+ const hasMainTrack = tracks.some((track) => isMainTrack(track));
+
+ if (!hasMainTrack) {
+ const mainTrack: TimelineTrack = {
+ id: generateUUID(),
+ name: "Main Track",
+ type: "video",
+ elements: [],
+ muted: false,
+ isMain: true,
+ hidden: false,
+ };
+ return [mainTrack, ...tracks];
+ }
+
+ return tracks;
+}
+
+export function canElementGoOnTrack({
+ elementType,
+ trackType,
+}: {
+ elementType: ElementType;
+ trackType: TrackType;
+}): boolean {
+ if (elementType === "text") return trackType === "text";
+ if (elementType === "audio") return trackType === "audio";
+ if (elementType === "sticker") return trackType === "sticker";
+ if (elementType === "video" || elementType === "image") {
+ return trackType === "video";
+ }
+ return false;
+}
+
+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/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts
new file mode 100644
index 00000000..49f7fcb6
--- /dev/null
+++ b/apps/web/src/lib/timeline/zoom-utils.ts
@@ -0,0 +1,53 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+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 safeDuration = Math.max(duration, 1);
+ const safeContainerWidth = containerWidth ?? 1000;
+ const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO;
+ const availableWidth = safeContainerWidth * contentRatioAtMinZoom;
+ const zoomToFit =
+ availableWidth / (safeDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND);
+
+ return Math.min(TIMELINE_CONSTANTS.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_CONSTANTS.ZOOM_MAX - minZoom);
+}