Files
hyperframes/packages/core/src/runtime/adapters/css.ts
T
ad11de698c 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>
2026-04-18 15:08:52 -07:00

126 lines
3.6 KiB
TypeScript

import type { RuntimeDeterministicAdapter } from "../types";
export function createCssAdapter(params?: {
resolveStartSeconds?: (element: Element) => number;
}): RuntimeDeterministicAdapter {
let entries: Array<{
el: HTMLElement;
baseDelay: string;
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: () => {
entries = [];
const all = document.querySelectorAll("*");
for (const rawEl of all) {
if (!(rawEl instanceof HTMLElement)) continue;
const style = window.getComputedStyle(rawEl);
if (!style.animationName || style.animationName === "none") continue;
entries.push({
el: rawEl,
baseDelay: rawEl.style.animationDelay || "",
basePlayState: rawEl.style.animationPlayState || "",
});
}
},
seek: (ctx) => {
const time = Number(ctx.time) || 0;
for (const entry of entries) {
if (!entry.el.isConnected) continue;
const start = params?.resolveStartSeconds
? 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);
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 = `-${(localTimeMs / 1000).toFixed(3)}s`;
}
},
pause: () => {
for (const entry of entries) {
if (!entry.el.isConnected) continue;
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: () => {
entries = [];
},
};
}