From 43c388c19ec865c5c2381dd85ddb71d46f345c71 Mon Sep 17 00:00:00 2001 From: ukimsanov Date: Sat, 11 Jul 2026 13:55:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20timeline=20leaf=20helpers=20?= =?UTF-8?q?=E2=80=94=20audio=20inspector,=20zoom=20math,=20UI=20prefs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../player/components/timelineZoom.test.ts | 67 ++++++++++ .../src/player/components/timelineZoom.ts | 51 ++++++++ .../src/utils/studioUiPreferences.test.ts | 38 ++++++ .../studio/src/utils/studioUiPreferences.ts | 27 ++++ .../src/utils/timelineInspector.test.ts | 121 ++++++++++++++++++ .../studio/src/utils/timelineInspector.ts | 33 ++++- 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/utils/timelineInspector.test.ts diff --git a/packages/studio/src/player/components/timelineZoom.test.ts b/packages/studio/src/player/components/timelineZoom.test.ts index 3e4a35cac5..8b2fc00572 100644 --- a/packages/studio/src/player/components/timelineZoom.test.ts +++ b/packages/studio/src/player/components/timelineZoom.test.ts @@ -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); + }); + } +}); diff --git a/packages/studio/src/player/components/timelineZoom.ts b/packages/studio/src/player/components/timelineZoom.ts index 0ac4a92b27..15baa15667 100644 --- a/packages/studio/src/player/components/timelineZoom.ts +++ b/packages/studio/src/player/components/timelineZoom.ts @@ -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)); +} diff --git a/packages/studio/src/utils/studioUiPreferences.test.ts b/packages/studio/src/utils/studioUiPreferences.test.ts index c5dbd50968..5e335cf5d3 100644 --- a/packages/studio/src/utils/studioUiPreferences.test.ts +++ b/packages/studio/src/utils/studioUiPreferences.test.ts @@ -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(); + }); +}); diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts index 13804959a6..6548a58eba 100644 --- a/packages/studio/src/utils/studioUiPreferences.ts +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -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 {}; diff --git a/packages/studio/src/utils/timelineInspector.test.ts b/packages/studio/src/utils/timelineInspector.test.ts new file mode 100644 index 0000000000..318f3cbb02 --- /dev/null +++ b/packages/studio/src/utils/timelineInspector.test.ts @@ -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 + >, +): Pick { + 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); + }); +}); diff --git a/packages/studio/src/utils/timelineInspector.ts b/packages/studio/src/utils/timelineInspector.ts index b1297e1900..4a33e8fc2f 100644 --- a/packages/studio/src/utils/timelineInspector.ts +++ b/packages/studio/src/utils/timelineInspector.ts @@ -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 | 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; +}