fix(studio): release retained preview resources (#2924)

This commit is contained in:
Miguel Ángel
2026-07-31 14:31:43 +02:00
committed by GitHub
parent a3c8f897f2
commit fbfffb1aa7
6 changed files with 308 additions and 92 deletions
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mountReactHarness } from "./domSelectionTestHarness";
import { useGestureRecording } from "./useGestureRecording";
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
afterEach(() => {
vi.unstubAllGlobals();
document.body.replaceChildren();
});
describe("useGestureRecording", () => {
it("releases the preview runtime when unmounted during a recording", () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 17),
);
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
const iframe = document.createElement("iframe");
document.body.append(iframe);
const previewDocument = iframe.contentDocument!;
const element = previewDocument.createElement("div");
element.id = "card";
element.style.visibility = "hidden";
element.style.setProperty("translate", "10px 20px");
element.style.setProperty("--hf-studio-offset-x", "12px");
element.style.setProperty("--hf-studio-offset-y", "-8px");
previewDocument.body.append(element);
const set = vi.fn();
const seek = vi.fn();
Reflect.set(iframe.contentWindow!, "gsap", {
getProperty: vi.fn(() => 0),
set,
});
Reflect.set(iframe.contentWindow!, "__timelines", {
main: { duration: () => 5, seek },
});
Reflect.set(iframe.contentWindow!, "__player", { getTime: () => 1 });
let recording: ReturnType<typeof useGestureRecording> | null = null;
function Harness() {
recording = useGestureRecording();
return null;
}
const root = mountReactHarness(<Harness />);
act(() => recording?.startRecording(element, iframe, 4));
expect(element.style.getPropertyValue("--hf-studio-offset-x")).toBe("0px");
expect(element.style.getPropertyValue("--hf-studio-offset-y")).toBe("0px");
act(() => root.unmount());
expect(cancelAnimationFrame).toHaveBeenCalledWith(17);
expect(set).toHaveBeenCalledWith("#card", {
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
});
expect(element.style.visibility).toBe("hidden");
expect(element.style.getPropertyValue("translate")).toBe("10px 20px");
expect(element.style.getPropertyValue("--hf-studio-offset-x")).toBe("12px");
expect(element.style.getPropertyValue("--hf-studio-offset-y")).toBe("-8px");
});
});
@@ -28,8 +28,8 @@ interface BasePosition {
}
interface GsapRuntime {
seek: (t: number) => void;
set: (target: string, vars: Record<string, number | string>) => void;
timeline: { seek: (t: number) => void };
gsap: { set: (target: string, vars: Record<string, number | string>) => void };
selector: string;
element: HTMLElement;
startTime: number;
@@ -100,8 +100,8 @@ function connectGsapRuntime(
if (win?.gsap?.set && tl?.seek && selector) {
const tlDuration = tl.duration();
return {
seek: tl.seek.bind(tl),
set: win.gsap.set.bind(win.gsap),
timeline: tl,
gsap: win.gsap,
selector,
element,
startTime: win.__player?.getTime() ?? 0,
@@ -123,9 +123,9 @@ function applyRuntimePreview(
properties: Record<string, number>,
): void {
const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
runtime.seek(seekTime);
runtime.timeline.seek(seekTime);
runtime.element.style.setProperty("translate", "none");
runtime.set(runtime.selector, { ...properties });
runtime.gsap.set(runtime.selector, { ...properties });
runtime.element.style.visibility = "visible";
liveTime.notify(seekTime);
usePlayerStore.getState().setCurrentTime(seekTime);
@@ -237,6 +237,26 @@ function createRecordingRefs(): RecordingRefs {
};
}
function releaseRuntimePreview(r: RecordingRefs): void {
const runtime = r.runtime;
if (!runtime) return;
const { element, savedVisibility, savedTranslate } = runtime;
element.style.visibility = savedVisibility;
element.style.setProperty("translate", savedTranslate || "");
try {
runtime.gsap.set(runtime.selector, {
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
});
} catch {
/* runtime gone */
}
if (r.cssVarOffset.x || r.cssVarOffset.y) {
element.style.setProperty("--hf-studio-offset-x", `${r.cssVarOffset.x}px`);
element.style.setProperty("--hf-studio-offset-y", `${r.cssVarOffset.y}px`);
}
r.runtime = null;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
@@ -259,9 +279,10 @@ export function useGestureRecording() {
useEffect(() => {
const r = refs.current;
return () => {
isRecordingRef.current = false;
releaseRuntimePreview(r);
r.cleanup?.();
r.cleanup = null;
isRecordingRef.current = false;
};
}, []);
@@ -290,15 +311,15 @@ export function useGestureRecording() {
// this scale to convert them to the iframe's composition pixels.
r.scale = computeIframeScale(iframeEl);
// Now clear the optimistic path offset (already folded into baseX/baseY).
if (base.cssOffX || base.cssOffY) {
element.style.setProperty("--hf-studio-offset-x", "0px");
element.style.setProperty("--hf-studio-offset-y", "0px");
}
// --- Phase 3: Connect to the iframe GSAP runtime ---
const selector = element.id ? `#${element.id}` : null;
r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
// Clear the optimistic path offset only while a live runtime owns the
// preview. releaseRuntimePreview restores it on every exit path.
if (r.runtime && (base.cssOffX || base.cssOffY)) {
element.style.setProperty("--hf-studio-offset-x", "0px");
element.style.setProperty("--hf-studio-offset-y", "0px");
}
// --- Phase 5: Attach event listeners ---
const handlePointerMove = (e: PointerEvent) => {
@@ -378,7 +399,7 @@ export function useGestureRecording() {
} catch {
// Preview failed — disable it for the rest of the gesture (recording
// continues). `r.runtime` is nulled so we don't retry on every frame.
r.runtime = null;
releaseRuntimePreview(r);
}
}
@@ -407,31 +428,7 @@ export function useGestureRecording() {
if (!isRecordingRef.current) return [];
isRecordingRef.current = false;
const r = refs.current;
if (r.runtime) {
const { element: el, savedVisibility, savedTranslate } = r.runtime;
el.style.visibility = savedVisibility;
el.style.setProperty("translate", savedTranslate || "");
// Drop the gesture's inline gsap transform before re-applying the path
// offset below, so the two don't briefly stack (the recorded keyframes
// already encode the full position, offset included). On commit the
// re-seek lands on the gesture's first keyframe; on cancel this leaves the
// element at its pre-recording position.
try {
r.runtime.set(r.runtime.selector, {
clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
});
} catch {
/* runtime gone */
}
}
if (r.cssVarOffset.x || r.cssVarOffset.y) {
const el = r.runtime?.element;
if (el) {
el.style.setProperty("--hf-studio-offset-x", `${r.cssVarOffset.x}px`);
el.style.setProperty("--hf-studio-offset-y", `${r.cssVarOffset.y}px`);
}
}
r.runtime = null;
releaseRuntimePreview(r);
r.cleanup?.();
r.cleanup = null;
const frozen = r.samples.slice();
@@ -1,5 +1,54 @@
import { describe, expect, it } from "vitest";
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildCompositionThumbnailUrl, CompositionThumbnail } from "./CompositionThumbnail";
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});
class MockResizeObserver {
observe() {}
disconnect() {}
unobserve() {}
}
class MockImage {
static instances: MockImage[] = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 0;
naturalHeight = 0;
src = "";
constructor() {
MockImage.instances.push(this);
}
}
const originalResizeObserver = globalThis.ResizeObserver;
const originalImage = globalThis.Image;
let host: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.Image = MockImage as unknown as typeof Image;
MockImage.instances = [];
host = document.createElement("div");
document.body.append(host);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
globalThis.ResizeObserver = originalResizeObserver;
globalThis.Image = originalImage;
document.body.replaceChildren();
});
describe("buildCompositionThumbnailUrl", () => {
it("includes selector and occurrence index for precise element thumbnails", () => {
@@ -17,3 +66,48 @@ describe("buildCompositionThumbnailUrl", () => {
);
});
});
describe("CompositionThumbnail", () => {
function renderThumbnail(): MockImage {
root = createRoot(host);
act(() => {
root!.render(
React.createElement(CompositionThumbnail, {
previewUrl: "/api/projects/demo/preview",
label: "",
labelColor: "#fff",
}),
);
});
const probe = MockImage.instances[0];
if (!probe) throw new Error("Expected an image probe");
return probe;
}
it("renders visible tiles after the off-DOM probe loads", () => {
const probe = renderThumbnail();
act(() => {
probe.naturalWidth = 1920;
probe.naturalHeight = 1080;
probe.onload?.();
});
const tiles = [...host.querySelectorAll("img")];
expect(tiles.length).toBeGreaterThan(0);
expect(tiles.every((tile) => !tile.classList.contains("hidden"))).toBe(true);
});
it("aborts its off-DOM image probe when unmounted", () => {
const probe = renderThumbnail();
expect(host.querySelector("img")).toBeNull();
expect(probe.src).toContain("/api/projects/demo/thumbnail/index.html");
act(() => root?.unmount());
root = null;
expect(probe.onload).toBeNull();
expect(probe.onerror).toBeNull();
expect(probe.src).toBe("");
});
});
@@ -1,4 +1,4 @@
import { memo, useCallback, useState, useRef } from "react";
import { memo, useCallback, useEffect, useState, useRef } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
interface CompositionThumbnailProps {
@@ -88,26 +88,39 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
selectorIndex,
origin: window.location.origin,
});
// Probe outside React's DOM and explicitly abort on URL changes/unmount. A
// hidden React <img> keeps its Fiber reachable from Blink's pending-activity
// queue while a request is unresolved.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
let cancelled = false;
setLoaded(false);
const probe = new Image();
probe.onload = () => {
if (cancelled) return;
if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
setAspect(probe.naturalWidth / probe.naturalHeight);
}
setLoaded(true);
};
probe.onerror = () => {
if (!cancelled) setLoaded(false);
};
probe.src = url;
return () => {
cancelled = true;
probe.onload = null;
probe.onerror = null;
probe.src = "";
};
}, [url]);
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
return (
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
<img
src={url}
alt=""
draggable={false}
loading="eager"
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setAspect(img.naturalWidth / img.naturalHeight);
}
setLoaded(true);
}}
className="hidden"
/>
{loaded && (
<div
className="absolute inset-0 flex"
@@ -1,13 +1,20 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
resolveDroppedAssetDimensions,
resolveTimelineIdForSelection,
resolveTimelineSelectionSeekTime,
} from "./studioHelpers";
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("resolveTimelineSelectionSeekTime", () => {
it("keeps the current time when it is already inside the clip range", () => {
expect(resolveTimelineSelectionSeekTime(3, { start: 0, duration: 5 })).toBe(3);
@@ -148,3 +155,43 @@ describe("resolveTimelineIdForSelection", () => {
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
});
});
describe("resolveDroppedAssetDimensions", () => {
it("aborts an image probe when metadata times out", async () => {
vi.useFakeTimers();
const probe = {
addEventListener: vi.fn(),
naturalHeight: 0,
naturalWidth: 0,
src: "",
};
vi.stubGlobal(
"Image",
vi.fn(() => probe),
);
const result = resolveDroppedAssetDimensions("demo", "assets/hung.png", "image");
await vi.advanceTimersByTimeAsync(3000);
await expect(result).resolves.toBeNull();
expect(probe.src).toBe("");
});
it("aborts a video probe when metadata times out", async () => {
vi.useFakeTimers();
const video = document.createElement("video");
const load = vi.fn();
Object.defineProperty(video, "load", { configurable: true, value: load });
const createElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName, options) =>
tagName === "video" ? video : createElement(tagName, options),
);
const result = resolveDroppedAssetDimensions("demo", "assets/hung.mp4", "video");
await vi.advanceTimersByTimeAsync(3000);
await expect(result).resolves.toBeNull();
expect(video.getAttribute("src")).toBe("");
expect(load).toHaveBeenCalledOnce();
});
});
+31 -34
View File
@@ -351,27 +351,24 @@ export async function resolveDroppedAssetDimensions(
if (kind === "image") {
return new Promise((resolve) => {
const img = new Image();
const timeout = window.setTimeout(() => resolve(null), 3000);
img.addEventListener(
"load",
() => {
window.clearTimeout(timeout);
resolve(
img.naturalWidth > 0 && img.naturalHeight > 0
? { width: img.naturalWidth, height: img.naturalHeight }
: null,
);
},
{ once: true },
);
img.addEventListener(
"error",
() => {
window.clearTimeout(timeout);
resolve(null);
},
{ once: true },
);
let settled = false;
const finalize = (value: { width: number; height: number } | null) => {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
img.onload = null;
img.onerror = null;
img.src = "";
resolve(value);
};
const timeout = window.setTimeout(() => finalize(null), 3000);
img.onload = () =>
finalize(
img.naturalWidth > 0 && img.naturalHeight > 0
? { width: img.naturalWidth, height: img.naturalHeight }
: null,
);
img.onerror = () => finalize(null);
img.src = src;
});
}
@@ -379,25 +376,25 @@ export async function resolveDroppedAssetDimensions(
return new Promise((resolve) => {
const video = document.createElement("video");
video.preload = "metadata";
const timeout = window.setTimeout(() => resolve(null), 3000);
let settled = false;
const finalize = (value: { width: number; height: number } | null) => {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
video.onloadedmetadata = null;
video.onerror = null;
video.src = "";
video.load();
resolve(value);
};
video.addEventListener(
"loadedmetadata",
() => {
finalize(
video.videoWidth > 0 && video.videoHeight > 0
? { width: video.videoWidth, height: video.videoHeight }
: null,
);
},
{ once: true },
);
video.addEventListener("error", () => finalize(null), { once: true });
const timeout = window.setTimeout(() => finalize(null), 3000);
video.onloadedmetadata = () =>
finalize(
video.videoWidth > 0 && video.videoHeight > 0
? { width: video.videoWidth, height: video.videoHeight }
: null,
);
video.onerror = () => finalize(null);
video.src = src;
});
}