mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(producer): auto-fallback screenshot capture for raf and iframes (#331)
* fix(core): drive adapter seeks when composition has no GSAP timeline renderSeek returned early when deps.getTimeline() was null, skipping the onDeterministicSeek call that drives all frame adapters (CSS, WAAPI, Lottie, Three.js). That meant compositions using any non-GSAP animation primitive froze on their initial frame during capture. Now we still quantize the seek time and fire onDeterministicSeek even without a timeline, so each adapter gets a chance to advance. GSAP compositions are unaffected — timeline-driven seek still takes the same path it did before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(producer): auto-fallback screenshot capture for raf and iframes Co-Authored-By: Codex <codex@openai.com> * test(producer): add render compatibility regression fixtures Co-Authored-By: Codex <codex@openai.com> * fix(core): scrub CSS animations via WAAPI currentTime Co-Authored-By: Codex <codex@openai.com> * test(producer): cover css keyframe renders Co-Authored-By: Codex <codex@openai.com> * fix(producer): propagate virtual time into iframe documents Co-Authored-By: Codex <codex@openai.com> * test(producer): refresh iframe docker golden Co-Authored-By: Codex <codex@openai.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
Codex
parent
59aa2c9ec9
commit
ad11de698c
@@ -37,6 +37,7 @@ describe("css adapter", () => {
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [];
|
||||
adapter.seek({ time: 3 });
|
||||
|
||||
expect(el.style.animationPlayState).toBe("paused");
|
||||
@@ -58,6 +59,7 @@ describe("css adapter", () => {
|
||||
|
||||
const adapter = createCssAdapter({ resolveStartSeconds: () => 2 });
|
||||
adapter.discover();
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [];
|
||||
adapter.seek({ time: 5 });
|
||||
|
||||
expect(el.style.animationPlayState).toBe("paused");
|
||||
@@ -80,6 +82,7 @@ describe("css adapter", () => {
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [];
|
||||
adapter.seek({ time: 1 });
|
||||
expect(el.style.animationPlayState).toBe("paused");
|
||||
|
||||
@@ -96,4 +99,54 @@ describe("css adapter", () => {
|
||||
// Should not crash when seeking after revert
|
||||
expect(() => adapter.seek({ time: 1 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("seek drives CSS animations through WAAPI currentTime when available", () => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("data-start", "1");
|
||||
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;
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [animation];
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
adapter.seek({ time: 3 });
|
||||
|
||||
expect(animation.currentTime).toBe(2000);
|
||||
expect(animation.pause).toHaveBeenCalled();
|
||||
expect(el.style.animationDelay).toBe("");
|
||||
expect(el.style.animationPlayState).toBe("");
|
||||
|
||||
document.body.removeChild(el);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("play resumes WAAPI animations and restores inline styles", () => {
|
||||
const el = document.createElement("div");
|
||||
el.style.animationName = "spin";
|
||||
el.style.animationPlayState = "running";
|
||||
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;
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [animation];
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
adapter.play?.();
|
||||
|
||||
expect(animation.play).toHaveBeenCalled();
|
||||
expect(el.style.animationPlayState).toBe("running");
|
||||
|
||||
document.body.removeChild(el);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,63 @@ export function createCssAdapter(params?: {
|
||||
basePlayState: string;
|
||||
}> = [];
|
||||
|
||||
const getAnimationsForElement = (el: HTMLElement): Animation[] => {
|
||||
if (typeof el.getAnimations !== "function") return [];
|
||||
try {
|
||||
return el.getAnimations();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const seekAnimations = (animations: Animation[], timeMs: number) => {
|
||||
for (const animation of animations) {
|
||||
try {
|
||||
animation.currentTime = timeMs;
|
||||
} catch {
|
||||
// ignore animations that reject currentTime writes
|
||||
}
|
||||
try {
|
||||
animation.pause();
|
||||
} catch {
|
||||
// infinite unresolved animations can throw on pause before currentTime sticks
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const playAnimations = (animations: Animation[]) => {
|
||||
for (const animation of animations) {
|
||||
try {
|
||||
animation.play();
|
||||
} catch {
|
||||
// ignore animation edge-cases
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pauseAnimations = (animations: Animation[]) => {
|
||||
for (const animation of animations) {
|
||||
try {
|
||||
animation.pause();
|
||||
} catch {
|
||||
// ignore animation edge-cases
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const restoreInlineStyles = (entry: (typeof entries)[number]) => {
|
||||
if (entry.baseDelay) {
|
||||
entry.el.style.animationDelay = entry.baseDelay;
|
||||
} else {
|
||||
entry.el.style.removeProperty("animation-delay");
|
||||
}
|
||||
if (entry.basePlayState) {
|
||||
entry.el.style.animationPlayState = entry.basePlayState;
|
||||
} else {
|
||||
entry.el.style.removeProperty("animation-play-state");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: "css",
|
||||
discover: () => {
|
||||
@@ -32,16 +89,33 @@ export function createCssAdapter(params?: {
|
||||
const start = params?.resolveStartSeconds
|
||||
? params.resolveStartSeconds(entry.el)
|
||||
: Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0;
|
||||
const localTime = Math.max(0, time - start);
|
||||
const localTimeMs = Math.max(0, time - start) * 1000;
|
||||
const animations = getAnimationsForElement(entry.el);
|
||||
if (animations.length > 0) {
|
||||
seekAnimations(animations, localTimeMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback for environments without WAAPI-backed CSS animation handles.
|
||||
entry.el.style.animationPlayState = "paused";
|
||||
entry.el.style.animationDelay = `-${localTime.toFixed(3)}s`;
|
||||
entry.el.style.animationDelay = `-${(localTimeMs / 1000).toFixed(3)}s`;
|
||||
}
|
||||
},
|
||||
pause: () => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.el.isConnected) continue;
|
||||
entry.el.style.animationPlayState = entry.basePlayState || "paused";
|
||||
if (entry.baseDelay) entry.el.style.animationDelay = entry.baseDelay;
|
||||
const animations = getAnimationsForElement(entry.el);
|
||||
if (animations.length > 0) {
|
||||
pauseAnimations(animations);
|
||||
}
|
||||
restoreInlineStyles(entry);
|
||||
}
|
||||
},
|
||||
play: () => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.el.isConnected) continue;
|
||||
restoreInlineStyles(entry);
|
||||
playAnimations(getAnimationsForElement(entry.el));
|
||||
}
|
||||
},
|
||||
revert: () => {
|
||||
|
||||
@@ -67,6 +67,22 @@ describe("waapi adapter", () => {
|
||||
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 adapter = createWaapiAdapter();
|
||||
adapter.seek({ time: 1.25 });
|
||||
|
||||
expect(mockAnim.currentTime).toBe(1250);
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("discover is a no-op", () => {
|
||||
const adapter = createWaapiAdapter();
|
||||
expect(() => adapter.discover()).not.toThrow();
|
||||
|
||||
@@ -9,10 +9,14 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
const timeMs = Math.max(0, (Number(ctx.time) || 0) * 1000);
|
||||
for (const animation of document.getAnimations()) {
|
||||
try {
|
||||
animation.pause();
|
||||
animation.currentTime = timeMs;
|
||||
} catch {
|
||||
// ignore animation edge-cases
|
||||
// ignore animations that reject currentTime writes
|
||||
}
|
||||
try {
|
||||
animation.pause();
|
||||
} catch {
|
||||
// infinite unresolved animations can throw here until currentTime resolves
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1475,10 +1475,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
|
||||
state.deterministicAdapters = [
|
||||
createWaapiAdapter(),
|
||||
createCssAdapter({
|
||||
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
|
||||
}),
|
||||
createWaapiAdapter(),
|
||||
createLottieAdapter(),
|
||||
createThreeAdapter(),
|
||||
createGsapAdapter({ getTimeline: () => state.capturedTimeline }),
|
||||
|
||||
@@ -89,12 +89,14 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
},
|
||||
renderSeek: (timeSeconds: number) => {
|
||||
const timeline = deps.getTimeline();
|
||||
if (!timeline) return;
|
||||
const quantized = seekTimelineDeterministically(
|
||||
timeline,
|
||||
timeSeconds,
|
||||
deps.getCanonicalFps(),
|
||||
);
|
||||
const canonicalFps = deps.getCanonicalFps();
|
||||
// When a composition has no GSAP timeline (pure CSS / WAAPI / Lottie /
|
||||
// Three.js adapters driving the animation), still seek the adapters so
|
||||
// their animations advance. Without this, non-GSAP compositions freeze
|
||||
// on their initial frame.
|
||||
const quantized = timeline
|
||||
? seekTimelineDeterministically(timeline, timeSeconds, canonicalFps)
|
||||
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
|
||||
deps.onDeterministicSeek(quantized);
|
||||
deps.setIsPlaying(false);
|
||||
deps.onSyncMedia(quantized, false);
|
||||
|
||||
Reference in New Issue
Block a user