mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(render): avoid empty WAAPI scans and llvmpipe auto GPU (#1775)
Avoid the screenshot-path #1715 regression by skipping empty WAAPI/CSS animation scans per seek and classifying known software WebGL renderers correctly in browserGpuMode=auto.\n\nAddresses #1715.
This commit is contained in:
@@ -126,6 +126,32 @@ describe("css adapter", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not rescan element animations on every seek", () => {
|
||||
const el = document.createElement("div");
|
||||
el.style.animationName = "spin";
|
||||
document.body.appendChild(el);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
|
||||
return { animationName: "spin" } as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
const animation = { currentTime: 0, pause: vi.fn(), play: vi.fn() } as unknown as Animation;
|
||||
const getAnimations = vi.fn(() => [animation]);
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = getAnimations;
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
adapter.seek({ time: 1 });
|
||||
adapter.seek({ time: 2 });
|
||||
adapter.seek({ time: 3 });
|
||||
|
||||
expect(getAnimations).toHaveBeenCalledTimes(1);
|
||||
expect(animation.currentTime).toBe(3000);
|
||||
|
||||
document.body.removeChild(el);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("play resumes WAAPI animations and restores inline styles", () => {
|
||||
const el = document.createElement("div");
|
||||
el.style.animationName = "spin";
|
||||
|
||||
@@ -8,6 +8,7 @@ export function createCssAdapter(params?: {
|
||||
el: HTMLElement;
|
||||
baseDelay: string;
|
||||
basePlayState: string;
|
||||
animations: Animation[];
|
||||
}> = [];
|
||||
|
||||
const getAnimationsForElement = (el: HTMLElement): Animation[] => {
|
||||
@@ -84,6 +85,7 @@ export function createCssAdapter(params?: {
|
||||
el: rawEl,
|
||||
baseDelay: rawEl.style.animationDelay || "",
|
||||
basePlayState: rawEl.style.animationPlayState || "",
|
||||
animations: getAnimationsForElement(rawEl),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -95,7 +97,7 @@ export function createCssAdapter(params?: {
|
||||
? params.resolveStartSeconds(entry.el)
|
||||
: Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0;
|
||||
const localTimeMs = Math.max(0, time - start) * 1000;
|
||||
const animations = getAnimationsForElement(entry.el);
|
||||
const animations = entry.animations;
|
||||
if (animations.length > 0) {
|
||||
seekAnimations(animations, localTimeMs);
|
||||
continue;
|
||||
@@ -109,7 +111,7 @@ export function createCssAdapter(params?: {
|
||||
pause: () => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.el.isConnected) continue;
|
||||
const animations = getAnimationsForElement(entry.el);
|
||||
const animations = entry.animations;
|
||||
if (animations.length > 0) {
|
||||
pauseAnimations(animations);
|
||||
}
|
||||
@@ -120,7 +122,7 @@ export function createCssAdapter(params?: {
|
||||
for (const entry of entries) {
|
||||
if (!entry.el.isConnected) continue;
|
||||
restoreInlineStyles(entry);
|
||||
playAnimations(getAnimationsForElement(entry.el));
|
||||
playAnimations(entry.animations);
|
||||
}
|
||||
},
|
||||
revert: () => {
|
||||
|
||||
@@ -4,6 +4,40 @@ import { createWaapiAdapter } from "./waapi";
|
||||
describe("waapi adapter", () => {
|
||||
const originalDocument = (globalThis as { document?: unknown }).document;
|
||||
|
||||
const makeAnimation = (currentTime = 0) => ({
|
||||
addEventListener: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
currentTime,
|
||||
});
|
||||
|
||||
const setAnimations = (items: Array<ReturnType<typeof makeAnimation>>) => {
|
||||
const getAnimations = vi.fn(() => items);
|
||||
(document as any).getAnimations = getAnimations;
|
||||
return getAnimations;
|
||||
};
|
||||
|
||||
const makeDynamicDiscoveryFixture = (dynamicStartMs = 0) => {
|
||||
const existing = makeAnimation();
|
||||
const dynamic = makeAnimation(dynamicStartMs);
|
||||
let includeDynamic = false;
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
adapter.seek({ time: 0.6 });
|
||||
expect(existing.currentTime).toBe(600);
|
||||
|
||||
return {
|
||||
adapter,
|
||||
dynamic,
|
||||
revealDynamic: () => {
|
||||
includeDynamic = true;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { document?: unknown }).document = {
|
||||
getAnimations: vi.fn(() => []),
|
||||
@@ -24,38 +58,34 @@ describe("waapi adapter", () => {
|
||||
});
|
||||
|
||||
it("seek pauses and sets currentTime on all animations", () => {
|
||||
const mockAnim = { pause: vi.fn(), currentTime: 0 };
|
||||
(document as any).getAnimations = vi.fn(() => [mockAnim]);
|
||||
const mockAnim = makeAnimation();
|
||||
setAnimations([mockAnim]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.seek({ time: 2.5 });
|
||||
|
||||
expect(mockAnim.pause).toHaveBeenCalled();
|
||||
expect(mockAnim.currentTime).toBe(2500); // seconds → ms
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("seek clamps negative time to 0", () => {
|
||||
const mockAnim = { pause: vi.fn(), currentTime: 0 };
|
||||
(document as any).getAnimations = vi.fn(() => [mockAnim]);
|
||||
const mockAnim = makeAnimation();
|
||||
setAnimations([mockAnim]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.seek({ time: -3 });
|
||||
|
||||
expect(mockAnim.currentTime).toBe(0);
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("pause pauses all animations", () => {
|
||||
const mockAnim = { pause: vi.fn(), currentTime: 0 };
|
||||
(document as any).getAnimations = vi.fn(() => [mockAnim]);
|
||||
const mockAnim = makeAnimation();
|
||||
setAnimations([mockAnim]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.pause();
|
||||
|
||||
expect(mockAnim.pause).toHaveBeenCalled();
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("handles missing getAnimations API", () => {
|
||||
@@ -70,34 +100,27 @@ describe("waapi adapter", () => {
|
||||
});
|
||||
|
||||
it("handles animation that throws on pause", () => {
|
||||
const mockAnim = {
|
||||
pause: vi.fn(() => {
|
||||
throw new Error("invalid state");
|
||||
}),
|
||||
currentTime: 0,
|
||||
};
|
||||
(document as any).getAnimations = vi.fn(() => [mockAnim]);
|
||||
const mockAnim = makeAnimation();
|
||||
mockAnim.pause.mockImplementation(() => {
|
||||
throw new Error("invalid state");
|
||||
});
|
||||
setAnimations([mockAnim]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("still sets currentTime when pause throws for an unresolved infinite animation", () => {
|
||||
const mockAnim = {
|
||||
pause: vi.fn(() => {
|
||||
throw new Error("invalid state");
|
||||
}),
|
||||
currentTime: 0,
|
||||
};
|
||||
(document as any).getAnimations = vi.fn(() => [mockAnim]);
|
||||
const mockAnim = makeAnimation();
|
||||
mockAnim.pause.mockImplementation(() => {
|
||||
throw new Error("invalid state");
|
||||
});
|
||||
setAnimations([mockAnim]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.seek({ time: 1.25 });
|
||||
|
||||
expect(mockAnim.currentTime).toBe(1250);
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("discover is a no-op", () => {
|
||||
@@ -105,75 +128,146 @@ describe("waapi adapter", () => {
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
});
|
||||
|
||||
it("anchors newly discovered WAAPI animations to the seek where they first appear", () => {
|
||||
const existing = { pause: vi.fn(), currentTime: 0 };
|
||||
const dynamic = { pause: vi.fn(), currentTime: 0 };
|
||||
let includeDynamic = false;
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
it.each([
|
||||
["relative start", 0],
|
||||
["inherited absolute composition time", 700],
|
||||
])("anchors newly discovered WAAPI animations with %s", (_label, dynamicStartMs) => {
|
||||
const { adapter, dynamic, revealDynamic } = makeDynamicDiscoveryFixture(dynamicStartMs);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
|
||||
adapter.seek({ time: 0.6 });
|
||||
expect(existing.currentTime).toBe(600);
|
||||
|
||||
includeDynamic = true;
|
||||
adapter.seek({ time: 0.7 });
|
||||
expect(existing.currentTime).toBe(700);
|
||||
expect(dynamic.currentTime).toBe(0);
|
||||
|
||||
adapter.seek({ time: 0.8 });
|
||||
expect(dynamic.currentTime).toBe(100);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("rebases newly discovered WAAPI animations that inherit absolute composition time", () => {
|
||||
const existing = { pause: vi.fn(), currentTime: 0 };
|
||||
const dynamic = { pause: vi.fn(), currentTime: 700 };
|
||||
let includeDynamic = false;
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
|
||||
adapter.seek({ time: 0.6 });
|
||||
expect(existing.currentTime).toBe(600);
|
||||
|
||||
includeDynamic = true;
|
||||
revealDynamic();
|
||||
adapter.seek({ time: 0.7 });
|
||||
expect(dynamic.currentTime).toBe(0);
|
||||
|
||||
adapter.seek({ time: 0.8 });
|
||||
expect(dynamic.currentTime).toBe(100);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("does not double-count inherited absolute time when discover runs again after time has advanced", () => {
|
||||
const existing = { pause: vi.fn(), currentTime: 0 };
|
||||
const dynamic = { pause: vi.fn(), currentTime: 700 };
|
||||
let includeDynamic = false;
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
const { adapter, dynamic, revealDynamic } = makeDynamicDiscoveryFixture(700);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
|
||||
adapter.seek({ time: 0.6 });
|
||||
expect(existing.currentTime).toBe(600);
|
||||
|
||||
includeDynamic = true;
|
||||
revealDynamic();
|
||||
adapter.discover();
|
||||
adapter.seek({ time: 0.7 });
|
||||
|
||||
expect(dynamic.currentTime).toBe(200);
|
||||
});
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
it("does not rescan document animations on every seek when discover found none", () => {
|
||||
const getAnimations = setAnimations([]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
adapter.seek({ time: 0.1 });
|
||||
adapter.seek({ time: 0.2 });
|
||||
adapter.seek({ time: 0.3 });
|
||||
|
||||
expect(getAnimations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tracks WAAPI animations created after an empty discover via Element.animate", () => {
|
||||
const getAnimations = setAnimations([]);
|
||||
|
||||
const originalElement = (globalThis as { Element?: unknown }).Element;
|
||||
const animation = makeAnimation();
|
||||
class MockElement {}
|
||||
(MockElement.prototype as { animate?: () => typeof animation }).animate = vi.fn(
|
||||
() => animation,
|
||||
);
|
||||
(globalThis as { Element?: unknown }).Element = MockElement;
|
||||
|
||||
try {
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
|
||||
const el = new MockElement() as InstanceType<typeof MockElement> & {
|
||||
animate: () => typeof animation;
|
||||
};
|
||||
el.animate();
|
||||
adapter.seek({ time: 0.25 });
|
||||
|
||||
expect(animation.currentTime).toBe(250);
|
||||
expect(animation.pause).toHaveBeenCalled();
|
||||
// The hook tracks the created animation; once WAAPI is active, the
|
||||
// adapter may resume scanning to catch sibling animations.
|
||||
expect(getAnimations).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
if (originalElement === undefined) {
|
||||
delete (globalThis as { Element?: unknown }).Element;
|
||||
} else {
|
||||
(globalThis as { Element?: unknown }).Element = originalElement;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("drops finished lazy-tracked animations so empty scans stay skipped again", () => {
|
||||
const getAnimations = setAnimations([]);
|
||||
|
||||
const originalElement = (globalThis as { Element?: unknown }).Element;
|
||||
const animation = makeAnimation();
|
||||
const listeners = new Map<string, EventListener>();
|
||||
animation.addEventListener.mockImplementation((type: string, listener: EventListener) => {
|
||||
listeners.set(type, listener);
|
||||
});
|
||||
class MockElement {}
|
||||
(MockElement.prototype as { animate?: () => typeof animation }).animate = vi.fn(
|
||||
() => animation,
|
||||
);
|
||||
(globalThis as { Element?: unknown }).Element = MockElement;
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
try {
|
||||
adapter.discover();
|
||||
|
||||
const el = new MockElement() as InstanceType<typeof MockElement> & {
|
||||
animate: () => typeof animation;
|
||||
};
|
||||
el.animate();
|
||||
adapter.seek({ time: 0.25 });
|
||||
|
||||
expect(animation.currentTime).toBe(250);
|
||||
expect(getAnimations).toHaveBeenCalledTimes(2);
|
||||
|
||||
listeners.get("finish")?.({} as Event);
|
||||
adapter.seek({ time: 0.5 });
|
||||
|
||||
expect(getAnimations).toHaveBeenCalledTimes(2);
|
||||
expect(animation.currentTime).toBe(250);
|
||||
} finally {
|
||||
adapter.revert?.();
|
||||
if (originalElement === undefined) {
|
||||
delete (globalThis as { Element?: unknown }).Element;
|
||||
} else {
|
||||
(globalThis as { Element?: unknown }).Element = originalElement;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("revert restores the Element.animate hook", () => {
|
||||
const originalElement = (globalThis as { Element?: unknown }).Element;
|
||||
const animation = makeAnimation();
|
||||
const originalAnimate = vi.fn(() => animation);
|
||||
class MockElement {}
|
||||
(MockElement.prototype as { animate?: typeof originalAnimate }).animate = originalAnimate;
|
||||
(globalThis as { Element?: unknown }).Element = MockElement;
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
try {
|
||||
adapter.discover();
|
||||
|
||||
expect((MockElement.prototype as { animate?: unknown }).animate).not.toBe(originalAnimate);
|
||||
|
||||
adapter.revert?.();
|
||||
|
||||
expect((MockElement.prototype as { animate?: unknown }).animate).toBe(originalAnimate);
|
||||
expect(
|
||||
(MockElement.prototype as { __hfOriginalAnimate?: unknown }).__hfOriginalAnimate,
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
if (originalElement === undefined) {
|
||||
delete (globalThis as { Element?: unknown }).Element;
|
||||
} else {
|
||||
(globalThis as { Element?: unknown }).Element = originalElement;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,17 @@ import { swallow } from "../diagnostics";
|
||||
export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
let didDiscover = false;
|
||||
let lastSeekTimeMs = 0;
|
||||
const baselines = new WeakMap<
|
||||
let animateHookInstalled = false;
|
||||
let hookedPrototype:
|
||||
| (Element & {
|
||||
animate?: Element["animate"];
|
||||
__hfOriginalAnimate?: Element["animate"];
|
||||
})
|
||||
| undefined;
|
||||
let originalAnimate: Element["animate"] | undefined;
|
||||
let installedAnimate: Element["animate"] | undefined;
|
||||
const animations = new Set<Animation>();
|
||||
let baselines = new WeakMap<
|
||||
Animation,
|
||||
{
|
||||
compositionTimeMs: number;
|
||||
@@ -54,18 +64,75 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
return baseline;
|
||||
};
|
||||
|
||||
const trackAnimation = (animation: Animation, compositionTimeMs: number) => {
|
||||
if (!animations.has(animation)) {
|
||||
animations.add(animation);
|
||||
const stopTracking = () => {
|
||||
animations.delete(animation);
|
||||
};
|
||||
try {
|
||||
animation.addEventListener("finish", stopTracking, { once: true });
|
||||
animation.addEventListener("cancel", stopTracking, { once: true });
|
||||
} catch (err) {
|
||||
swallow("runtime.adapters.waapi.site4", err);
|
||||
}
|
||||
}
|
||||
ensureBaseline(animation, compositionTimeMs);
|
||||
};
|
||||
|
||||
const trackAnimations = (items: Animation[], compositionTimeMs: number) => {
|
||||
for (const animation of items) {
|
||||
trackAnimation(animation, compositionTimeMs);
|
||||
}
|
||||
};
|
||||
|
||||
const installAnimateHook = () => {
|
||||
if (animateHookInstalled) return;
|
||||
if (typeof Element === "undefined") return;
|
||||
const proto = Element.prototype as Element & {
|
||||
animate?: Element["animate"];
|
||||
__hfOriginalAnimate?: Element["animate"];
|
||||
};
|
||||
if (typeof proto.animate !== "function" || proto.__hfOriginalAnimate) return;
|
||||
const original = proto.animate;
|
||||
try {
|
||||
Object.defineProperty(proto, "__hfOriginalAnimate", {
|
||||
value: original,
|
||||
configurable: true,
|
||||
});
|
||||
const wrappedAnimate = function (...args: Parameters<Element["animate"]>) {
|
||||
const animation = original.apply(this, args);
|
||||
trackAnimation(animation, lastSeekTimeMs);
|
||||
return animation;
|
||||
};
|
||||
proto.animate = wrappedAnimate;
|
||||
hookedPrototype = proto;
|
||||
originalAnimate = original;
|
||||
installedAnimate = wrappedAnimate;
|
||||
animateHookInstalled = true;
|
||||
} catch {
|
||||
// Best-effort only. Existing animations are still discovered via snapshot.
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: "waapi",
|
||||
discover: () => {
|
||||
didDiscover = true;
|
||||
for (const animation of snapshotAnimations()) {
|
||||
ensureBaseline(animation, lastSeekTimeMs);
|
||||
}
|
||||
installAnimateHook();
|
||||
trackAnimations(snapshotAnimations(), lastSeekTimeMs);
|
||||
},
|
||||
seek: (ctx) => {
|
||||
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
|
||||
lastSeekTimeMs = timeMs;
|
||||
for (const animation of snapshotAnimations()) {
|
||||
// document.getAnimations() is surprisingly expensive in Chromium even
|
||||
// when it returns [], and renderSeek calls this adapter once per frame.
|
||||
// After an empty discover, skip the per-frame global scan until authored
|
||||
// code creates a WAAPI animation via Element.animate (hooked above).
|
||||
if (!didDiscover || animations.size > 0) {
|
||||
trackAnimations(snapshotAnimations(), didDiscover ? timeMs : 0);
|
||||
}
|
||||
for (const animation of animations) {
|
||||
const baseline = didDiscover
|
||||
? ensureBaseline(animation, timeMs)
|
||||
: ensureBaseline(animation, 0);
|
||||
@@ -86,8 +153,10 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
}
|
||||
},
|
||||
pause: () => {
|
||||
if (!document.getAnimations) return;
|
||||
for (const animation of document.getAnimations()) {
|
||||
if (!didDiscover) {
|
||||
trackAnimations(snapshotAnimations(), lastSeekTimeMs);
|
||||
}
|
||||
for (const animation of animations) {
|
||||
try {
|
||||
animation.pause();
|
||||
} catch (err) {
|
||||
@@ -96,5 +165,30 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
}
|
||||
}
|
||||
},
|
||||
revert: () => {
|
||||
animations.clear();
|
||||
baselines = new WeakMap();
|
||||
didDiscover = false;
|
||||
lastSeekTimeMs = 0;
|
||||
if (
|
||||
hookedPrototype &&
|
||||
originalAnimate &&
|
||||
installedAnimate &&
|
||||
hookedPrototype.animate === installedAnimate
|
||||
) {
|
||||
try {
|
||||
hookedPrototype.animate = originalAnimate;
|
||||
if (hookedPrototype.__hfOriginalAnimate === originalAnimate) {
|
||||
delete hookedPrototype.__hfOriginalAnimate;
|
||||
}
|
||||
} catch (err) {
|
||||
swallow("runtime.adapters.waapi.site5", err);
|
||||
}
|
||||
}
|
||||
hookedPrototype = undefined;
|
||||
originalAnimate = undefined;
|
||||
installedAnimate = undefined;
|
||||
animateHookInstalled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user