diff --git a/.cursor/debug.log b/.cursor/debug.log deleted file mode 100644 index e69de29b..00000000 diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc index f1b23366..be1509dc 100644 --- a/.cursor/rules/codebase-index.mdc +++ b/.cursor/rules/codebase-index.mdc @@ -983,6 +983,33 @@ index.ts tracks: TimelineTrack[]; }): number +ruler-utils.ts + export interface RulerConfig { + labelIntervalSeconds: number + tickIntervalSeconds: number + } + export function getRulerConfig({ + zoomLevel, + fps, + }: { + zoomLevel: number; + fps: number; + }): RulerConfig + export function shouldShowLabel({ + time, + labelIntervalSeconds, + }: { + time: number; + labelIntervalSeconds: number; + }): boolean + export function formatRulerLabel({ + timeInSeconds, + fps, + }: { + timeInSeconds: number; + fps: number; + }): string + track-utils.ts export function canTracktHaveAudio( track: TimelineTrack, diff --git a/apps/web/src/components/editor/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/timeline/timeline-ruler.tsx index 4742f507..87702a4f 100644 --- a/apps/web/src/components/editor/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/timeline/timeline-ruler.tsx @@ -1,6 +1,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { DEFAULT_FPS } from "@/constants/project-constants"; import { useEditor } from "@/hooks/use-editor"; +import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils"; import { TimelineTick } from "./timeline-tick"; interface TimelineRulerProps { @@ -29,16 +30,26 @@ export function TimelineRuler({ const effectiveDuration = Math.max(duration, visibleDuration); const project = editor.project.getActive(); const fps = project?.settings.fps ?? DEFAULT_FPS; - const interval = getOptimalTimeInterval({ zoomLevel, fps }); - const markerCount = Math.ceil(effectiveDuration / interval) + 1; + const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ + zoomLevel, + fps, + }); + const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1; const timelineTicks: Array = []; - for (let markerIndex = 0; markerIndex < markerCount; markerIndex += 1) { - const time = markerIndex * interval; + for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) { + const time = tickIndex * tickIntervalSeconds; if (time > effectiveDuration) break; + const showLabel = shouldShowLabel({ time, labelIntervalSeconds }); timelineTicks.push( - , + , ); } @@ -50,7 +61,7 @@ export function TimelineRuler({ aria-valuemin={0} aria-valuemax={effectiveDuration} aria-valuenow={0} - className="relative h-4 flex-1 overflow-hidden" + className="relative h-4 flex-1 overflow-x-visible" onWheel={handleWheel} onClick={handleTimelineContentClick} onMouseDown={handleRulerTrackingMouseDown} @@ -70,33 +81,3 @@ export function TimelineRuler({ ); } - -function getOptimalTimeInterval({ - zoomLevel, - fps, -}: { - zoomLevel: number; - fps: number; -}) { - const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; - const pixelsPerFrame = pixelsPerSecond / fps; - const minPixelSpacing = 18; - const minFrames = minPixelSpacing / pixelsPerFrame; - const baseIntervals = [1, 3, 6, 12, 15, 30]; - const maxInterval = fps * 10; - const niceIntervals: Array = [...baseIntervals]; - - let currentInterval = baseIntervals[baseIntervals.length - 1]; - while (currentInterval < maxInterval) { - currentInterval *= 2; - niceIntervals.push(currentInterval); - } - - for (const intervalFrames of niceIntervals) { - if (intervalFrames >= minFrames) { - return intervalFrames / fps; - } - } - - return niceIntervals[niceIntervals.length - 1] / fps; -} diff --git a/apps/web/src/components/editor/timeline/timeline-tick.tsx b/apps/web/src/components/editor/timeline/timeline-tick.tsx index 30de450b..9402d25e 100644 --- a/apps/web/src/components/editor/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/timeline/timeline-tick.tsx @@ -1,19 +1,39 @@ "use client"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; interface TimelineTickProps { time: number; zoomLevel: number; + fps: number; + showLabel: boolean; } -export function TimelineTick({ time, zoomLevel }: TimelineTickProps) { +export function TimelineTick({ + time, + zoomLevel, + fps, + showLabel, +}: TimelineTickProps) { + const leftPosition = time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + + if (showLabel) { + const label = formatRulerLabel({ timeInSeconds: time, fps }); + return ( + + {label} + + ); + } + return (
+ className="border-muted-foreground/25 absolute bottom-0.5 h-1.5 border-l" + style={{ left: `${leftPosition}px` }} + /> ); } diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx index 9c22a87c..692ccd65 100644 --- a/apps/web/src/constants/timeline-constants.tsx +++ b/apps/web/src/constants/timeline-constants.tsx @@ -39,7 +39,7 @@ export const TIMELINE_CONSTANTS = { PADDING_TOP_PX: 0, ZOOM_LEVELS: [0.1, 0.25, 0.5, 1, 1.5, 2, 3, 4, 6, 8, 10, 15, 20, 30, 50], ZOOM_MIN: 0.1, - ZOOM_MAX: 50, + ZOOM_MAX: 100, ZOOM_STEP: 0.1, } as const; diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index fe72a9ed..f3ace94f 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -3,6 +3,7 @@ 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, 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")}`; +}