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:
Ular Kimsanov
2026-07-11 20:20:29 -04:00
committed by GitHub
parent ae4946e624
commit 920dad35eb
6 changed files with 336 additions and 1 deletions
@@ -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 (102000) to a slider position (0100) using a log scale.
* Log scale is used because the range spans 200×; linear would compress the
* low end (10100%) 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 (0100) to a zoom percent (102000) 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));
}
@@ -50,3 +50,41 @@ describe("studio UI preferences", () => {
});
});
});
describe("timelineSnapEnabled preference", () => {
it("round-trips through storage", () => {
const storage = createStorage();
writeStudioUiPreferences({ timelineSnapEnabled: false }, storage);
expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBe(false);
});
it("ignores non-boolean values", () => {
const storage = createStorage();
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ timelineSnapEnabled: "yes" }));
expect(readStudioUiPreferences(storage).timelineSnapEnabled).toBeUndefined();
});
});
describe("timeline zoom pin persistence", () => {
it("round-trips a pinned manual zoom (survives the post-edit reload)", () => {
const storage = createStorage();
writeStudioUiPreferences(
{ timelineZoomMode: "manual", timelineManualZoomPercent: 250 },
storage,
);
const prefs = readStudioUiPreferences(storage);
expect(prefs.timelineZoomMode).toBe("manual");
expect(prefs.timelineManualZoomPercent).toBe(250);
});
it("ignores an invalid zoom mode and a non-finite percent", () => {
const storage = createStorage();
storage.setItem(
"hf-studio-ui-preferences",
JSON.stringify({ timelineZoomMode: "zoomy", timelineManualZoomPercent: "big" }),
);
const prefs = readStudioUiPreferences(storage);
expect(prefs.timelineZoomMode).toBeUndefined();
expect(prefs.timelineManualZoomPercent).toBeUndefined();
});
});
@@ -16,6 +16,18 @@ export interface StudioUiPreferences {
gridVisible?: boolean;
gridSpacing?: number;
snapToGrid?: boolean;
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
timelineSnapEnabled?: boolean;
/** Transport + ruler readout mode: timecode or frame number. */
timeDisplayMode?: "time" | "frame";
/**
* Timeline zoom mode. Persisted so a zoom PINNED on the first edit survives the
* post-edit iframe reload — otherwise the store reset to "fit" and the duration
* change rescaled every clip (the blink-fix's rescale symptom).
*/
timelineZoomMode?: "fit" | "manual";
/** Manual timeline zoom percent, paired with `timelineZoomMode: "manual"`. */
timelineManualZoomPercent?: number;
}
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
@@ -88,6 +100,21 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
if (typeof parsed.snapToGrid === "boolean") {
preferences.snapToGrid = parsed.snapToGrid;
}
if (typeof parsed.timelineSnapEnabled === "boolean") {
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
}
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
preferences.timeDisplayMode = parsed.timeDisplayMode;
}
if (parsed.timelineZoomMode === "fit" || parsed.timelineZoomMode === "manual") {
preferences.timelineZoomMode = parsed.timelineZoomMode;
}
if (
typeof parsed.timelineManualZoomPercent === "number" &&
Number.isFinite(parsed.timelineManualZoomPercent)
) {
preferences.timelineManualZoomPercent = parsed.timelineManualZoomPercent;
}
return preferences;
} catch {
return {};
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { isAudioTimelineElement, isMusicTrack, resolveBeatSourceTrack } from "./timelineInspector";
import type { TimelineElement } from "../player";
// Minimal element factory for tests
function el(
overrides: Partial<
Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration">
>,
): Pick<TimelineElement, "tag" | "src" | "id" | "domId" | "timelineRole" | "duration"> {
return {
tag: "audio",
src: "assets/track.mp3",
id: "el-1",
domId: "el-1",
timelineRole: undefined,
duration: 10,
...overrides,
};
}
describe("isAudioTimelineElement", () => {
it("is true for audio tag", () => {
expect(isAudioTimelineElement(el({ tag: "audio" }))).toBe(true);
});
it("is true for music/sfx/narration semantic tags", () => {
expect(isAudioTimelineElement(el({ tag: "music" }))).toBe(true);
expect(isAudioTimelineElement(el({ tag: "sfx" }))).toBe(true);
expect(isAudioTimelineElement(el({ tag: "narration" }))).toBe(true);
});
it("is true for img/div with an audio src extension", () => {
expect(isAudioTimelineElement(el({ tag: "div", src: "assets/bg.mp3" }))).toBe(true);
expect(isAudioTimelineElement(el({ tag: "div", src: "assets/fx.wav" }))).toBe(true);
});
it("is false for video and image elements", () => {
expect(isAudioTimelineElement(el({ tag: "video", src: "assets/clip.mp4" }))).toBe(false);
expect(isAudioTimelineElement(el({ tag: "img", src: "assets/logo.svg" }))).toBe(false);
});
it("returns false for null/undefined", () => {
expect(isAudioTimelineElement(null)).toBe(false);
expect(isAudioTimelineElement(undefined)).toBe(false);
});
});
describe("isMusicTrack", () => {
it("is true when timelineRole is 'music'", () => {
expect(isMusicTrack(el({ timelineRole: "music" }))).toBe(true);
});
it("is false for explicit non-music roles", () => {
expect(isMusicTrack(el({ timelineRole: "sfx" }))).toBe(false);
expect(isMusicTrack(el({ timelineRole: "voiceover" }))).toBe(false);
});
it("matches music-like ids when no role is set", () => {
expect(isMusicTrack(el({ domId: "bgm", timelineRole: undefined }))).toBe(true);
expect(isMusicTrack(el({ domId: "background_music", timelineRole: undefined }))).toBe(true);
expect(isMusicTrack(el({ domId: "soundtrack", timelineRole: undefined }))).toBe(true);
});
it("is false for generic ids with no role", () => {
expect(isMusicTrack(el({ domId: "my_audio_file", timelineRole: undefined }))).toBe(false);
expect(isMusicTrack(el({ domId: "drop_1", timelineRole: undefined }))).toBe(false);
});
it("is false for non-audio elements even with a music-like id", () => {
expect(isMusicTrack(el({ tag: "img", src: "assets/logo.svg", domId: "bgm" }))).toBe(false);
});
});
describe("resolveBeatSourceTrack", () => {
it("returns null when there are no elements", () => {
expect(resolveBeatSourceTrack([])).toBeNull();
});
it("returns null when there are no audio elements", () => {
const elements = [el({ tag: "img", src: "assets/logo.png" })];
expect(resolveBeatSourceTrack(elements)).toBeNull();
});
it("returns isFallback=false for an explicitly tagged music track", () => {
const music = el({ timelineRole: "music" });
const result = resolveBeatSourceTrack([music]);
expect(result).not.toBeNull();
expect(result!.isFallback).toBe(false);
expect(result!.element).toBe(music);
});
it("prefers the explicit music track over a longer untagged clip", () => {
const music = el({ timelineRole: "music", duration: 30 });
const other = el({ id: "drop_1", domId: "drop_1", timelineRole: undefined, duration: 120 });
const result = resolveBeatSourceTrack([music, other]);
expect(result!.element).toBe(music);
expect(result!.isFallback).toBe(false);
});
it("falls back to the longest untagged audio clip when no music track exists", () => {
const short = el({ id: "drop_1", domId: "drop_1", duration: 5, timelineRole: undefined });
const long = el({ id: "drop_2", domId: "drop_2", duration: 60, timelineRole: undefined });
const result = resolveBeatSourceTrack([short, long]);
expect(result).not.toBeNull();
expect(result!.isFallback).toBe(true);
expect(result!.element).toBe(long);
});
it("excludes explicitly non-music roles (sfx, voiceover) from the fallback", () => {
const sfx = el({ id: "sfx_1", domId: "sfx_1", timelineRole: "sfx", duration: 30 });
const vo = el({ id: "vo_1", domId: "vo_1", timelineRole: "voiceover", duration: 20 });
expect(resolveBeatSourceTrack([sfx, vo])).toBeNull();
});
it("returns isFallback=true for an untagged clip with a non-music id", () => {
const clip = el({ id: "my_audio_file", domId: "my_audio_file", timelineRole: undefined });
const result = resolveBeatSourceTrack([clip]);
expect(result!.isFallback).toBe(true);
});
});
+32 -1
View File
@@ -4,7 +4,7 @@ const AUDIO_TIMELINE_TAGS = new Set(["audio", "music", "sfx", "sound", "narratio
const AUDIO_SOURCE_EXT_RE = /\.(aac|flac|m4a|mp3|ogg|opus|wav)(?:[?#].*)?$/i;
const MUSIC_ID_RE = /\b(music|bgm|soundtrack|background[-_]?music)\b/i;
function isAudioTimelineElement(
export function isAudioTimelineElement(
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
): boolean {
if (!element) return false;
@@ -29,3 +29,34 @@ export function isMusicTrack(
const id = element.domId ?? element.id ?? "";
return MUSIC_ID_RE.test(id);
}
/**
* Resolve the best audio source for beat analysis. An explicitly tagged or
* named music track wins; when none is present (e.g. an audio file dropped
* from Finder with a generic id), the LONGEST untagged audio clip is used as a
* fallback. Ties on duration resolve to the FIRST such clip encountered (the loop
* keeps the current best on `>` only), i.e. discovery/DOM order wins.
* Returns the element and whether it was found via the fallback path.
*
* The `isMusicTrack` predicate is unchanged so beat-snap and drag-exclusion
* logic remain unaffected by this fallback.
*/
export function resolveBeatSourceTrack(
elements: readonly Pick<
TimelineElement,
"tag" | "src" | "id" | "domId" | "timelineRole" | "duration"
>[],
): { element: (typeof elements)[number]; isFallback: boolean } | null {
const explicit = elements.find(isMusicTrack);
if (explicit) return { element: explicit, isFallback: false };
// Fallback: pick the longest audio clip (skipping explicitly non-music roles
// like "sfx" or "voiceover" to avoid triggering beat analysis on those).
let best: (typeof elements)[number] | null = null;
for (const el of elements) {
if (!isAudioTimelineElement(el)) continue;
if (el.timelineRole && el.timelineRole !== "music") continue;
if (!best || el.duration > best.duration) best = el;
}
return best ? { element: best, isFallback: true } : null;
}