Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/studio/src/player/components/timelineZoom.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
}
});
51 changes: 51 additions & 0 deletions packages/studio/src/player/components/timelineZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
38 changes: 38 additions & 0 deletions packages/studio/src/utils/studioUiPreferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
27 changes: 27 additions & 0 deletions packages/studio/src/utils/studioUiPreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {};
Expand Down
121 changes: 121 additions & 0 deletions packages/studio/src/utils/timelineInspector.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 32 additions & 1 deletion packages/studio/src/utils/timelineInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Loading