diff --git a/.github/workflows/preview-regression.yml b/.github/workflows/preview-regression.yml index e4a56c52e..9049992a9 100644 --- a/.github/workflows/preview-regression.yml +++ b/.github/workflows/preview-regression.yml @@ -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 diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index e9c436b1c..4affd71c1 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -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 }); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index cfc55c98b..e76b23dd2 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -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( diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index d63bce285..46a2640fa 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -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; @@ -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, @@ -46,12 +58,78 @@ export const NLEPreview = memo(function NLEPreview({ }: NLEPreviewProps) { const baseKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey }); const prevRefreshKeyRef = useRef(refreshKey); + const viewportRef = useRef(null); + const stageRef = useRef(null); const [retiringKey, setRetiringKey] = useState(null); const retiringTimerRef = useRef | 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(loadInitialZoom()); + const hudRef = useRef(null); + const hudTimerRef = useRef | null>(null); + const settleTimerRef = useRef | 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; @@ -60,8 +138,16 @@ 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); @@ -69,33 +155,167 @@ export const NLEPreview = memo(function NLEPreview({ }, 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) => { + 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) => { + 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) => { + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null; + } + }, []); + + const initial = zoomRef.current; + return (
- {retiringKey && ( +
+ {retiringKey && ( + {}} + portrait={portrait} + style={{ position: "absolute", inset: 0, zIndex: 0, opacity: 1 }} + /> + )} {}} + 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} /> - )} - +
diff --git a/packages/studio/src/components/nle/previewZoom.test.ts b/packages/studio/src/components/nle/previewZoom.test.ts new file mode 100644 index 000000000..bd1d83de1 --- /dev/null +++ b/packages/studio/src/components/nle/previewZoom.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_PREVIEW_ZOOM, + MAX_PREVIEW_ZOOM_PERCENT, + MIN_PREVIEW_ZOOM_PERCENT, + clampPreviewPan, + clampPreviewZoomPercent, + getNextPreviewZoomPercent, + getPreviewWheelZoomPercent, + resolvePreviewWheelZoom, + toDomPrecision, +} from "./previewZoom"; + +describe("toDomPrecision", () => { + it("rounds to 4 decimal places", () => { + expect(toDomPrecision(1.23456789)).toBe(1.2346); + }); + + it("preserves zero", () => { + expect(toDomPrecision(0)).toBe(0); + }); + + it("handles negative values", () => { + expect(toDomPrecision(-3.14159)).toBe(-3.1416); + }); +}); + +describe("clampPreviewZoomPercent", () => { + it("falls back to fit zoom for invalid input", () => { + expect(clampPreviewZoomPercent(Number.NaN)).toBe(100); + }); + + it("clamps to supported preview zoom bounds", () => { + expect(clampPreviewZoomPercent(1)).toBe(MIN_PREVIEW_ZOOM_PERCENT); + expect(clampPreviewZoomPercent(5000)).toBe(MAX_PREVIEW_ZOOM_PERCENT); + }); +}); + +describe("getPreviewWheelZoomPercent", () => { + it("zooms in on negative deltaY (scroll up / pinch out)", () => { + expect(getPreviewWheelZoomPercent(-5, 100)).toBeGreaterThan(100); + }); + + it("zooms out on positive deltaY (scroll down / pinch in)", () => { + expect(getPreviewWheelZoomPercent(5, 200)).toBeLessThan(200); + }); + + it("clamps large deltas to prevent overshoot", () => { + const small = getPreviewWheelZoomPercent(-5, 100); + const large = getPreviewWheelZoomPercent(-50, 100); + expect(large).toBeLessThan(small * 2); + }); + + it("preserves the current zoom for invalid input", () => { + expect(getPreviewWheelZoomPercent(Number.NaN, 180)).toBe(180); + }); +}); + +describe("getNextPreviewZoomPercent", () => { + it("steps preview zoom in and out", () => { + expect(getNextPreviewZoomPercent("in", 100)).toBe(125); + expect(getNextPreviewZoomPercent("out", 125)).toBe(100); + }); +}); + +describe("clampPreviewPan", () => { + it("centers the preview when fit or zoomed out", () => { + expect( + clampPreviewPan({ + panX: 120, + panY: -90, + zoomPercent: 100, + viewportWidth: 800, + viewportHeight: 600, + }), + ).toEqual({ panX: 0, panY: 0 }); + }); + + it("keeps pan within the zoomed preview bounds", () => { + expect( + clampPreviewPan({ + panX: 900, + panY: -900, + zoomPercent: 200, + viewportWidth: 800, + viewportHeight: 600, + }), + ).toEqual({ panX: 400, panY: -300 }); + }); +}); + +describe("resolvePreviewWheelZoom", () => { + it("zooms in from center without shifting pan", () => { + const next = resolvePreviewWheelZoom({ + state: DEFAULT_PREVIEW_ZOOM, + deltaY: -5, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(next.zoomPercent).toBeGreaterThan(100); + expect(next.panX).toBe(0); + expect(next.panY).toBe(0); + }); + + it("clamps pan when zooming out past minimum", () => { + const next = resolvePreviewWheelZoom({ + state: { zoomPercent: 26, panX: 20, panY: 20 }, + deltaY: 500, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(next.zoomPercent).toBeCloseTo(MIN_PREVIEW_ZOOM_PERCENT, 0); + expect(next.panX).toBe(0); + expect(next.panY).toBe(0); + }); +}); diff --git a/packages/studio/src/components/nle/previewZoom.ts b/packages/studio/src/components/nle/previewZoom.ts new file mode 100644 index 000000000..d39296a8b --- /dev/null +++ b/packages/studio/src/components/nle/previewZoom.ts @@ -0,0 +1,84 @@ +export interface PreviewZoomState { + zoomPercent: number; + panX: number; + panY: number; +} + +export const MIN_PREVIEW_ZOOM_PERCENT = 25; +export const MAX_PREVIEW_ZOOM_PERCENT = 400; +export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = { + zoomPercent: 100, + panX: 0, + panY: 0, +}; + +const ZOOM_SENSITIVITY = 0.007; +const MAX_DELTA = 10; + +export function toDomPrecision(value: number): number { + return Math.round(value * 10000) / 10000; +} + +export function clampPreviewZoomPercent(percent: number): number { + if (!Number.isFinite(percent)) return 100; + return Math.min(MAX_PREVIEW_ZOOM_PERCENT, Math.max(MIN_PREVIEW_ZOOM_PERCENT, percent)); +} + +export function getPreviewWheelZoomPercent(deltaY: number, currentZoomPercent: number): number { + if (!Number.isFinite(deltaY)) return clampPreviewZoomPercent(currentZoomPercent); + const clamped = Math.abs(deltaY) > MAX_DELTA ? MAX_DELTA * Math.sign(deltaY) : deltaY; + const step = -clamped * ZOOM_SENSITIVITY; + const current = clampPreviewZoomPercent(currentZoomPercent); + return clampPreviewZoomPercent(current * Math.exp(step)); +} + +export function getNextPreviewZoomPercent( + direction: "in" | "out", + currentZoomPercent: number, +): number { + const current = clampPreviewZoomPercent(currentZoomPercent); + const multiplier = direction === "in" ? 1.25 : 0.8; + return clampPreviewZoomPercent(current * multiplier); +} + +export function clampPreviewPan(input: { + panX: number; + panY: number; + zoomPercent: number; + viewportWidth: number; + viewportHeight: number; +}): Pick { + const scale = clampPreviewZoomPercent(input.zoomPercent) / 100; + if (scale <= 1) return { panX: 0, panY: 0 }; + + const maxPanX = ((scale - 1) * input.viewportWidth) / 2; + const maxPanY = ((scale - 1) * input.viewportHeight) / 2; + return { + panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)), + panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)), + }; +} + +export function resolvePreviewWheelZoom(input: { + state: PreviewZoomState; + deltaY: number; + viewportWidth: number; + viewportHeight: number; +}): PreviewZoomState { + const nextZoomPercent = getPreviewWheelZoomPercent( + input.deltaY, + clampPreviewZoomPercent(input.state.zoomPercent), + ); + const pan = clampPreviewPan({ + panX: input.state.panX, + panY: input.state.panY, + zoomPercent: nextZoomPercent, + viewportWidth: input.viewportWidth, + viewportHeight: input.viewportHeight, + }); + + return { + zoomPercent: nextZoomPercent, + ...pan, + }; +} diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index 2370ae828..61290f5a3 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -1,10 +1,13 @@ import { useState, useCallback, useRef } from "react"; import type { RightPanelTab } from "../utils/studioHelpers"; +import { readStudioUiPreferences, writeStudioUiPreferences } from "../utils/studioUiPreferences"; export function usePanelLayout() { const [leftWidth, setLeftWidth] = useState(240); const [rightWidth, setRightWidth] = useState(400); - const [leftCollapsed, setLeftCollapsed] = useState(false); + const [leftCollapsed, setLeftCollapsed] = useState( + () => readStudioUiPreferences().leftCollapsed ?? false, + ); const [rightCollapsed, setRightCollapsed] = useState(true); const [rightPanelTab, setRightPanelTab] = useState("renders"); const panelDragRef = useRef<{ @@ -14,7 +17,10 @@ export function usePanelLayout() { } | null>(null); const toggleLeftSidebar = useCallback(() => { - setLeftCollapsed((collapsed) => !collapsed); + setLeftCollapsed((collapsed) => { + writeStudioUiPreferences({ leftCollapsed: !collapsed }); + return !collapsed; + }); }, []); const handlePanelResizeStart = useCallback( diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 4e978c37c..2fce59381 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; export interface TimelineElement { id: string; @@ -88,7 +89,7 @@ export const usePlayerStore = create((set) => ({ timelineReady: false, elements: [], selectedElementId: null, - playbackRate: 1, + playbackRate: readStudioUiPreferences().playbackRate ?? 1, loopEnabled: false, zoomMode: "fit", manualZoomPercent: 100, @@ -98,7 +99,10 @@ export const usePlayerStore = create((set) => ({ clearSeekRequest: () => set({ requestedSeekTime: null }), setIsPlaying: (playing) => set({ isPlaying: playing }), - setPlaybackRate: (rate) => set({ playbackRate: rate }), + setPlaybackRate: (rate) => { + writeStudioUiPreferences({ playbackRate: rate }); + set({ playbackRate: rate }); + }, setLoopEnabled: (enabled) => set({ loopEnabled: enabled }), setZoomMode: (mode) => set({ zoomMode: mode }), setManualZoomPercent: (percent) => diff --git a/packages/studio/src/utils/studioUiPreferences.test.ts b/packages/studio/src/utils/studioUiPreferences.test.ts new file mode 100644 index 000000000..baa950bca --- /dev/null +++ b/packages/studio/src/utils/studioUiPreferences.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences"; + +function createStorage(): Storage { + const entries = new Map(); + 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, + }); + }); +}); diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts new file mode 100644 index 000000000..820b0839b --- /dev/null +++ b/packages/studio/src/utils/studioUiPreferences.ts @@ -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 { + 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 */ + } +}