summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/ruler-utils.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/ruler-utils.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/ruler-utils.ts')
-rw-r--r--apps/web/src/timeline/ruler-utils.ts257
1 files changed, 257 insertions, 0 deletions
diff --git a/apps/web/src/timeline/ruler-utils.ts b/apps/web/src/timeline/ruler-utils.ts
new file mode 100644
index 00000000..d355ef31
--- /dev/null
+++ b/apps/web/src/timeline/ruler-utils.ts
@@ -0,0 +1,257 @@
+import type { FrameRate } from "opencut-wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { frameRateToFloat } from "@/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}`;
+}