mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
feat(studio): leaf helper extensions — seek restore, zoom math, UI prefs, audio inspector, files route
What: extends five leaf modules to their final NLE-stack form, each with its tests in the same change: timelineInspector (isAudioTimelineElement, resolveBeatSourceTrack), timelineZoom (zoom/pps math), studioUiPreferences (persisted editor prefs), useTimelineSyncCallbacks (resolveReloadSeekTime, revealIframe — the reload seek-restore contract), and the studio-server files route. Why: these are the leaf dependencies of the upcoming NLE timeline integration. Landing them first keeps that PR to glue and lets each contract be reviewed on its own. How: additions only from the consumer side — every export main already uses is unchanged (typecheck of packages/studio and packages/studio-server against main's consumers passes untouched); every new export is exercised by a test in this PR. Test plan: bunx vitest run on the five test files (60 studio + 25 studio-server tests); tsc --noEmit in packages/studio and packages/studio-server; fallow audit --base origin/main clean.
This commit is contained in:
@@ -805,4 +805,54 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0);
|
||||
// The variable target was not flattened to a string-literal selector
|
||||
expect(result.after).toContain("tl.to(kicker,");
|
||||
});
|
||||
|
||||
it("shift-positions-batch equals sequential single shifts (atomic multi-clip)", async () => {
|
||||
const TWO_TWEENS = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#a", { duration: 1, x: 100 }, 1);
|
||||
tl.to("#b", { duration: 1, x: 200 }, 2);
|
||||
</script></body></html>`;
|
||||
|
||||
const seqDir = createProjectDir();
|
||||
writeHtml(seqDir, "seq.html", TWO_TWEENS);
|
||||
const seqApp = new Hono();
|
||||
registerFileRoutes(seqApp, createAdapter(seqDir));
|
||||
await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "shift-positions", targetSelector: "#a", delta: 1 }),
|
||||
});
|
||||
const seqRes = await seqApp.request("http://localhost/projects/demo/gsap-mutations/seq.html", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: "shift-positions", targetSelector: "#b", delta: 0.5 }),
|
||||
});
|
||||
const seqAfter = ((await seqRes.json()) as { after: string }).after;
|
||||
|
||||
const batchDir = createProjectDir();
|
||||
writeHtml(batchDir, "batch.html", TWO_TWEENS);
|
||||
const batchApp = new Hono();
|
||||
registerFileRoutes(batchApp, createAdapter(batchDir));
|
||||
const batchRes = await batchApp.request(
|
||||
"http://localhost/projects/demo/gsap-mutations/batch.html",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "shift-positions-batch",
|
||||
shifts: [
|
||||
{ targetSelector: "#a", delta: 1 },
|
||||
{ targetSelector: "#b", delta: 0.5 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
const batch = (await batchRes.json()) as { ok: boolean; changed: boolean; after: string };
|
||||
|
||||
expect(batchRes.status).toBe(200);
|
||||
expect(batch.ok).toBe(true);
|
||||
expect(batch.changed).toBe(true);
|
||||
// Batching #a then #b in one write == applying them as two sequential single shifts.
|
||||
expect(batch.after).toBe(seqAfter);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -773,6 +773,13 @@ type GsapMutationRequest =
|
||||
targetSelector: string;
|
||||
delta: number;
|
||||
}
|
||||
| {
|
||||
// Batched shift: fold shiftPositionsInScript over N selectors in one write.
|
||||
// Lets a multi-clip timeline move (ripple / insert) shift every affected
|
||||
// clip's tweens atomically instead of one racing server round-trip per clip.
|
||||
type: "shift-positions-batch";
|
||||
shifts: Array<{ targetSelector: string; delta: number }>;
|
||||
}
|
||||
| {
|
||||
type: "scale-positions";
|
||||
targetSelector: string;
|
||||
@@ -815,6 +822,7 @@ const HOLD_SYNC_MUTATION_TYPES = new Set<string>([
|
||||
// Time-shift / time-scale tweens, which can move a keyframed position tween's start
|
||||
// across t=0, flipping hold need; stale holds are not repositioned by these ops.
|
||||
"shift-positions",
|
||||
"shift-positions-batch",
|
||||
"scale-positions",
|
||||
// Retargets keyframed position tweens to a cloned element's selector; the old hold is
|
||||
// keyed to the prior selector, so holds must be rebuilt for the new target.
|
||||
@@ -1101,6 +1109,14 @@ function executeGsapMutationAcorn(
|
||||
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
|
||||
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
|
||||
}
|
||||
case "shift-positions-batch": {
|
||||
let script = block.scriptText;
|
||||
for (const s of body.shifts) {
|
||||
if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue;
|
||||
script = shiftPositionsInScript(script, s.targetSelector, s.delta);
|
||||
}
|
||||
return script;
|
||||
}
|
||||
case "scale-positions": {
|
||||
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
||||
if (
|
||||
@@ -1463,6 +1479,15 @@ async function executeGsapMutationRecast(
|
||||
const { shiftPositionsInScript } = parser;
|
||||
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
|
||||
}
|
||||
case "shift-positions-batch": {
|
||||
const { shiftPositionsInScript } = parser;
|
||||
let script = block.scriptText;
|
||||
for (const s of body.shifts) {
|
||||
if (!s.targetSelector || !Number.isFinite(s.delta) || s.delta === 0) continue;
|
||||
script = shiftPositionsInScript(script, s.targetSelector, s.delta);
|
||||
}
|
||||
return script;
|
||||
}
|
||||
case "scale-positions": {
|
||||
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
||||
if (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReloadSeekTime, revealIframe } from "./useTimelineSyncCallbacks";
|
||||
|
||||
// Minimal stand-in — revealIframe only touches `style.visibility`. Avoids
|
||||
// depending on a DOM environment (this test file runs under node).
|
||||
function fakeIframe(visibility: string): HTMLIFrameElement {
|
||||
return { style: { visibility } } as unknown as HTMLIFrameElement;
|
||||
}
|
||||
|
||||
describe("revealIframe", () => {
|
||||
it("clears a hidden iframe's visibility (undoes refreshPlayer's hide)", () => {
|
||||
const iframe = fakeIframe("hidden");
|
||||
revealIframe(iframe);
|
||||
expect(iframe.style.visibility).toBe("");
|
||||
});
|
||||
|
||||
it("leaves an already-visible iframe untouched (idempotent)", () => {
|
||||
const iframe = fakeIframe("");
|
||||
revealIframe(iframe);
|
||||
expect(iframe.style.visibility).toBe("");
|
||||
});
|
||||
|
||||
it("no-ops on a null iframe", () => {
|
||||
expect(() => revealIframe(null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReloadSeekTime", () => {
|
||||
it("restores the pending seek saved by refreshPlayer (the primary reload path)", () => {
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: 7.2,
|
||||
requestedSeek: null,
|
||||
storeCurrentTime: 7.2,
|
||||
duration: 20,
|
||||
}),
|
||||
).toBe(7.2);
|
||||
});
|
||||
|
||||
it("honors a deep-link seek request when no pending seek exists", () => {
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: null,
|
||||
requestedSeek: 12.5,
|
||||
storeCurrentTime: 0,
|
||||
duration: 20,
|
||||
}),
|
||||
).toBe(12.5);
|
||||
});
|
||||
|
||||
it("THE BUG: a second overlapping reload (pending seek already consumed) restores the store playhead, not 0", () => {
|
||||
// Drop → reload #1 consumes pendingSeek and seeks/syncs to 7.2. A staggered
|
||||
// second reload (refreshPreviewDocumentVersion 80/300ms bumps) then finds the
|
||||
// slot empty — the old code reset the playhead to 0 here.
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: null,
|
||||
requestedSeek: null,
|
||||
storeCurrentTime: 7.2,
|
||||
duration: 20,
|
||||
}),
|
||||
).toBe(7.2);
|
||||
});
|
||||
|
||||
it("fresh project load starts at 0 (store resets currentTime on project switch)", () => {
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: null,
|
||||
requestedSeek: null,
|
||||
storeCurrentTime: 0,
|
||||
duration: 20,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps to duration when content shrank past the playhead (the one sanctioned move)", () => {
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: 18,
|
||||
requestedSeek: null,
|
||||
storeCurrentTime: 18,
|
||||
duration: 9,
|
||||
}),
|
||||
).toBe(9);
|
||||
});
|
||||
|
||||
it("a pending seek of 0 is an explicit position, not a missing value", () => {
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: 0,
|
||||
requestedSeek: 12,
|
||||
storeCurrentTime: 5,
|
||||
duration: 20,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("guards against non-finite and negative targets", () => {
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: Number.NaN,
|
||||
requestedSeek: null,
|
||||
storeCurrentTime: 5,
|
||||
duration: 20,
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
resolveReloadSeekTime({
|
||||
pendingSeek: -3,
|
||||
requestedSeek: null,
|
||||
storeCurrentTime: 5,
|
||||
duration: 20,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,43 @@ interface UseTimelineSyncCallbacksParams {
|
||||
applyPreviewAudioState: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where should the player seek when the preview (re)loads?
|
||||
* Priority: explicit pending seek (saved by refreshPlayer right before a
|
||||
* reload) → store-level seek request (deep-link `?t=` hydration) → the store's
|
||||
* last known playhead. The last fallback makes the playhead RELOAD-INVARIANT:
|
||||
* edits persist + reload the preview, sometimes more than once (App's
|
||||
* refreshPreviewDocumentVersion staggers extra bumps at 80/300ms), and the
|
||||
* consume-once pendingSeekRef meant any reload after the first found the slot
|
||||
* empty and reset the playhead to 0 — the "dropped a file and the playhead
|
||||
* jumped to 0" bug. Falling back to the store's playhead means every reload
|
||||
* restores position; a fresh project load still starts at 0 because the store
|
||||
* resets currentTime on project switch. Invariant: an edit NEVER moves the
|
||||
* playhead (the clamp below is the one sanctioned move — content shrank past it).
|
||||
*/
|
||||
/**
|
||||
* Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
|
||||
* Safe to call when the iframe was never hidden (idempotent no-op). Every reload
|
||||
* completion + failure path funnels through here so the preview can never get
|
||||
* stuck invisible.
|
||||
*/
|
||||
export function revealIframe(iframe: HTMLIFrameElement | null): void {
|
||||
if (iframe && iframe.style.visibility === "hidden") {
|
||||
iframe.style.visibility = "";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReloadSeekTime(input: {
|
||||
pendingSeek: number | null;
|
||||
requestedSeek: number | null;
|
||||
storeCurrentTime: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
|
||||
if (!Number.isFinite(target) || target <= 0) return 0;
|
||||
return Math.min(target, input.duration);
|
||||
}
|
||||
|
||||
export function useTimelineSyncCallbacks({
|
||||
iframeRef,
|
||||
probeIntervalRef,
|
||||
@@ -218,12 +255,32 @@ export function useTimelineSyncCallbacks({
|
||||
// never reaches pendingSeekRef). Reconciling with the store here is what makes a
|
||||
// deep-linked `?t=` land instead of starting at 0.
|
||||
const storeSeek = usePlayerStore.getState().requestedSeekTime;
|
||||
const seekTo = pendingSeekRef.current ?? storeSeek;
|
||||
const startTime = resolveReloadSeekTime({
|
||||
pendingSeek: pendingSeekRef.current,
|
||||
requestedSeek: storeSeek,
|
||||
storeCurrentTime: usePlayerStore.getState().currentTime,
|
||||
duration: adapter.getDuration(),
|
||||
});
|
||||
pendingSeekRef.current = null;
|
||||
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
||||
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
||||
|
||||
// Force a REAL render at startTime, not a no-op. After a post-edit reload the
|
||||
// freshly rebuilt GSAP timeline can already report being at `startTime`
|
||||
// internally (the reload restores the same playhead), so a single
|
||||
// `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
|
||||
// doesn't re-evaluate. That's why a just-dropped clip stayed invisible until
|
||||
// the user nudged the playhead: its element's state was never applied at the
|
||||
// restore position. Seeking to a DIFFERENT guard value first (a hair off, or 0
|
||||
// when startTime is already ~0) guarantees the follow-up seek to `startTime`
|
||||
// crosses a time boundary and re-renders every clip — including the new one.
|
||||
const guardTime = startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001;
|
||||
adapter.seek(guardTime);
|
||||
adapter.seek(startTime);
|
||||
// The correct frame is now rendered — reveal the iframe that refreshPlayer hid
|
||||
// for the reload, so the user sees the restored frame directly (never the raw
|
||||
// all-clips DOM). Cleared unconditionally: any later failure path must not leave
|
||||
// the preview stuck invisible.
|
||||
revealIframe(iframeRef.current);
|
||||
// Keep non-React listeners such as the capture link and time display in sync
|
||||
// with the initial adapter seek on iframe load.
|
||||
liveTime.notify(startTime);
|
||||
@@ -332,6 +389,9 @@ export function useTimelineSyncCallbacks({
|
||||
trySettle();
|
||||
}
|
||||
window.removeEventListener("message", onMessage);
|
||||
// Never leave the preview stuck invisible if the runtime never settled
|
||||
// (initializeAdapter reveals on success; this covers the give-up case).
|
||||
revealIframe(iframeRef.current);
|
||||
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
||||
}, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
|
||||
|
||||
|
||||
@@ -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,32 @@ 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. 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