fix(studio): add preview zoom controls (#761)

* fix(studio): add smooth preview zoom with pinch/Ctrl+scroll

- Scale iframe content from inside (contentDocument.documentElement) instead
  of scaling the parent div, avoiding compositor re-rasterization on every
  zoom frame — critical for smooth zoom on high-refresh displays (240Hz)
- Document-level capture-phase wheel handler bypasses the DomEditOverlay
- Center-based zoom (no pan drift from pointer-anchored formulas)
- Transient HUD shows zoom % briefly, no persistent UI controls
- Double-click preview area to reset zoom to fit
- Drag-to-pan when zoomed past 100%
- Momentum scroll suppression after pinch gesture (400ms cooldown)
- Delta clamping (MAX_DELTA=10) prevents overshooting on fast gestures
- toDomPrecision rounds transform values to 4 decimals (matches tldraw)
- Zoom state persisted to localStorage with 200ms debounce
- Exposes --preview-zoom CSS custom property for overlay coordinate mapping
- Fix infinite render loop in NLELayout (onIframeRef → refreshPreviewDocumentVersion)

* fix(studio): use CSS zoom instead of transform scale for preview zoom

CSS transform: scale() on a div containing an iframe causes compositor
cross-layer sync issues that produce visible frame tearing on high-refresh
displays (240Hz ProMotion). CSS zoom property changes the actual rendered
size without compositor layer synchronization, eliminating the jumping.

- Replace transform: scale(Z) with zoom: Z on the stage div
- Keep transform: translate() for panning (compositor-friendly, no iframe)
- Overlays work correctly since getBoundingClientRect() includes zoom
- Remove will-change, transition hacks, pointer-events toggles

* fix(ci): use apt-get for ffmpeg in preview-regression workflow

The FedericoCarboni/setup-ffmpeg action downloads from an external URL
that has been persistently unreachable, causing CI failures. Switch to
apt-get install which uses Ubuntu's package repos (same as ci.yml and
player-perf.yml).

* fix(studio): clear zoom timers on NLEPreview unmount

settleTimerRef, hudTimerRef, and retiringTimerRef could fire after
component unmount. Add cleanup effect to prevent stale callbacks.

* feat(studio): persist sidebar, timeline, and playback speed across reloads

Wire up studioUiPreferences for the three remaining UI states requested
in #752: left sidebar collapsed, timeline visibility, and playback rate.
All three now survive page reloads using the same localStorage key as
preview zoom.
This commit is contained in:
Miguel Ángel
2026-05-13 09:29:01 +02:00
committed by GitHub
parent 10fb968583
commit 2e6022972b
10 changed files with 614 additions and 39 deletions
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
function createStorage(): Storage {
const entries = new Map<string, string>();
return {
get length() {
return entries.size;
},
clear: () => entries.clear(),
getItem: (key) => entries.get(key) ?? null,
key: (index) => Array.from(entries.keys())[index] ?? null,
removeItem: (key) => entries.delete(key),
setItem: (key, value) => entries.set(key, value),
};
}
describe("studio UI preferences", () => {
it("merges preference patches into one localStorage entry", () => {
const storage = createStorage();
writeStudioUiPreferences({ timelineVisible: false }, storage);
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
expect(readStudioUiPreferences(storage)).toEqual({
timelineVisible: false,
playbackRate: 1.5,
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
});
});
it("ignores malformed stored values", () => {
const storage = createStorage();
storage.setItem(
"hf-studio-ui-preferences",
JSON.stringify({
leftCollapsed: "yes",
timelineVisible: true,
playbackRate: Number.NaN,
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
}),
);
expect(readStudioUiPreferences(storage)).toEqual({
timelineVisible: true,
});
});
});
@@ -0,0 +1,84 @@
export interface StoredPreviewZoomState {
zoomPercent: number;
panX: number;
panY: number;
}
export interface StudioUiPreferences {
leftCollapsed?: boolean;
timelineVisible?: boolean;
playbackRate?: number;
previewZoom?: StoredPreviewZoomState;
}
const STUDIO_UI_PREFERENCES_KEY = "hf-studio-ui-preferences";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getBrowserStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function readStorage(storage: Storage | null): StudioUiPreferences {
if (!storage) return {};
try {
const raw = storage.getItem(STUDIO_UI_PREFERENCES_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const preferences: StudioUiPreferences = {};
if (typeof parsed.leftCollapsed === "boolean") {
preferences.leftCollapsed = parsed.leftCollapsed;
}
if (typeof parsed.timelineVisible === "boolean") {
preferences.timelineVisible = parsed.timelineVisible;
}
if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
preferences.playbackRate = parsed.playbackRate;
}
if (isRecord(parsed.previewZoom)) {
const { zoomPercent, panX, panY } = parsed.previewZoom;
if (
typeof zoomPercent === "number" &&
Number.isFinite(zoomPercent) &&
typeof panX === "number" &&
Number.isFinite(panX) &&
typeof panY === "number" &&
Number.isFinite(panY)
) {
preferences.previewZoom = { zoomPercent, panX, panY };
}
}
return preferences;
} catch {
return {};
}
}
export function readStudioUiPreferences(storage: Storage | null = getBrowserStorage()) {
return readStorage(storage);
}
export function writeStudioUiPreferences(
patch: StudioUiPreferences,
storage: Storage | null = getBrowserStorage(),
) {
if (!storage) return;
try {
const next = {
...readStorage(storage),
...patch,
};
storage.setItem(STUDIO_UI_PREFERENCES_KEY, JSON.stringify(next));
} catch {
/* localStorage may be unavailable or full */
}
}