summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/timeline/ruler-utils.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/timeline/ruler-utils.ts')
-rw-r--r--apps/web/src/lib/timeline/ruler-utils.ts257
1 files changed, 0 insertions, 257 deletions
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}`;
-}