diff --git a/packages/engine/spikes/de-fade-filter-test.mjs b/packages/engine/spikes/de-fade-filter-test.mjs
index 7e85121f50..18d80efd17 100644
--- a/packages/engine/spikes/de-fade-filter-test.mjs
+++ b/packages/engine/spikes/de-fade-filter-test.mjs
@@ -2,7 +2,6 @@
// instead of opacity? Reproduces the crossfade drop (two stacked scene
// wrappers at partial opacity) and compares both fade mechanisms.
import puppeteer from "puppeteer";
-import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
const W=400,H=300;
const HTML=`
diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts
index 1d57533c72..3087247752 100644
--- a/packages/engine/src/services/frameCapture.ts
+++ b/packages/engine/src/services/frameCapture.ts
@@ -36,7 +36,7 @@ import {
resolveDrawElementCaptureMode,
instrumentAcceleratedCanvases,
} from "./drawElementService.js";
-import { initThreeDProjection } from "./threeDProjection.js";
+import { initThreeDProjection, detectStackedFadeRisk } from "./threeDProjection.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import type {
CaptureOptions,
@@ -409,6 +409,24 @@ async function initDrawElementOrTransparentBackground(
await initTransparentBackground(session.page);
}
} else {
+ // Stacked-fade gate: >=2 overlapping viewport-scale fade targets
+ // reproduce the drawElementImage mid-fade blackout (crbug 521861819).
+ // Mechanism-based — resolved from the producer stub's recorded fade
+ // tween targets, independent of any authoring convention. Falls back
+ // to the platform's baseline route, same contract as the video gate.
+ // HF_FAST_CAPTURE_CROSSFADE=true bypasses for R&D.
+ if (process.env.HF_FAST_CAPTURE_CROSSFADE !== "true" && (await detectStackedFadeRisk(page))) {
+ console.log(
+ `[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
+ "stacked viewport-scale fade targets detected (drawElementImage drops " +
+ "mid-fade content, crbug 521861819)",
+ );
+ session.captureMode = session.launchCaptureMode;
+ if (transparent) {
+ await initTransparentBackground(session.page);
+ }
+ return;
+ }
// Rewrite CSS 3D contexts into WebGL-projected canvases BEFORE the
// layoutsubtree canvas goes in (rects are measured in normal layout).
// drawElementImage cannot paint 3D rendering contexts — see
diff --git a/packages/engine/src/services/threeDProjection.ts b/packages/engine/src/services/threeDProjection.ts
index 224e31655c..6c799492e0 100644
--- a/packages/engine/src/services/threeDProjection.ts
+++ b/packages/engine/src/services/threeDProjection.ts
@@ -894,3 +894,65 @@ export async function initThreeDProjection(page: Page): Promise= half the viewport area) AND overlap each other reproduce the broken
+ * pattern regardless of authoring convention. Small fade targets (captions,
+ * letters, badges) pass — measured clean at 49+ dB.
+ */
+export async function detectStackedFadeRisk(page: Page): Promise {
+ try {
+ return await page.evaluate(() => {
+ const w = window as Window & { __hfFadeTargets?: unknown[] };
+ const targets = w.__hfFadeTargets ?? [];
+ if (targets.length < 2) return false;
+ const els = new Set();
+ for (const target of targets) {
+ if (typeof target === "string") {
+ try {
+ for (const el of Array.from(document.querySelectorAll(target))) {
+ if (el instanceof HTMLElement) els.add(el);
+ }
+ } catch {
+ /* invalid selector */
+ }
+ } else if (target instanceof HTMLElement) {
+ els.add(target);
+ } else if (Array.isArray(target)) {
+ for (const t of target) if (t instanceof HTMLElement) els.add(t);
+ }
+ }
+ const viewportArea = window.innerWidth * window.innerHeight;
+ const big: DOMRect[] = [];
+ for (const el of els) {
+ const r = el.getBoundingClientRect();
+ if (r.width * r.height >= viewportArea * 0.5) big.push(r);
+ }
+ for (let i = 0; i < big.length; i++) {
+ for (let j = i + 1; j < big.length; j++) {
+ const a = big[i];
+ const b = big[j];
+ if (!a || !b) continue;
+ if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) {
+ return true;
+ }
+ }
+ }
+ return false;
+ });
+ } catch {
+ // detection failure must not break the render — treat as no risk
+ return false;
+ }
+}
diff --git a/packages/producer/src/generated/hf-early-stub-inline.ts b/packages/producer/src/generated/hf-early-stub-inline.ts
index c7485dd80e..137cd802ab 100644
--- a/packages/producer/src/generated/hf-early-stub-inline.ts
+++ b/packages/producer/src/generated/hf-early-stub-inline.ts
@@ -1,6 +1,6 @@
// AUTO-GENERATED by scripts/build-hf-early-stub.ts — do not edit
const HF_EARLY_STUB_IIFE: string =
- '"use strict";(()=>{var b=100,O=[],s=[],w=!1,f=!1,c=new Set,k=new Set;function h(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function x(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function S(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function p(n){if(n===null||typeof n!="object"||Array.isArray(n))return n;let e=n;if(!("opacity"in e)||"autoAlpha"in e||"visibility"in e)return n;let o={};for(let t of Object.keys(e))t==="opacity"?o.autoAlpha=e[t]:o[t]=e[t];return o}function T(n,e){if(window.__HF_FAST_CAPTURE_AUTOALPHA__!==!0||n==="add")return e;let o=e.slice(),t=!1;if(o.length>1){let i=p(o[1]);i!==o[1]&&(t=!0),o[1]=i}if(n==="fromTo"&&o.length>2){let i=p(o[2]);i!==o[2]&&(t=!0),o[2]=i}return t&&o[0]!==null&&o[0]!==void 0&&c.add(o[0]),v(o),o}function _(n){if(n===null||typeof n!="object"||Array.isArray(n))return!1;let e=n;return"rotationX"in e||"rotationY"in e||"transformPerspective"in e}var m=new Set;function v(n){let e=n[0];if(e==null)return;let o=window;m.has(e)||(m.add(e),o.__hfAllTweenTargets=Array.from(m)),(_(n[1])||_(n[2]))&&(k.add(e),o.__hf3dTweenTargets=Array.from(k))}function R(n){if(typeof n=="string")try{return Array.from(document.querySelectorAll(n))}catch{return[]}if(n instanceof Element)return[n];if(Array.isArray(n)||typeof NodeList<"u"&&n instanceof NodeList){let e=[];for(let o of n)o instanceof Element&&e.push(o);return e}return[]}function E(){if(window.__HF_FAST_CAPTURE_AUTOALPHA__!==!0||c.size===0)return;let n=Array.from(c);c.clear();for(let e of n)for(let o of R(e)){let t=o;if(!t.style||t.style.visibility!=="")continue;let i="";try{i=getComputedStyle(o).opacity}catch{continue}i==="0"&&(t.style.visibility="hidden")}}function y(n){let e=n.proxy.__hfReal,o=e[n.method];if(typeof o=="function"){let t=n.method==="add"?n.args.map(S):n.args;o.call(e,...t)}}function a(n,e,o){let t={proxy:n,method:e,args:T(e,o)};return n.__hfQueue.push(t),s.push(t),I(),n}function g(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function r(){for(;s.length>0;){let n=s.shift();n&&(g(n),y(n))}F()}function A(){f=!1,E(),window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function F(){f||(f=!0,x(()=>{s.length===0?A():f=!1}))}function P(){w=!1;let n=s.splice(0,b);for(let e of n)g(e),y(e);s.length>0?(w=!0,h(P)):A()}function I(){w||(w=!0,window.__hfTimelinesBuilding=!0,h(P))}var C=new Set(["to","from","fromTo","set","add"]);function G(n,e){let o=e;for(;o!==null&&o!==Object.prototype;){for(let t of Object.getOwnPropertyNames(o)){if(t==="constructor"||t==="then"||t in n||C.has(t)||t.charAt(0)==="_")continue;let i=Object.getOwnPropertyDescriptor(o,t);if(!i||typeof i.value!="function")continue;let u=i.value;n[t]=function(...d){r();let l=u.call(e,...d);return l===e?n:l}}o=Object.getPrototypeOf(o)}}function H(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...o){return a(e,"to",o)},from(...o){return a(e,"from",o)},fromTo(...o){return a(e,"fromTo",o)},set(...o){return a(e,"set",o)},add(...o){return a(e,"add",o)},pause(...o){return r(),n.pause(...o),e},play(...o){return r(),n.play(...o),e},seek(...o){return r(),n.seek(...o),e},totalTime(...o){return r(),o.length>0?(n.totalTime(...o),e):n.totalTime()},time(...o){return r(),o.length>0?(n.time(...o),e):n.time()},duration(...o){return r(),o.length>0?(n.duration(...o),e):n.duration()},getChildren(...o){r();let t=n.getChildren(...o);return Array.isArray(t)?t:[]},paused(...o){return r(),o.length>0?(n.paused(...o),e):n.paused()},timeScale(...o){return r(),o.length>0?(n.timeScale(...o),e):n.timeScale()},kill(){r(),n.kill()}};return G(e,n),O.push(e),e}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;let n=null;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function")return;let o=e.timeline.bind(e);e.timeline=i=>H(o(i));for(let i of["to","from","set"]){let u=e[i];if(typeof u!="function")continue;let d=u.bind(e);e[i]=(...l)=>d(...T(i,l))}let t=e.fromTo;if(typeof t=="function"){let i=t.bind(e);e.fromTo=(...u)=>i(...T("fromTo",u))}}})}catch{}}})();\n';
+ '"use strict";(()=>{var x=100,S=[],s=[],w=!1,l=!1,c=new Set,p=new Set;function A(n){let e=window.__HF_VIRTUAL_TIME__?.originalRequestAnimationFrame;return typeof e=="function"?e(n):requestAnimationFrame(n)}function R(n){let e=window.__HF_VIRTUAL_TIME__?.originalSetTimeout;if(typeof e=="function"){e(n,0);return}setTimeout(n,0)}function v(n){return n!==null&&typeof n=="object"&&"__hfIsProxy"in n?n.__hfReal:n}function _(n){if(n===null||typeof n!="object"||Array.isArray(n))return n;let e=n;if(!("opacity"in e)||"autoAlpha"in e||"visibility"in e)return n;let o={};for(let t of Object.keys(e))t==="opacity"?o.autoAlpha=e[t]:o[t]=e[t];return o}function k(n,e){if(window.__HF_FAST_CAPTURE_AUTOALPHA__!==!0||n==="add")return e;let o=e.slice(),t=!1;if(o.length>1){let i=_(o[1]);i!==o[1]&&(t=!0),o[1]=i}if(n==="fromTo"&&o.length>2){let i=_(o[2]);i!==o[2]&&(t=!0),o[2]=i}return t&&o[0]!==null&&o[0]!==void 0&&c.add(o[0]),F(o),o}function h(n){if(n===null||typeof n!="object"||Array.isArray(n))return!1;let e=n;return"rotationX"in e||"rotationY"in e||"transformPerspective"in e}var m=new Set,T=new Set;function y(n){if(n===null||typeof n!="object"||Array.isArray(n))return!1;let e=n;return"opacity"in e||"autoAlpha"in e}function F(n){let e=n[0];if(e==null)return;let o=window;m.has(e)||(m.add(e),o.__hfAllTweenTargets=Array.from(m)),(y(n[1])||y(n[2]))&&!T.has(e)&&(T.add(e),o.__hfFadeTargets=Array.from(T)),(h(n[1])||h(n[2]))&&(p.add(e),o.__hf3dTweenTargets=Array.from(p))}function E(n){if(typeof n=="string")try{return Array.from(document.querySelectorAll(n))}catch{return[]}if(n instanceof Element)return[n];if(Array.isArray(n)||typeof NodeList<"u"&&n instanceof NodeList){let e=[];for(let o of n)o instanceof Element&&e.push(o);return e}return[]}function I(){if(window.__HF_FAST_CAPTURE_AUTOALPHA__!==!0||c.size===0)return;let n=Array.from(c);c.clear();for(let e of n)for(let o of E(e)){let t=o;if(!t.style||t.style.visibility!=="")continue;let i="";try{i=getComputedStyle(o).opacity}catch{continue}i==="0"&&(t.style.visibility="hidden")}}function g(n){let e=n.proxy.__hfReal,o=e[n.method];if(typeof o=="function"){let t=n.method==="add"?n.args.map(v):n.args;o.call(e,...t)}}function a(n,e,o){let t={proxy:n,method:e,args:k(e,o)};return n.__hfQueue.push(t),s.push(t),H(),n}function b(n){let e=n.proxy.__hfQueue.indexOf(n);e>=0&&n.proxy.__hfQueue.splice(e,1)}function r(){for(;s.length>0;){let n=s.shift();n&&(b(n),g(n))}C()}function P(){l=!1,I(),window.__hfTimelinesBuilding=!1;try{window.dispatchEvent(new CustomEvent("hf-timelines-built"))}catch{}}function C(){l||(l=!0,R(()=>{s.length===0?P():l=!1}))}function O(){w=!1;let n=s.splice(0,x);for(let e of n)b(e),g(e);s.length>0?(w=!0,A(O)):P()}function H(){w||(w=!0,window.__hfTimelinesBuilding=!0,A(O))}var j=new Set(["to","from","fromTo","set","add"]);function G(n,e){let o=e;for(;o!==null&&o!==Object.prototype;){for(let t of Object.getOwnPropertyNames(o)){if(t==="constructor"||t==="then"||t in n||j.has(t)||t.charAt(0)==="_")continue;let i=Object.getOwnPropertyDescriptor(o,t);if(!i||typeof i.value!="function")continue;let u=i.value;n[t]=function(...d){r();let f=u.call(e,...d);return f===e?n:f}}o=Object.getPrototypeOf(o)}}function B(n){let e={__hfReal:n,__hfQueue:[],__hfIsProxy:!0,to(...o){return a(e,"to",o)},from(...o){return a(e,"from",o)},fromTo(...o){return a(e,"fromTo",o)},set(...o){return a(e,"set",o)},add(...o){return a(e,"add",o)},pause(...o){return r(),n.pause(...o),e},play(...o){return r(),n.play(...o),e},seek(...o){return r(),n.seek(...o),e},totalTime(...o){return r(),o.length>0?(n.totalTime(...o),e):n.totalTime()},time(...o){return r(),o.length>0?(n.time(...o),e):n.time()},duration(...o){return r(),o.length>0?(n.duration(...o),e):n.duration()},getChildren(...o){r();let t=n.getChildren(...o);return Array.isArray(t)?t:[]},paused(...o){return r(),o.length>0?(n.paused(...o),e):n.paused()},timeScale(...o){return r(),o.length>0?(n.timeScale(...o),e):n.timeScale()},kill(){r(),n.kill()}};return G(e,n),S.push(e),e}if(typeof window<"u"){window.__hf||(window.__hf={}),window.__hfTimelinesBuilding=!1;let n=null;try{Object.defineProperty(window,"gsap",{configurable:!0,enumerable:!0,get(){return n},set(e){if(n=e,!e||typeof e.timeline!="function")return;let o=e.timeline.bind(e);e.timeline=i=>B(o(i));for(let i of["to","from","set"]){let u=e[i];if(typeof u!="function")continue;let d=u.bind(e);e[i]=(...f)=>d(...k(i,f))}let t=e.fromTo;if(typeof t=="function"){let i=t.bind(e);e.fromTo=(...u)=>i(...k("fromTo",u))}}})}catch{}}})();\n';
/**
* Returns the pre-built HyperFrames early stub IIFE as a string constant.
diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts
index be7daede1a..d209ceeb03 100644
--- a/packages/producer/src/services/htmlCompiler.test.ts
+++ b/packages/producer/src/services/htmlCompiler.test.ts
@@ -8,7 +8,6 @@ import {
compileForRender,
detectRenderModeHints,
detectShaderTransitionUsage,
- detectSceneCrossfades,
detectThreeDTransformUsage,
discoverAudioVolumeAutomationFromTimeline,
inlineExternalScripts,
@@ -524,32 +523,6 @@ describe("detectRenderModeHints", () => {
});
});
-describe("detectSceneCrossfades", () => {
- it("detects two or more hf-tx wrappers", () => {
- expect(detectSceneCrossfades('a
b
')).toBe(
- true,
- );
- });
-
- it("does not match a single hf-tx wrapper", () => {
- expect(detectSceneCrossfades('only
')).toBe(false);
- });
-
- it("does not match hf-tx as a substring of another class", () => {
- expect(detectSceneCrossfades('a
b
')).toBe(
- false,
- );
- });
-
- it("matches hf-tx among multiple classes", () => {
- expect(
- detectSceneCrossfades(
- 'a
',
- ),
- ).toBe(true);
- });
-});
-
describe("detectThreeDTransformUsage", () => {
it("detects CSS perspective property", () => {
expect(detectThreeDTransformUsage("")).toBe(true);
diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts
index 49736c2748..9e70f3c4a0 100644
--- a/packages/producer/src/services/htmlCompiler.ts
+++ b/packages/producer/src/services/htmlCompiler.ts
@@ -59,8 +59,6 @@ export interface CompiledComposition {
hasShaderTransitions: boolean;
/** Author HTML/CSS/scripts use a CSS 3D rendering context (pre-CDN-inline scan). */
usesThreeDTransforms: boolean;
- /** Two or more hf-tx scene-transition wrappers — crossfades capture broken. */
- usesSceneCrossfades: boolean;
}
export type RenderModeHintCode = "iframe" | "requestAnimationFrame" | "htmlInCanvas";
@@ -165,20 +163,6 @@ export function detectThreeDTransformUsage(html: string): boolean {
return THREE_D_CONTEXT_PATTERN.test(html);
}
-/**
- * Scene-crossfade signal: two or more `hf-tx` transition wrappers. The
- * runtime crossfades scenes by animating opacity on stacked full-size
- * wrappers — exactly the pattern drawElementImage captures as transparent
- * frames mid-fade (crbug 521861819; spikes/de-fade-filter-crbug.mjs shows
- * filter-based fades hit it too, so no re-expression escapes it). Measured
- * floors on real multi-scene comps: 22–26 dB during every transition window.
- */
-const HF_TX_PATTERN = /class\s*=\s*["'][^"']*\bhf-tx\b/g;
-
-export function detectSceneCrossfades(html: string): boolean {
- return (html.match(HF_TX_PATTERN)?.length ?? 0) >= 2;
-}
-
const SHADER_TRANSITION_USAGE_PATTERN =
/\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
@@ -1426,7 +1410,6 @@ export async function compileForRender(
// `transformPerspective`, so scanning post-inline HTML would flag every
// composition that loads GSAP from a CDN.
const usesThreeDTransforms = detectThreeDTransformUsage(sanitizedHtml);
- const usesSceneCrossfades = detectSceneCrossfades(sanitizedHtml);
const coalescedHtml = await injectDeterministicFontFaces(
injectTextRenderingRule(
@@ -1590,7 +1573,6 @@ export async function compileForRender(
renderModeHints,
hasShaderTransitions,
usesThreeDTransforms,
- usesSceneCrossfades,
};
}
diff --git a/packages/producer/src/services/render/stages/compileStage.ts b/packages/producer/src/services/render/stages/compileStage.ts
index 0a9d659acb..61bea7f75e 100644
--- a/packages/producer/src/services/render/stages/compileStage.ts
+++ b/packages/producer/src/services/render/stages/compileStage.ts
@@ -194,24 +194,6 @@ export async function runCompileStage(input: CompileStageInput): Promise();
+/** Tween targets whose vars fade them (opacity or autoAlpha). The engine's
+ * stacked-fade gate resolves these at init: two or more overlapping
+ * viewport-scale fade targets reproduce the drawElementImage mid-fade
+ * blackout (crbug 521861819) and route the render to the baseline path.
+ * Exposed as window.__hfFadeTargets. */
+const fadeTweenTargets = new Set();
+
+function varsHasFade(vars: unknown): boolean {
+ if (vars === null || typeof vars !== "object" || Array.isArray(vars)) return false;
+ const record = vars as Record;
+ return "opacity" in record || "autoAlpha" in record;
+}
+
function recordThreeDTweenTarget(args: unknown[]): void {
const target = args[0];
if (target === null || target === undefined) return;
const w = window as Window & {
__hf3dTweenTargets?: unknown[];
__hfAllTweenTargets?: unknown[];
+ __hfFadeTargets?: unknown[];
};
if (!allTweenTargets.has(target)) {
allTweenTargets.add(target);
w.__hfAllTweenTargets = Array.from(allTweenTargets);
}
+ if ((varsHasFade(args[1]) || varsHasFade(args[2])) && !fadeTweenTargets.has(target)) {
+ fadeTweenTargets.add(target);
+ w.__hfFadeTargets = Array.from(fadeTweenTargets);
+ }
if (varsHasThreeD(args[1]) || varsHasThreeD(args[2])) {
threeDTweenTargets.add(target);
w.__hf3dTweenTargets = Array.from(threeDTweenTargets);