mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
feat(studio): timeline leaf helpers — audio inspector, zoom math, UI prefs (#2192)
What: extends three leaf modules to their final NLE-stack form, tests in the same change: timelineInspector (isAudioTimelineElement, resolveBeatSourceTrack), timelineZoom (zoom/pps math incl. computePinnedZoomPercent), and studioUiPreferences (persisted editor prefs). Why: leaf dependencies of the NLE timeline stack; landing them first keeps the later glue PRs to wiring. How: additive from the consumer side — every export main already uses is unchanged (typecheck against main's consumers passes untouched); every new export is exercised by a test in this PR. Test plan: bunx vitest run on the three test files; tsc --noEmit in packages/studio; fallow audit --base origin/main clean.
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampTimelineZoomPercent,
|
||||
computePinnedZoomPercent,
|
||||
getNextTimelineZoomPercent,
|
||||
getPinchTimelineZoomPercent,
|
||||
getTimelinePixelsPerSecond,
|
||||
getTimelineZoomPercent,
|
||||
MAX_TIMELINE_ZOOM_PERCENT,
|
||||
MIN_TIMELINE_ZOOM_PERCENT,
|
||||
timelineZoomPercentToSlider,
|
||||
timelineSliderToZoomPercent,
|
||||
} from "./timelineZoom";
|
||||
|
||||
describe("clampTimelineZoomPercent", () => {
|
||||
@@ -81,3 +84,67 @@ describe("getPinchTimelineZoomPercent", () => {
|
||||
expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timelineZoomPercentToSlider", () => {
|
||||
it("maps min zoom to slider position 0", () => {
|
||||
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(0, 5);
|
||||
});
|
||||
|
||||
it("maps max zoom to slider position 100", () => {
|
||||
expect(timelineZoomPercentToSlider(MAX_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(100, 5);
|
||||
});
|
||||
|
||||
it("maps 100% to the log midpoint between 10 and 2000", () => {
|
||||
const expected = ((Math.log(100) - Math.log(10)) / (Math.log(2000) - Math.log(10))) * 100;
|
||||
expect(timelineZoomPercentToSlider(100)).toBeCloseTo(expected, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timelineSliderToZoomPercent", () => {
|
||||
it("maps slider 0 to min zoom", () => {
|
||||
expect(timelineSliderToZoomPercent(0)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
});
|
||||
|
||||
it("maps slider 100 to max zoom", () => {
|
||||
expect(timelineSliderToZoomPercent(100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computePinnedZoomPercent", () => {
|
||||
it("returns 100 when current pps equals the fit pps (a no-op pin at the current fit)", () => {
|
||||
expect(computePinnedZoomPercent(42, 42)).toBe(100);
|
||||
});
|
||||
|
||||
it("reproduces the current pps: percent × fitPps / 100 === currentPps", () => {
|
||||
const fitPps = 20;
|
||||
const currentPps = 50; // user zoomed in 2.5×
|
||||
const percent = computePinnedZoomPercent(currentPps, fitPps);
|
||||
expect(percent).toBe(250);
|
||||
// Round-trips through getTimelinePixelsPerSecond back to the on-screen pps.
|
||||
expect(getTimelinePixelsPerSecond(fitPps, "manual", percent)).toBeCloseTo(currentPps, 5);
|
||||
});
|
||||
|
||||
it("clamps a pin that would exceed the manual-zoom bounds", () => {
|
||||
// currentPps 10000 / fitPps 1 = 1_000_000% → clamped to MAX.
|
||||
expect(computePinnedZoomPercent(10000, 1)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
// Tiny ratio → clamped up to MIN.
|
||||
expect(computePinnedZoomPercent(0.001, 1000)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
});
|
||||
|
||||
it("falls back to 100 for unusable inputs (a safe no-op pin)", () => {
|
||||
expect(computePinnedZoomPercent(Number.NaN, 20)).toBe(100);
|
||||
expect(computePinnedZoomPercent(50, 0)).toBe(100);
|
||||
expect(computePinnedZoomPercent(-5, 20)).toBe(100);
|
||||
expect(computePinnedZoomPercent(50, Number.POSITIVE_INFINITY)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timelineZoomPercentToSlider / timelineSliderToZoomPercent round-trip", () => {
|
||||
for (const percent of [10, 100, 500, 2000]) {
|
||||
it(`round-trips ${percent}% within ±1%`, () => {
|
||||
const slider = timelineZoomPercentToSlider(percent);
|
||||
const back = timelineSliderToZoomPercent(slider);
|
||||
expect(Math.abs(back - percent) / percent).toBeLessThan(0.01);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,34 @@ export function getTimelineZoomPercent(zoomMode: ZoomMode, manualZoomPercent: nu
|
||||
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent);
|
||||
}
|
||||
|
||||
/**
|
||||
* The manual-zoom percent that, applied to `fitPixelsPerSecond`, reproduces the
|
||||
* CURRENT on-screen pixels-per-second exactly. Used to PIN the timeline zoom on
|
||||
* the first edit so a duration change (which recomputes fit-pps) no longer
|
||||
* rescales every clip: we switch `zoomMode` to "manual" with this percent, so
|
||||
* `getTimelinePixelsPerSecond` keeps returning today's pps regardless of the new
|
||||
* fit basis.
|
||||
*
|
||||
* Since `pps = fitPps * (percent / 100)` in manual mode, and while fitting
|
||||
* `pps === fitPps`, the pinned percent is `currentPps / fitPps * 100`. Clamped to
|
||||
* the manual-zoom range so the pin can't land outside the slider's bounds; falls
|
||||
* back to 100 (a no-op pin at the current fit) when either input is unusable.
|
||||
*/
|
||||
export function computePinnedZoomPercent(
|
||||
currentPixelsPerSecond: number,
|
||||
fitPixelsPerSecond: number,
|
||||
): number {
|
||||
if (
|
||||
!Number.isFinite(currentPixelsPerSecond) ||
|
||||
currentPixelsPerSecond <= 0 ||
|
||||
!Number.isFinite(fitPixelsPerSecond) ||
|
||||
fitPixelsPerSecond <= 0
|
||||
) {
|
||||
return 100;
|
||||
}
|
||||
return clampTimelineZoomPercent((currentPixelsPerSecond / fitPixelsPerSecond) * 100);
|
||||
}
|
||||
|
||||
export function getTimelinePixelsPerSecond(
|
||||
fitPixelsPerSecond: number,
|
||||
zoomMode: ZoomMode,
|
||||
@@ -47,3 +75,26 @@ export function getPinchTimelineZoomPercent(
|
||||
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
||||
return clampTimelineZoomPercent(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
|
||||
}
|
||||
|
||||
const LOG_MIN = Math.log(MIN_TIMELINE_ZOOM_PERCENT);
|
||||
const LOG_MAX = Math.log(MAX_TIMELINE_ZOOM_PERCENT);
|
||||
|
||||
/**
|
||||
* Maps a zoom percent (10–2000) to a slider position (0–100) using a log scale.
|
||||
* Log scale is used because the range spans 200×; linear would compress the
|
||||
* low end (10–100%) into a tiny sliver of the slider.
|
||||
*/
|
||||
export function timelineZoomPercentToSlider(percent: number): number {
|
||||
const clamped = clampTimelineZoomPercent(percent);
|
||||
return ((Math.log(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a slider position (0–100) to a zoom percent (10–2000) using a log scale.
|
||||
* Inverse of `timelineZoomPercentToSlider`.
|
||||
*/
|
||||
export function timelineSliderToZoomPercent(slider: number): number {
|
||||
const clampedSlider = Math.max(0, Math.min(100, slider));
|
||||
const logValue = LOG_MIN + (clampedSlider / 100) * (LOG_MAX - LOG_MIN);
|
||||
return clampTimelineZoomPercent(Math.exp(logValue));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user