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,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 */
}
}