Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/preview-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:
run: bun run --cwd packages/producer parity:fixtures:ci

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

- name: Set up Chrome
id: setup-chrome
Expand Down
12 changes: 10 additions & 2 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 4 additions & 2 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,11 @@ export const NLELayout = memo(function NLELayout({
const currentLevel = compositionStack[compositionStack.length - 1];
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;

const onIframeRefStable = useRef(onIframeRef);
onIframeRefStable.current = onIframeRef;
useEffect(() => {
onIframeRef?.(iframeRef.current);
}, [compositionStack.length, onIframeRef, refreshKey, iframeRef]);
onIframeRefStable.current?.(iframeRef.current);
}, [compositionStack.length, refreshKey, iframeRef]);

// Resize divider handlers
const handleDividerPointerDown = useCallback(
Expand Down
280 changes: 250 additions & 30 deletions packages/studio/src/components/nle/NLEPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { memo, useRef, useState, type Ref } from "react";
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
import { Player } from "../../player";
import {
DEFAULT_PREVIEW_ZOOM,
clampPreviewPan,
clampPreviewZoomPercent,
resolvePreviewWheelZoom,
toDomPrecision,
type PreviewZoomState,
} from "./previewZoom";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";

interface NLEPreviewProps {
projectId: string;
Expand All @@ -23,17 +32,20 @@ export function getPreviewPlayerKey({
return directUrl ?? projectId;
}

/**
* Manages the composition preview with crossfade on reload.
*
* When refreshKey changes, a new Player is mounted alongside the old one.
* The old Player stays visible (opacity 1) until the new one fires onLoad,
* at which point the old is removed. This avoids the flash that a simple
* key-swap remount would cause.
*
* Uses the render-time state adjustment pattern (React-sanctioned) to detect
* refreshKey changes — no useEffect needed.
*/
const ZOOM_HUD_TIMEOUT_MS = 1200;
const ZOOM_SETTLE_MS = 200;

function loadInitialZoom(): PreviewZoomState {
const stored = readStudioUiPreferences().previewZoom;
return stored
? {
zoomPercent: clampPreviewZoomPercent(stored.zoomPercent),
panX: stored.panX,
panY: stored.panY,
}
: DEFAULT_PREVIEW_ZOOM;
}

export const NLEPreview = memo(function NLEPreview({
projectId,
iframeRef,
Expand All @@ -46,12 +58,78 @@ export const NLEPreview = memo(function NLEPreview({
}: NLEPreviewProps) {
const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
const prevRefreshKeyRef = useRef(refreshKey);
const viewportRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<HTMLDivElement>(null);
const [retiringKey, setRetiringKey] = useState<string | null>(null);
const retiringTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Detect refreshKey change during render (React-sanctioned derived state pattern).
// When the key changes, the current active player becomes the retiring player
// and a new active player is mounted alongside it.
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
const hudRef = useRef<HTMLDivElement>(null);
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const zoomingRef = useRef(false);
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);

useEffect(() => {
return () => {
if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
};
}, []);

const writeTransform = useCallback((state: PreviewZoomState) => {
const stage = stageRef.current;
if (!stage) return;
const s = toDomPrecision(state.zoomPercent / 100);
const px = toDomPrecision(state.panX);
const py = toDomPrecision(state.panY);
stage.style.zoom = String(s);
stage.style.transform = `translate(${px}px, ${py}px)`;
}, []);

const applyZoom = useCallback(
(next: PreviewZoomState) => {
const clamped: PreviewZoomState = {
zoomPercent: clampPreviewZoomPercent(next.zoomPercent),
panX: Number.isFinite(next.panX) ? next.panX : 0,
panY: Number.isFinite(next.panY) ? next.panY : 0,
};
zoomRef.current = clamped;

if (!zoomingRef.current) {
zoomingRef.current = true;
const hud = hudRef.current;
if (hud) hud.style.opacity = "1";
}

writeTransform(clamped);

if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
settleTimerRef.current = setTimeout(() => {
zoomingRef.current = false;
const final = zoomRef.current;
writeStudioUiPreferences({ previewZoom: final });
const hud = hudRef.current;
if (hud) {
const zoomed = Math.abs(final.zoomPercent - 100) > 0.5;
hud.textContent = zoomed ? `${Math.round(final.zoomPercent)}%` : "Fit";
if (hudTimerRef.current) clearTimeout(hudTimerRef.current);
hudTimerRef.current = setTimeout(() => {
if (hudRef.current) hudRef.current.style.opacity = "0";
}, ZOOM_HUD_TIMEOUT_MS);
}
}, ZOOM_SETTLE_MS);
},
[writeTransform],
);

if (refreshKey !== prevRefreshKeyRef.current) {
const oldKey = `${baseKey}:${prevRefreshKeyRef.current ?? 0}`;
prevRefreshKeyRef.current = refreshKey;
Expand All @@ -60,42 +138,184 @@ export const NLEPreview = memo(function NLEPreview({

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

const applyInitialZoom = useCallback(() => {
const z = zoomRef.current;
if (Math.abs(z.zoomPercent - 100) > 0.5 || Math.abs(z.panX) > 0.1 || Math.abs(z.panY) > 0.1) {
writeTransform(z);
}
}, [writeTransform]);

const handleNewPlayerLoad = () => {
onIframeLoad();
applyInitialZoom();
if (retiringTimerRef.current) clearTimeout(retiringTimerRef.current);
retiringTimerRef.current = setTimeout(() => {
setRetiringKey(null);
retiringTimerRef.current = null;
}, 160);
};

useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;

let lastZoomTime = 0;

const handleWheel = (event: WheelEvent) => {
const rect = viewport.getBoundingClientRect();
if (
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom
) {
return;
}

const isZoomGesture = event.ctrlKey || event.metaKey;

if (isZoomGesture) {
lastZoomTime = Date.now();
event.preventDefault();
event.stopPropagation();

const next = resolvePreviewWheelZoom({
state: zoomRef.current,
deltaY: event.deltaY,
viewportWidth: rect.width,
viewportHeight: rect.height,
});
applyZoom(next);
return;
}

if (Date.now() - lastZoomTime < 400) {
event.preventDefault();
event.stopPropagation();
}
};

document.addEventListener("wheel", handleWheel, { passive: false, capture: true });
return () => document.removeEventListener("wheel", handleWheel, { capture: true });
}, [applyZoom]);

useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;

const handleDblClick = (event: MouseEvent) => {
if (Math.abs(zoomRef.current.zoomPercent - 100) < 0.5) return;
const rect = viewport.getBoundingClientRect();
if (
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom
) {
return;
}
applyZoom(DEFAULT_PREVIEW_ZOOM);
};

document.addEventListener("dblclick", handleDblClick, { capture: true });
return () => document.removeEventListener("dblclick", handleDblClick, { capture: true });
}, [applyZoom]);

const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (zoomRef.current.zoomPercent <= 100 || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: zoomRef.current.panX,
originY: zoomRef.current.panY,
};
}, []);

const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
const viewport = viewportRef.current;
if (!drag || !viewport || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const rect = viewport.getBoundingClientRect();
const pan = clampPreviewPan({
panX: drag.originX + event.clientX - drag.startX,
panY: drag.originY + event.clientY - drag.startY,
zoomPercent: zoomRef.current.zoomPercent,
viewportWidth: rect.width,
viewportHeight: rect.height,
});
applyZoom({ ...zoomRef.current, ...pan });
},
[applyZoom],
);

const finishDrag = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null;
}
}, []);

const initial = zoomRef.current;

return (
<div className="flex flex-col h-full min-h-0">
<div
ref={viewportRef}
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"
tabIndex={0}
aria-label="Composition preview"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
>
{retiringKey && (
<div
ref={stageRef}
className="absolute inset-2"
style={{
zoom: toDomPrecision(initial.zoomPercent / 100),
transform: `translate(${toDomPrecision(initial.panX)}px, ${toDomPrecision(initial.panY)}px)`,
transformOrigin: "0 0",
}}
data-testid="preview-zoom-stage"
>
{retiringKey && (
<Player
key={retiringKey}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {}}
portrait={portrait}
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
/>
)}
<Player
key={retiringKey}
key={activeKey}
ref={iframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={() => {}}
onLoad={
retiringKey
? handleNewPlayerLoad
: () => {
onIframeLoad();
applyInitialZoom();
}
}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }}
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
suppressLoadingOverlay={suppressLoadingOverlay}
/>
)}
<Player
key={activeKey}
ref={iframeRef}
projectId={directUrl ? undefined : projectId}
directUrl={directUrl}
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
onCompositionLoadingChange={onCompositionLoadingChange}
portrait={portrait}
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
suppressLoadingOverlay={suppressLoadingOverlay}
</div>
<div
ref={hudRef}
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"
style={{ opacity: 0, transition: "opacity 300ms ease-out" }}
aria-live="polite"
/>
</div>
</div>
Expand Down
Loading
Loading