diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 940e144a4..19041d5e0 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + formatTimelineTickLabel, generateTicks, getDefaultDroppedTrack, getTimelineCanvasHeight, @@ -79,6 +80,20 @@ describe("generateTicks", () => { expect(major[0]).toBe(0); } }); + + it("uses denser major labels as timeline zoom increases", () => { + const fitTicks = generateTicks(180, 10); + const zoomedTicks = generateTicks(180, 48); + expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15); + expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5); + expect(zoomedTicks.minor).toContain(1); + expect(zoomedTicks.minor).toContain(4); + }); + + it("keeps labels readable instead of placing one at every tiny tick", () => { + const { major } = generateTicks(180, 80); + expect(major[1] - major[0]).toBe(2); + }); }); describe("formatTime", () => { @@ -119,6 +134,20 @@ describe("formatTime", () => { }); }); +describe("formatTimelineTickLabel", () => { + it("uses minute-second labels for normal timeline intervals", () => { + expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30"); + }); + + it("uses hour labels for long timelines", () => { + expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01"); + }); + + it("shows subsecond labels when the major ruler interval is below one second", () => { + expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5"); + }); +}); + describe("shouldAutoScrollTimeline", () => { it("never auto-scrolls in fit mode", () => { expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 810975875..13f971ba4 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle { } /* ── Tick Generation ────────────────────────────────────────────── */ -export function generateTicks(duration: number): { major: number[]; minor: number[] } { +function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number { + const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; + if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) { + const targetMajorPx = 128; + return ( + zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600 + ); + } + const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60]; + const target = duration / 6; + return durationIntervals.find((interval) => interval >= target) ?? 60; +} + +function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number { + let interval = majorInterval / 2; + if (majorInterval >= 30) interval = majorInterval / 6; + else if (majorInterval >= 15) interval = majorInterval / 3; + else if (majorInterval >= 5) interval = majorInterval / 5; + else if (majorInterval >= 1) interval = majorInterval / 4; + + if ( + Number.isFinite(pixelsPerSecond) && + (pixelsPerSecond ?? 0) > 0 && + interval * (pixelsPerSecond ?? 0) < 20 + ) { + return Math.max(0.25, majorInterval / 2); + } + return Math.max(0.25, interval); +} + +export function generateTicks( + duration: number, + pixelsPerSecond?: number, +): { major: number[]; minor: number[] } { if (duration <= 0 || !Number.isFinite(duration) || duration > 7200) return { major: [], minor: [] }; - const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60]; - const target = duration / 6; - const majorInterval = intervals.find((i) => i >= target) ?? 60; - const minorInterval = Math.max(0.25, majorInterval / 2); + const majorInterval = getMajorTickInterval(duration, pixelsPerSecond); + const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond); const major: number[] = []; const minor: number[] = []; - const maxTicks = 500; // Safety cap to prevent infinite loop + const maxTicks = 2000; // Safety cap to prevent runaway tick generation for ( let t = 0; t <= duration + 0.001 && major.length + minor.length < maxTicks; @@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe return { major, minor }; } +export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) { + if (!Number.isFinite(time)) return "0:00"; + const safeTime = Math.max(0, time); + if (majorInterval < 1) { + const totalTenths = Math.round(safeTime * 10); + const wholeSeconds = Math.floor(totalTenths / 10); + const tenth = totalTenths % 10; + return `${formatTime(wholeSeconds)}.${tenth}`; + } + if (duration >= 3600 || safeTime >= 3600) { + const totalSeconds = Math.floor(safeTime); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + return formatTime(safeTime); +} + export function shouldAutoScrollTimeline( zoomMode: ZoomMode, scrollWidth: number, @@ -956,7 +1006,12 @@ export const Timeline = memo(function Timeline({ cancelAnimationFrame(dragScrollRaf.current); }, []); - const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]); + const { major, minor } = useMemo( + () => generateTicks(effectiveDuration, pps), + [effectiveDuration, pps], + ); + const majorTickInterval = + major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration; const getPreviewElement = useCallback( (element: TimelineElement): TimelineElement => { if (resizingClip?.element.id === element.id) { @@ -1324,7 +1379,7 @@ export const Timeline = memo(function Timeline({ className="text-[9px] font-mono tabular-nums leading-none mb-0.5" style={{ color: theme.tickText }} > - {formatTime(t)} + {formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}