mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user