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
b
', - ), - ).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);