mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): adapt timeline ruler density
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
formatTimelineTickLabel,
|
||||||
generateTicks,
|
generateTicks,
|
||||||
getDefaultDroppedTrack,
|
getDefaultDroppedTrack,
|
||||||
getTimelineCanvasHeight,
|
getTimelineCanvasHeight,
|
||||||
@@ -79,6 +80,20 @@ describe("generateTicks", () => {
|
|||||||
expect(major[0]).toBe(0);
|
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", () => {
|
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", () => {
|
describe("shouldAutoScrollTimeline", () => {
|
||||||
it("never auto-scrolls in fit mode", () => {
|
it("never auto-scrolls in fit mode", () => {
|
||||||
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
|
||||||
|
|||||||
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tick Generation ────────────────────────────────────────────── */
|
/* ── 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)
|
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
|
||||||
return { major: [], minor: [] };
|
return { major: [], minor: [] };
|
||||||
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
|
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
|
||||||
const target = duration / 6;
|
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
|
||||||
const majorInterval = intervals.find((i) => i >= target) ?? 60;
|
|
||||||
const minorInterval = Math.max(0.25, majorInterval / 2);
|
|
||||||
const major: number[] = [];
|
const major: number[] = [];
|
||||||
const minor: number[] = [];
|
const minor: number[] = [];
|
||||||
const maxTicks = 500; // Safety cap to prevent infinite loop
|
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
|
||||||
for (
|
for (
|
||||||
let t = 0;
|
let t = 0;
|
||||||
t <= duration + 0.001 && major.length + minor.length < maxTicks;
|
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 };
|
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(
|
export function shouldAutoScrollTimeline(
|
||||||
zoomMode: ZoomMode,
|
zoomMode: ZoomMode,
|
||||||
scrollWidth: number,
|
scrollWidth: number,
|
||||||
@@ -956,7 +1006,12 @@ export const Timeline = memo(function Timeline({
|
|||||||
cancelAnimationFrame(dragScrollRaf.current);
|
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(
|
const getPreviewElement = useCallback(
|
||||||
(element: TimelineElement): TimelineElement => {
|
(element: TimelineElement): TimelineElement => {
|
||||||
if (resizingClip?.element.id === element.id) {
|
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"
|
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
||||||
style={{ color: theme.tickText }}
|
style={{ color: theme.tickText }}
|
||||||
>
|
>
|
||||||
{formatTime(t)}
|
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user