Skip to content

Commit 2e60229

Browse files
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.
1 parent 10fb968 commit 2e60229

10 files changed

Lines changed: 614 additions & 39 deletions

File tree

‎.github/workflows/preview-regression.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
run: bun run --cwd packages/producer parity:fixtures:ci
7171

7272
- name: Install ffmpeg
73-
uses: FedericoCarboni/setup-ffmpeg@36c6454b5a2348e7794ba2d82a21506605921e3d # v3
73+
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends ffmpeg
7474

7575
- name: Set up Chrome
7676
id: setup-chrome

‎packages/studio/src/App.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
1313
import { useTimelineEditing } from "./hooks/useTimelineEditing";
1414
import { useDomEditSession } from "./hooks/useDomEditSession";
1515
import { useAppHotkeys } from "./hooks/useAppHotkeys";
16+
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
1617
import { useCaptionDetection } from "./hooks/useCaptionDetection";
1718
import { useRenderClipContent } from "./hooks/useRenderClipContent";
1819
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
@@ -98,8 +99,15 @@ export function StudioApp() {
9899
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300);
99100
}, []);
100101

101-
const [timelineVisible, setTimelineVisible] = useState(true);
102-
const toggleTimelineVisibility = useCallback(() => setTimelineVisible((v) => !v), []);
102+
const [timelineVisible, setTimelineVisible] = useState(
103+
() => readStudioUiPreferences().timelineVisible ?? true,
104+
);
105+
const toggleTimelineVisibility = useCallback(() => {
106+
setTimelineVisible((v) => {
107+
writeStudioUiPreferences({ timelineVisible: !v });
108+
return !v;
109+
});
110+
}, []);
103111
const { appToast, showToast } = useToast();
104112
const panelLayout = usePanelLayout();
105113
const editHistory = usePersistentEditHistory({ projectId });

‎packages/studio/src/components/nle/NLELayout.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,11 @@ export const NLELayout = memo(function NLELayout({
247247
const currentLevel = compositionStack[compositionStack.length - 1];
248248
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
249249

250+
const onIframeRefStable = useRef(onIframeRef);
251+
onIframeRefStable.current = onIframeRef;
250252
useEffect(() => {
251-
onIframeRef?.(iframeRef.current);
252-
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
253+
onIframeRefStable.current?.(iframeRef.current);
254+
}, [compositionStack.length, refreshKey, iframeRef]);
253255

254256
// Resize divider handlers
255257
const handleDividerPointerDown = useCallback(

‎packages/studio/src/components/nle/NLEPreview.tsx‎

Lines changed: 250 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import { memo, useRef, useState, type Ref } from "react";
1+
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
22
import { Player } from "../../player";
3+
import {
4+
DEFAULT_PREVIEW_ZOOM,
5+
clampPreviewPan,
6+
clampPreviewZoomPercent,
7+
resolvePreviewWheelZoom,
8+
toDomPrecision,
9+
type PreviewZoomState,
10+
} from "./previewZoom";
11+
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
312

413
interface NLEPreviewProps {
514
projectId: string;
@@ -23,17 +32,20 @@ export function getPreviewPlayerKey({
2332
return directUrl ?? projectId;
2433
}
2534

26-
/**
27-
* Manages the composition preview with crossfade on reload.
28-
*
29-
* When refreshKey changes, a new Player is mounted alongside the old one.
30-
* The old Player stays visible (opacity 1) until the new one fires onLoad,
31-
* at which point the old is removed. This avoids the flash that a simple
32-
* key-swap remount would cause.
33-
*
34-
* Uses the render-time state adjustment pattern (React-sanctioned) to detect
35-
* refreshKey changes — no useEffect needed.
36-
*/
35+
const ZOOM_HUD_TIMEOUT_MS = 1200;
36+
const ZOOM_SETTLE_MS = 200;
37+
38+
function loadInitialZoom(): PreviewZoomState {
39+
const stored = readStudioUiPreferences().previewZoom;
40+
return stored
41+
? {
42+
zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
43+
panX: stored.panX,
44+
panY: stored.panY,
45+
}
46+
: DEFAULT_PREVIEW_ZOOM;
47+
}
48+
3749
export const NLEPreview = memo(function NLEPreview({
3850
projectId,
3951
iframeRef,
@@ -46,12 +58,78 @@ export const NLEPreview = memo(function NLEPreview({
4658
}: NLEPreviewProps) {
4759
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
4860
const prevRefreshKeyRef = useRef(refreshKey);
61+
const viewportRef = useRef<HTMLDivElement>(null);
62+
const stageRef = useRef<HTMLDivElement>(null);
4963
const [retiringKey, setRetiringKey] = useState<string | null>(null);
5064
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
5165

52-
// Detect refreshKey change during render (React-sanctioned derived state pattern).
53-
// When the key changes, the current active player becomes the retiring player
54-
// and a new active player is mounted alongside it.
66+
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
67+
const hudRef = useRef<HTMLDivElement>(null);
68+
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
69+
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70+
const zoomingRef = useRef(false);
71+
const dragRef = useRef<{
72+
pointerId: number;
73+
startX: number;
74+
startY: number;
75+
originX: number;
76+
originY: number;
77+
} | null>(null);
78+
79+
useEffect(() => {
80+
return () => {
81+
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
82+
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
83+
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
84+
};
85+
}, []);
86+
87+
const writeTransform = useCallback((state: PreviewZoomState) => {
88+
const stage = stageRef.current;
89+
if (!stage) return;
90+
const s = toDomPrecision(state.zoomPercent / 100);
91+
const px = toDomPrecision(state.panX);
92+
const py = toDomPrecision(state.panY);
93+
stage.style.zoom = String(s);
94+
stage.style.transform = `translate(${px}px, ${py}px)`;
95+
}, []);
96+
97+
const applyZoom = useCallback(
98+
(next: PreviewZoomState) => {
99+
const clamped: PreviewZoomState = {
100+
zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
101+
panX: Number.isFinite(next.panX) ? next.panX : 0,
102+
panY: Number.isFinite(next.panY) ? next.panY : 0,
103+
};
104+
zoomRef.current = clamped;
105+
106+
if (!zoomingRef.current) {
107+
zoomingRef.current = true;
108+
const hud = hudRef.current;
109+
if (hud) hud.style.opacity = "1";
110+
}
111+
112+
writeTransform(clamped);
113+
114+
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
115+
settleTimerRef.current = setTimeout(() => {
116+
zoomingRef.current = false;
117+
const final = zoomRef.current;
118+
writeStudioUiPreferences({ previewZoom: final });
119+
const hud = hudRef.current;
120+
if (hud) {
121+
const zoomed = Math.abs(final.zoomPercent - 100) > 0.5;
122+
hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit";
123+
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
124+
hudTimerRef.current = setTimeout(() => {
125+
if (hudRef.current) hudRef.current.style.opacity = "0";
126+
}, ZOOM_HUD_TIMEOUT_MS);
127+
}
128+
}, ZOOM_SETTLE_MS);
129+
},
130+
[writeTransform],
131+
);
132+
55133
if (refreshKey !== prevRefreshKeyRef.current) {
56134
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
57135
prevRefreshKeyRef.current = refreshKey;
@@ -60,42 +138,184 @@ export const NLEPreview = memo(function NLEPreview({
60138

61139
const activeKey = `${baseKey}:${refreshKey ?? 0}`;
62140

141+
const applyInitialZoom = useCallback(() => {
142+
const z = zoomRef.current;
143+
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
144+
writeTransform(z);
145+
}
146+
}, [writeTransform]);
147+
63148
const handleNewPlayerLoad = () => {
64149
onIframeLoad();
150+
applyInitialZoom();
65151
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
66152
retiringTimerRef.current = setTimeout(() => {
67153
setRetiringKey(null);
68154
retiringTimerRef.current = null;
69155
}, 160);
70156
};
71157

158+
useEffect(() => {
159+
const viewport = viewportRef.current;
160+
if (!viewport) return;
161+
162+
let lastZoomTime = 0;
163+
164+
const handleWheel = (event: WheelEvent) => {
165+
const rect = viewport.getBoundingClientRect();
166+
if (
167+
event.clientX < rect.left ||
168+
event.clientX > rect.right ||
169+
event.clientY < rect.top ||
170+
event.clientY > rect.bottom
171+
) {
172+
return;
173+
}
174+
175+
const isZoomGesture = event.ctrlKey || event.metaKey;
176+
177+
if (isZoomGesture) {
178+
lastZoomTime = Date.now();
179+
event.preventDefault();
180+
event.stopPropagation();
181+
182+
const next = resolvePreviewWheelZoom({
183+
state: zoomRef.current,
184+
deltaY: event.deltaY,
185+
viewportWidth: rect.width,
186+
viewportHeight: rect.height,
187+
});
188+
applyZoom(next);
189+
return;
190+
}
191+
192+
if (Date.now() - lastZoomTime < 400) {
193+
event.preventDefault();
194+
event.stopPropagation();
195+
}
196+
};
197+
198+
document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
199+
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
200+
}, [applyZoom]);
201+
202+
useEffect(() => {
203+
const viewport = viewportRef.current;
204+
if (!viewport) return;
205+
206+
const handleDblClick = (event: MouseEvent) => {
207+
if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return;
208+
const rect = viewport.getBoundingClientRect();
209+
if (
210+
event.clientX < rect.left ||
211+
event.clientX > rect.right ||
212+
event.clientY < rect.top ||
213+
event.clientY > rect.bottom
214+
) {
215+
return;
216+
}
217+
applyZoom(DEFAULT_PREVIEW_ZOOM);
218+
};
219+
220+
document.addEventListener("dblclick", handleDblClick, { capture: true });
221+
return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
222+
}, [applyZoom]);
223+
224+
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
225+
if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return;
226+
event.currentTarget.setPointerCapture(event.pointerId);
227+
dragRef.current = {
228+
pointerId: event.pointerId,
229+
startX: event.clientX,
230+
startY: event.clientY,
231+
originX: zoomRef.current.panX,
232+
originY: zoomRef.current.panY,
233+
};
234+
}, []);
235+
236+
const handlePointerMove = useCallback(
237+
(event: React.PointerEvent<HTMLDivElement>) => {
238+
const drag = dragRef.current;
239+
const viewport = viewportRef.current;
240+
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
241+
event.preventDefault();
242+
const rect = viewport.getBoundingClientRect();
243+
const pan = clampPreviewPan({
244+
panX: drag.originX + event.clientX - drag.startX,
245+
panY: drag.originY + event.clientY - drag.startY,
246+
zoomPercent: zoomRef.current.zoomPercent,
247+
viewportWidth: rect.width,
248+
viewportHeight: rect.height,
249+
});
250+
applyZoom({ ...zoomRef.current, ...pan });
251+
},
252+
[applyZoom],
253+
);
254+
255+
const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
256+
if (dragRef.current?.pointerId === event.pointerId) {
257+
dragRef.current = null;
258+
}
259+
}, []);
260+
261+
const initial = zoomRef.current;
262+
72263
return (
73264
<div className="flex flex-col h-full min-h-0">
74265
<div
266+
ref={viewportRef}
75267
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
76268
tabIndex={0}
77269
aria-label="Composition preview"
270+
onPointerDown={handlePointerDown}
271+
onPointerMove={handlePointerMove}
272+
onPointerUp={finishDrag}
273+
onPointerCancel={finishDrag}
78274
>
79-
{retiringKey && (
275+
<div
276+
ref={stageRef}
277+
className="absolute inset-2"
278+
style={{
279+
zoom: toDomPrecision(initial.zoomPercent / 100),
280+
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
281+
transformOrigin: "0 0",
282+
}}
283+
data-testid="preview-zoom-stage"
284+
>
285+
{retiringKey && (
286+
<Player
287+
key={retiringKey}
288+
projectId={directUrl ? undefined : projectId}
289+
directUrl={directUrl}
290+
onLoad={() => {}}
291+
portrait={portrait}
292+
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
293+
/>
294+
)}
80295
<Player
81-
key={retiringKey}
296+
key={activeKey}
297+
ref={iframeRef}
82298
projectId={directUrl ? undefined : projectId}
83299
directUrl={directUrl}
84-
onLoad={() => {}}
300+
onLoad={
301+
retiringKey
302+
? handleNewPlayerLoad
303+
: () => {
304+
onIframeLoad();
305+
applyInitialZoom();
306+
}
307+
}
308+
onCompositionLoadingChange={onCompositionLoadingChange}
85309
portrait={portrait}
86-
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
310+
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
311+
suppressLoadingOverlay={suppressLoadingOverlay}
87312
/>
88-
)}
89-
<Player
90-
key={activeKey}
91-
ref={iframeRef}
92-
projectId={directUrl ? undefined : projectId}
93-
directUrl={directUrl}
94-
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
95-
onCompositionLoadingChange={onCompositionLoadingChange}
96-
portrait={portrait}
97-
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
98-
suppressLoadingOverlay={suppressLoadingOverlay}
313+
</div>
314+
<div
315+
ref={hudRef}
316+
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-lg px-4 py-2 text-sm font-mono tabular-nums text-white/90 bg-black/60 backdrop-blur-sm shadow-lg"
317+
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
318+
aria-live="polite"
99319
/>
100320
</div>
101321
</div>

0 commit comments

Comments
 (0)