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
+10 -2
View File
@@ -13,6 +13,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
import { useTimelineEditing } from "./hooks/useTimelineEditing";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
import { useCaptionDetection } from "./hooks/useCaptionDetection";
import { useRenderClipContent } from "./hooks/useRenderClipContent";
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
@@ -98,8 +99,15 @@ export function StudioApp() {
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300);
}, []);
const [timelineVisible, setTimelineVisible] = useState(true);
const toggleTimelineVisibility = useCallback(() => setTimelineVisible((v) => !v), []);
const [timelineVisible, setTimelineVisible] = useState(
() => readStudioUiPreferences().timelineVisible ?? true,
);
const toggleTimelineVisibility = useCallback(() => {
setTimelineVisible((v) => {
writeStudioUiPreferences({ timelineVisible: !v });
return !v;
});
}, []);
const { appToast, showToast } = useToast();
const panelLayout = usePanelLayout();
const editHistory = usePersistentEditHistory({ projectId });