mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(studio): release retained preview resources (#2924)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user