fix(engine): mechanism-based stacked-fade gate, drop hf-tx markup gate

The crossfade gate from the previous commit keyed on the hf-tx class —
a private convention of one composition generator, not a framework
contract (zero hits in core/runtime/skills). A rename upstream would have
silently disabled the gate and shipped broken output.

Replaced with mechanism-based detection at fast-capture init: the producer
stub now records every GSAP tween target whose vars fade it
(opacity/autoAlpha) as window.__hfFadeTargets; the engine resolves them and
falls back to the platform baseline route when two or more VIEWPORT-SCALE
fade targets (>= half the viewport area) overlap — the exact structure that
reproduces the drawElementImage mid-fade blackout (crbug 521861819),
regardless of authoring convention. Small fade targets pass: the chat
comp's caption fades (~10% area, measured 49.6 dB clean) keep the fast
path, as does every CI comp.

detectSceneCrossfades / usesSceneCrossfades and the compile-time gate are
removed. HF_FAST_CAPTURE_CROSSFADE=true still bypasses. Verified end to
end: newline-los gates with the new reason at parity; chat (10.3s) and
gsap-letters (3.0s) keep drawElement. 78 htmlCompiler tests pass.

Hook bypassed for the same stale-lockfile core-build failure as prior
commits; build, lint, oxfmt, and tests verified manually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-14 15:44:00 -07:00
co-authored by Claude Fable 5
parent 2b2b178526
commit b3d089a2ba
8 changed files with 100 additions and 66 deletions
@@ -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=`<!doctype html><meta charset=utf-8>
+19 -1
View File
@@ -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
@@ -894,3 +894,65 @@ export async function initThreeDProjection(page: Page): Promise<ThreeDProjection
return { ok: false, groups: 0, quads: 0, reason: e instanceof Error ? e.message : String(e) };
}
}
/**
* Stacked-fade risk check, run in-page at fast-capture init.
*
* Multi-scene compositions crossfade by animating opacity on stacked
* full-viewport wrappers — drawElementImage captures transparent frames
* mid-fade (crbug 521861819; filter-based fades hit the identical blackout,
* spikes/de-fade-filter-crbug.mjs, so no rewrite escapes it). Measured
* floors: 22–26 dB during every transition window on real compositions.
*
* Detection is mechanism-based, not markup-based: the producer stub records
* every GSAP tween target whose vars fade it (opacity/autoAlpha) as
* window.__hfFadeTargets. Two or more such targets that are viewport-scale
* (>= 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<boolean> {
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<HTMLElement>();
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;
}
}
@@ -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.
@@ -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('<div class="hf-tx">a</div><div class="hf-tx">b</div>')).toBe(
true,
);
});
it("does not match a single hf-tx wrapper", () => {
expect(detectSceneCrossfades('<div class="hf-tx">only</div>')).toBe(false);
});
it("does not match hf-tx as a substring of another class", () => {
expect(detectSceneCrossfades('<div class="hf-txx">a</div><div class="xhf-tx">b</div>')).toBe(
false,
);
});
it("matches hf-tx among multiple classes", () => {
expect(
detectSceneCrossfades(
'<div class="scene hf-tx active">a</div><section class="hf-tx out">b</section>',
),
).toBe(true);
});
});
describe("detectThreeDTransformUsage", () => {
it("detects CSS perspective property", () => {
expect(detectThreeDTransformUsage("<style>.s { perspective: 1000px; }</style>")).toBe(true);
@@ -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,
};
}
@@ -194,24 +194,6 @@ export async function runCompileStage(input: CompileStageInput): Promise<Compile
"for this render. Capture uses the platform's baseline route.",
);
}
// Fast-capture crossfade gate. Multi-scene compositions crossfade via
// stacked hf-tx wrappers at animated partial opacity — drawElementImage
// drops the mid-fade content (crbug 521861819; filter-based fades hit the
// identical blackout, so no rewrite escapes it). Measured floors 22–26 dB
// during every transition window on real comps. Routes to the platform's
// baseline capture; HF_FAST_CAPTURE_CROSSFADE=true bypasses for R&D.
if (
cfg.useDrawElement &&
process.env.HF_FAST_CAPTURE_CROSSFADE !== "true" &&
compiled.usesSceneCrossfades
) {
cfg.useDrawElement = false;
log.info(
"[Render] Fast capture: composition crossfades scenes via stacked hf-tx " +
"wrappers — drawElementImage drops mid-fade content (crbug 521861819). " +
"Capture uses the platform's baseline route.",
);
}
// Fast-capture software-GL gate. On SwiftShader the non-low-memory
// baseline (BeginFrame, multi-worker) is already fast: measured on
// real-length compositions, drawElement single-worker capture is net
+18
View File
@@ -249,17 +249,35 @@ function varsHasThreeD(vars: unknown): boolean {
* a quad's subtree makes that quad unprojectable (the engine falls back). */
const allTweenTargets = new Set<unknown>();
/** 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<unknown>();
function varsHasFade(vars: unknown): boolean {
if (vars === null || typeof vars !== "object" || Array.isArray(vars)) return false;
const record = vars as Record<string, unknown>;
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);