mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
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:
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
|
||||
|
||||
function createStorage(): Storage {
|
||||
const entries = new Map<string, string>();
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user