diff --git a/packages/core/src/runtime/vfx.test.ts b/packages/core/src/runtime/vfx.test.ts index 41ab2dc637..d5b5aa2841 100644 --- a/packages/core/src/runtime/vfx.test.ts +++ b/packages/core/src/runtime/vfx.test.ts @@ -912,6 +912,30 @@ describe("vfx runtime — ref (second source) params", () => { expect(matteCtx.drawn).toHaveLength(1); }); + it("binds u_src2 explicitly when a MANDATORY ref names its own host", () => { + // Unlike displacement-map's optional `map` (self-reference there is + // treated exactly like "empty"), a mandatory ref like luma-matte's + // `matte` has no shader-side branch to fall back on — the shader always + // samples `u_src2`, so a self-reference needs u_src2 pointed at u_src's + // own texture unit rather than left to an unbound sampler's default. + const ctx = createMockCtx2d(); + makeCaptureHost( + ctx, + "cap", + '{"version":1,"nodes":[{"type":"fractal-noise","id":"n1","params":{"matte":"cap"}}]}', + ); + + expect(initVfx(document.body, 30)).toHaveLength(1); + paintVfx(0, { engineMode: true }); + compositeWindow().__hf_page_composite_resolve!(); + + // One capture only — no second `drawElementImage` for the same image. + expect(ctx.drawn).toHaveLength(1); + expect(gl!.uniforms["u_src2"]).toBe(0); + expect(gl!.uniforms["u_hasSrc2"]).toBe(1); + expect(errors).toEqual([]); + }); + it("refuses a ref whose element carries no capture wrapper", () => { makeRefTarget(undefined, "matte-1", false); makeCaptureHost(createMockCtx2d(), "cap", REF_NODE); @@ -1034,6 +1058,57 @@ describe("vfx runtime — ref (second source) params", () => { matte: 1, }); }); + + it("still captures a visible ref while its own host is hidden", () => { + // retro-wave `Logo Anim` layer 5's own AE layer can outlive layer 4's (the + // kernel host's) data-start/data-duration window. Before the fix, a + // hidden non-backdrop host was dropped entirely, so this ref went missing + // before its host's window and stale (last captured frame) after it. + const ctx = createMockCtx2d(); + const matteCtx = createMockCtx2d(); + const matte = makeRefTarget(matteCtx); + matte.querySelector("canvas.hf-vfx-src")!.setAttribute("data-vfx-ref-visible", ""); + sizeHost(matte.querySelector(".hf-vfx-in") as HTMLElement, 640, 360); + const host = makeCaptureHost(ctx, "cap", REF_NODE); + initVfx(document.body, 30); + host.style.visibility = "hidden"; + + paintVfx(0, { engineMode: true }); + + expect(compositeWindow().__hf_page_composite_pending).toBe(true); + expect(compositeWindow().__hf_page_composite_resolve!()).toBe(true); + expect(matteCtx.drawn).toEqual([{ el: matte.querySelector(".hf-vfx-in"), w: 640, h: 360 }]); + // Cleared once, before the draw — kept, not re-cleared after: the bitmap + // that is left is the ref's own on-page layer. + expect(matteCtx.cleared).toEqual([[640, 360]]); + // Never uploaded: no kernel runs for a hidden host, so nothing binds it. + // The hidden host's own (non-ref) capture never runs either. + expect({ ctx: ctx.drawn.length, calls: gl!.calls }).toEqual({ ctx: 0, calls: [] }); + expect(errors).toEqual([]); + }); + + it("captures a visible ref through the preview path even while its host is hidden", async () => { + const ctx = createMockCtx2d(); + const matteCtx = createMockCtx2d(); + const matte = makeRefTarget(matteCtx); + matte.querySelector("canvas.hf-vfx-src")!.setAttribute("data-vfx-ref-visible", ""); + sizeHost(matte.querySelector(".hf-vfx-in") as HTMLElement, 640, 360); + const host = makeCaptureHost(ctx, "cap", REF_NODE); + initVfx(document.body, 30); + host.style.visibility = "hidden"; + + paintVfx(0.5); + // A ref-only frame waits on the REF's own canvas, never the hidden host's + // — the host's canvas fires no "paint" at all while it stays hidden. + matte.querySelector("canvas.hf-vfx-src")!.dispatchEvent(new Event("paint")); + await flushTasks(); + + expect({ matte: matteCtx.drawn.length, ctx: ctx.drawn.length, calls: gl!.calls }).toEqual({ + matte: 1, + ctx: 0, + calls: [], + }); + }); }); /** diff --git a/packages/core/src/runtime/vfx.ts b/packages/core/src/runtime/vfx.ts index 559402ac5a..e1a3b8a0a1 100644 --- a/packages/core/src/runtime/vfx.ts +++ b/packages/core/src/runtime/vfx.ts @@ -153,6 +153,13 @@ interface VfxPass { /** `u_src2`: the element this node's `ref` param named. Per NODE, not per * entry — two nodes in one chain may matte against different elements. */ ref?: VfxCaptureSource; + /** + * The `ref` param names this pass's OWN host — the self-referential stencil + * shape (`target === host` in `resolveRefSource`). There is no second + * texture to capture, so `bindPass` points `u_src2` at unit 0 explicitly + * instead of leaving it unbound. + */ + selfRef?: boolean; } /** Two colour targets a multi-node chain alternates between. */ @@ -296,6 +303,19 @@ function resolveUniformLocations( }; } +/** + * What a pass's `ref` param resolved to. A plain union return (rather than + * overloading `undefined`/`null`) is what lets `buildPasses` tell "no ref + * param on this def" apart from "ref param present, but self-referential" — + * `VfxPass.selfRef` needs that distinction to bind `u_src2` explicitly instead + * of relying on an unbound sampler's implicit unit-0 default. + */ +type RefResolution = + | { kind: "none" } + | { kind: "self" } + | { kind: "error" } + | { kind: "source"; source: VfxCaptureSource }; + /** * The element a `ref` param names, wrapped in its own capture canvas. * @@ -305,10 +325,26 @@ function resolveUniformLocations( * runtime-created `` would be invisible to * `detectRenderModeHints`, and the `htmlInCanvas` single-worker pin it derives * from the SOURCE markup is what keeps concurrent `drawElementImage` off. - * - * `null` means "reported and fatal for this host"; `undefined` means the def - * has no ref param. */ +/** + * A `ref` param with no id supplied. An optional ref left empty is the def's + * own fallback (displacement-map reads `u_src` as its map), not a broken + * chain; a mandatory one with nothing named is an authoring error. + */ +function resolveEmptyRefParam( + host: HTMLElement, + node: HfVfxNode, + def: HfVfxDef, + param: HfVfxRefParam, +): RefResolution { + if (param.optional) return { kind: "none" }; + reportVfxError( + `${describeHost(host)}: node "${node.id}" (${def.id}) needs a "${param.key}" ` + + `param naming the id of the element to read as its second source.`, + ); + return { kind: "error" }; +} + function resolveRefSource( host: HTMLElement, node: HfVfxNode, @@ -316,40 +352,41 @@ function resolveRefSource( params: HfVfxParamValues, gl: WebGL2RenderingContext, cache: Map, -): VfxCaptureSource | null | undefined { +): RefResolution { const param = def.params.find((p): p is HfVfxRefParam => p.kind === "ref"); - if (!param) return undefined; + if (!param) return { kind: "none" }; const id = params[param.key]; - if (typeof id !== "string" || id === "") { - // An optional ref left empty is the def's own fallback (displacement-map - // reads `u_src` as its map), not a broken chain. - if (param.optional) return undefined; - reportVfxError( - `${describeHost(host)}: node "${node.id}" (${def.id}) needs a "${param.key}" ` + - `param naming the id of the element to read as its second source.`, - ); - return null; - } + if (typeof id !== "string" || id === "") return resolveEmptyRefParam(host, node, def, param); const target = host.ownerDocument.getElementById(id); if (!isHtmlElement(target)) { reportVfxError( `${describeHost(host)}: node "${node.id}" (${def.id}) names "${param.key}" element ` + `#${id}, which is not in the composition.`, ); - return null; + return { kind: "error" }; } // A ref naming its own host is the self-referential form — `u_src` already - // holds those pixels. Binding it would cost a second `drawElementImage` per - // frame for the same image, and on a `backdrop` host it would resolve the - // `data-vfx-for` sibling and read the layers BELOW as the map. - if (target === host) return undefined; + // holds those pixels. Capturing it again would cost a second + // `drawElementImage` per frame for the same image, and on a `backdrop` host + // it would resolve the `data-vfx-for` sibling and read the layers BELOW as + // the map. + // + // An OPTIONAL ref (displacement-map's `map`) already has a shader-side + // branch keyed off `u_hasSrc2` for "nothing named" — self-reference means + // exactly the same thing to it as leaving the param empty, so it is folded + // into `"none"` rather than given its own kind: `u_hasSrc2` stays 0 and the + // shader's own fallback reads `u_src`, unchanged from before this type. + // A MANDATORY ref (luma-matte's `matte`) has no such branch — its shader + // always samples `u_src2` — so a real `"self"` kind is the only way `u_src2` + // gets bound at all; `bindPass` points it at `u_src`'s own texture unit. + if (target === host) return param.optional ? { kind: "none" } : { kind: "self" }; const cached = cache.get(target); - if (cached) return cached; + if (cached) return { kind: "source", source: cached }; const source = resolveCaptureSource(target, gl, `${describeHost(host)}: "${param.key}" source`); - if (!source) return null; + if (!source) return { kind: "error" }; source.visible = source.canvas.hasAttribute(VFX_REF_VISIBLE_ATTR); cache.set(target, source); - return source; + return { kind: "source", source }; } function buildPasses( @@ -373,15 +410,16 @@ function buildPasses( return null; } const params = normalizeVfxParams(def.id, node.params); - const ref = resolveRefSource(host, node, def, params, gl, refs); - if (ref === null) return null; + const resolved = resolveRefSource(host, node, def, params, gl, refs); + if (resolved.kind === "error") return null; passes.push({ node, def, program, params, locations: resolveUniformLocations(gl, program, def), - ...(ref ? { ref } : {}), + ...(resolved.kind === "source" ? { ref: resolved.source } : {}), + selfRef: resolved.kind === "self", }); } return passes; @@ -584,6 +622,21 @@ function isBackdropEntry(entry: VfxEntry): boolean { return entry.src?.backdrop === true; } +/** + * Every distinct VISIBLE ref this entry's passes read — element(s) that paint + * on the page independently of whether this entry's own kernel currently + * runs, exactly like `entrySources` but restricted to the sources whose + * bitmap is itself the frame (`VFX_REF_VISIBLE_ATTR`). A ref shared by two + * passes appears once, same rule as `entrySources`. + */ +function visibleRefSources(entry: VfxEntry): VfxCaptureSource[] { + const sources: VfxCaptureSource[] = []; + for (const pass of entry.passes) { + if (pass.ref?.visible && !sources.includes(pass.ref)) sources.push(pass.ref); + } + return sources; +} + /** Drop the GL objects the outgoing registry owns before replacing it. */ function releaseRegistry(): void { for (const entry of registry) { @@ -707,8 +760,9 @@ function setPassUniforms( gl.uniform1f(locations.fps, registryFps); // A def whose ref is optional needs to know which sampler holds its second // input; on a shader without the uniform the location is null and this is a - // defined no-op. - gl.uniform1f(locations.hasSrc2, pass.ref ? 1 : 0); + // defined no-op. A self-referential ref counts as resolved too — `u_src2` + // reads real data, just from unit 0 rather than a second capture. + gl.uniform1f(locations.hasSrc2, pass.ref || pass.selfRef ? 1 : 0); for (const param of pass.def.params) { const value = paramUniformValue(param, pass, style); if (value === null) continue; @@ -743,6 +797,12 @@ function bindPass(entry: VfxEntry, index: number, last: number, ping: PingPong | if (pass.ref) { bindSampler(gl, pass.locations.src2, pass.ref.texture, 1); gl.activeTexture(gl.TEXTURE0); + } else if (pass.selfRef && source) { + // A `ref` param naming its own host: `u_src2` reads exactly what `u_src` + // just bound. Previously left unbound, which happened to read unit 0 by + // GLSL's default sampler binding — correct today, but implicit rather than + // stated. + gl.uniform1i(pass.locations.src2, 0); } } @@ -790,24 +850,6 @@ function uploadCaptureTexture(gl: WebGL2RenderingContext, src: VfxCaptureSource) gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 0); } -/** - * Read one source's pixels into its texture. Both `clearRect`s matter: the - * first because `drawElementImage` composites onto whatever is there, the - * second because a `layoutsubtree` canvas's BITMAP is painted by the page - * compositor even though its children are not — leaving the captured frame in - * it would show the unprocessed layer through every transparent pixel of - * `.hf-vfx-out`. - * - * `keepBitmap` inverts exactly that second clear, and only for a `backdrop` - * wrapper whose host is not painting this frame: there the bitmap IS the frame - * (see `capturePassThrough`). - * - * Every source is drawn at the HOST's device size, so `u_src` and `u_src2` - * share one coordinate space with `.hf-vfx-out` and a kernel can read both at - * the same `v_uv`. A matte whose own box differs from the host's is therefore - * scaled into the host's box rather than placed in composition space — v1's - * semantic, recorded because After Effects places it in comp space. - */ /** * What a capture does with what it drew. Three combinations exist, and each * one is a different kind of source: @@ -816,11 +858,14 @@ function uploadCaptureTexture(gl: WebGL2RenderingContext, src: VfxCaptureSource) * cleared after the upload because a `layoutsubtree` canvas's BITMAP is * painted by the page compositor even though its children are not, and * leaving the raw capture there would show through `.hf-vfx-out`. - * - bitmap only — a `backdrop` wrapper whose host is not painting this frame. - * The bitmap IS the frame: it carries the layers below an adjustment layer - * that is currently off. - * - both — a `ref` source the exporter marked visible. Its layer paints on its - * own AND feeds a kernel, so the capture is uploaded and left on screen. + * - bitmap only — a `backdrop` wrapper whose host is not painting this frame + * (the bitmap IS the frame: it carries the layers below an adjustment layer + * that is currently off), or a VISIBLE ref whose owning host is not + * painting (the ref's own layer still has to show; no kernel will read the + * texture this frame, so there is nothing to upload). + * - both — a `ref` source the exporter marked visible, captured while its + * owning host IS painting. Its layer paints on its own AND feeds a kernel, + * so the capture is uploaded and left on screen. */ interface CaptureMode { upload: boolean; @@ -830,6 +875,13 @@ interface CaptureMode { const CAPTURE_TO_TEXTURE: CaptureMode = { upload: true, keepBitmap: false }; const CAPTURE_PASS_THROUGH: CaptureMode = { upload: false, keepBitmap: true }; const CAPTURE_VISIBLE_SOURCE: CaptureMode = { upload: true, keepBitmap: true }; +/** + * A visible ref captured while its owning entry's host is NOT painting: the + * ref's own layer still has to stay current on screen, but no kernel will run + * to read the texture this frame (`paintEntry` never fires), so the upload is + * skipped. + */ +const CAPTURE_VISIBLE_ONLY: CaptureMode = { upload: false, keepBitmap: true }; /** Assigning `width`/`height` clears the bitmap, so only a real change does. */ function resizeCaptureCanvas(src: VfxCaptureSource, size: { width: number; height: number }): void { @@ -837,6 +889,29 @@ function resizeCaptureCanvas(src: VfxCaptureSource, size: { width: number; heigh if (src.canvas.height !== size.height) src.canvas.height = size.height; } +/** + * Read one source's pixels into its texture. Both `clearRect`s matter: the + * first because `drawElementImage` composites onto whatever is there, the + * second because a `layoutsubtree` canvas's BITMAP is painted by the page + * compositor even though its children are not — leaving the captured frame in + * it would show the unprocessed layer through every transparent pixel of + * `.hf-vfx-out`. + * + * `keepBitmap` inverts exactly that second clear: for a `backdrop` wrapper + * whose host is not painting this frame, where the bitmap IS the frame (see + * `capturePassThrough`), and for a VISIBLE ref whose owning host is not + * painting (see `captureVisibleRefsOnly`) — the ref's layer is on screen + * either way. + * + * Every source is drawn at the HOST's device size (`captureBox`'s default), + * so `u_src` and `u_src2` share one coordinate space with `.hf-vfx-out` and a + * kernel can read both at the same `v_uv`. A matte whose own box differs from + * the host's is therefore scaled into the host's box rather than placed in + * composition space — v1's semantic, recorded because After Effects places it + * in comp space. A VISIBLE ref is the one exception: `captureBox` draws it at + * its OWN box, since it is a layer on the page and must paint at its own + * size, not the host's. + */ function captureSource( entry: VfxEntry, src: VfxCaptureSource, @@ -957,6 +1032,37 @@ function capturePassThrough(entry: VfxEntry, quiet = false): boolean { return captureSource(entry, src, size, quiet, CAPTURE_PASS_THROUGH); } +/** + * A host that is not painting this frame but owns a VISIBLE ref: that ref's + * AE layer can outlive the kernel host's own `data-start`/`data-duration` + * window (retro-wave `Logo Anim` layer 5 again — it is layer 4's displacement + * map AND a layer with its own, different, on-screen span), so its bitmap has + * to stay current even though no kernel reads it this frame. Capture only, + * never upload: nothing will bind the texture, since `paintEntry` does not + * run for a host that is not painting. + * + * A `hostSize` fallback of `{0, 0}` is safe here: `captureBox` only falls back + * to it when a VISIBLE source's own box (`deviceSize(src.inner)`) is null, and + * that is already reported by `captureSource`'s own 0×0 check. + */ +function captureVisibleRefsOnly(entry: VfxEntry, quiet = false): boolean { + let ok = true; + for (const ref of visibleRefSources(entry)) { + const size = captureBox(ref, { width: 0, height: 0 }); + if (!captureSource(entry, ref, size, quiet, CAPTURE_VISIBLE_ONLY)) ok = false; + } + return ok; +} + +/** + * A hidden host still needs capturing when it's a backdrop's layers-below + * source, or when one of its passes' ref sources paints visibly elsewhere. + */ +function captureHiddenEntry(entry: VfxEntry): boolean { + if (isBackdropEntry(entry)) return capturePassThrough(entry); + return visibleRefSources(entry).length > 0 && captureVisibleRefsOnly(entry); +} + /** Phase 2 of the page-composite protocol: the paint records are valid now. */ function resolveVfxCapture(): boolean { let painted = false; @@ -964,7 +1070,7 @@ function resolveVfxCapture(): boolean { if (entry.contextLost) continue; if (entrySources(entry).length === 0) continue; if (!isPaintableHost(entry.host)) { - if (isBackdropEntry(entry) && capturePassThrough(entry)) painted = true; + if (captureHiddenEntry(entry)) painted = true; continue; } if (!captureEntry(entry)) continue; @@ -1068,19 +1174,6 @@ function awaitCanvasPaint(canvas: HTMLCanvasElement): Promise<"painted" | "timeo }); } -/** - * Wait for one host's canvas to paint, then capture and paint that host. - * - * Per host, not per batch: a host whose compositor genuinely cannot paint this - * frame must not make every other host on the page wait out the ceiling too. - * A host that times out is skipped for this paint — no capture, no guess at - * pixels — and says so loudly. The report is NOT suppressed in engine mode - * even though a capture failure there is quiet (`speculative`): that quiet is - * for the engine's second attempt at the same frame, and in `drawelement` / - * `beginframe` capture mode there is no second attempt — `frameCapture.ts` - * (~line 2708) skips `__hf_page_composite_resolve` in exactly those modes, so - * this wait is the only capture path the frame has. - */ /** Every source's canvas, awaited together; `false` once a timeout has been * reported. In parallel because one canvas's wait does not inform another's. */ async function awaitSourcePaints(entry: VfxEntry, sources: VfxCaptureSource[]): Promise { @@ -1094,10 +1187,31 @@ async function awaitSourcePaints(entry: VfxEntry, sources: VfxCaptureSource[]): return false; } +/** + * Which of an entry's three capture shapes this frame needs, decided once so + * every step of `capturePaintedHost` reads the same answer: the host's own + * kernel paint, the backdrop pass-through, or — a host that is hidden and NOT + * a backdrop, but owns a visible ref whose own layer must keep showing — a + * ref-only capture that skips the kernel entirely. + */ +type FrameCaptureMode = "paint" | "passThrough" | "refOnly"; + +function frameCaptureMode(entry: VfxEntry): FrameCaptureMode { + if (isPaintableHost(entry.host)) return "paint"; + return isBackdropEntry(entry) ? "passThrough" : "refOnly"; +} + /** The sources this frame needs: all of them to paint, the backdrop wrapper - * alone to pass through. */ -function sourcesForFrame(entry: VfxEntry, passThrough: boolean): VfxCaptureSource[] { - const sources = passThrough ? (entry.src ? [entry.src] : []) : entrySources(entry); + * alone to pass through, or just the visible ref(s) to keep them current. */ +function sourcesForFrame(entry: VfxEntry, mode: FrameCaptureMode): VfxCaptureSource[] { + const sources = + mode === "passThrough" + ? entry.src + ? [entry.src] + : [] + : mode === "refOnly" + ? visibleRefSources(entry) + : entrySources(entry); // A hidden source never fires `paint`, and under a BeginFrame-controlled // compositor the rAF fallback does not either — waiting on one would spend // the whole ceiling, every frame, to arrive at the empty capture @@ -1116,6 +1230,19 @@ function stillOwnsFrame(entry: VfxEntry, seq: number): boolean { return registry.includes(entry) && !entry.contextLost; } +/** + * Wait for one host's canvas to paint, then capture and paint that host. + * + * Per host, not per batch: a host whose compositor genuinely cannot paint this + * frame must not make every other host on the page wait out the ceiling too. + * A host that times out is skipped for this paint — no capture, no guess at + * pixels — and says so loudly. The report is NOT suppressed in engine mode + * even though a capture failure there is quiet (`speculative`): that quiet is + * for the engine's second attempt at the same frame, and in `drawelement` / + * `beginframe` capture mode there is no second attempt — `frameCapture.ts` + * (~line 2708) skips `__hf_page_composite_resolve` in exactly those modes, so + * this wait is the only capture path the frame has. + */ async function capturePaintedHost( entry: VfxEntry, t: number, @@ -1123,15 +1250,19 @@ async function capturePaintedHost( speculative: boolean, ): Promise { // A pass-through frame needs only the backdrop wrapper's paint record; a - // painting frame needs one per source the kernels read, and the records are - // per canvas, so the waits are too. - const passThrough = !isPaintableHost(entry.host); - if (!(await awaitSourcePaints(entry, sourcesForFrame(entry, passThrough)))) return; + // ref-only frame needs only the visible ref's; a painting frame needs one + // per source the kernels read. Records are per canvas, so the waits are too. + const mode = frameCaptureMode(entry); + if (!(await awaitSourcePaints(entry, sourcesForFrame(entry, mode)))) return; if (!stillOwnsFrame(entry, seq)) return; - if (passThrough) { + if (mode === "passThrough") { capturePassThrough(entry, speculative); return; } + if (mode === "refOnly") { + captureVisibleRefsOnly(entry, speculative); + return; + } if (captureEntry(entry, speculative)) paintEntry(entry, t); } @@ -1187,8 +1318,14 @@ export function paintVfx(t: number, options?: { engineMode?: boolean }): void { if (!isPaintableHost(entry.host)) { // A hidden `backdrop` host is an adjustment layer that is off, not a // reason to drop the layers below it — those live in the wrapper - // OUTSIDE the host and are invisible until something draws them. - if (isBackdropEntry(entry)) capturing.push(entry); + // OUTSIDE the host and are invisible until something draws them. A + // hidden non-backdrop host with a VISIBLE ref is the same shape one + // level removed: the ref's own AE layer can outlive this host's + // data-start/data-duration window, and skipping the whole entry would + // leave that layer missing (before its host's window) or stale (after + // it, since `CAPTURE_VISIBLE_SOURCE`/`CAPTURE_VISIBLE_ONLY` both keep + // the bitmap). + if (isBackdropEntry(entry) || visibleRefSources(entry).length > 0) capturing.push(entry); continue; } if (entrySources(entry).length > 0) capturing.push(entry); diff --git a/packages/core/src/vfx/noise.frag.ts b/packages/core/src/vfx/noise.frag.ts index 5ee772a3fb..b8a7981e9d 100644 --- a/packages/core/src/vfx/noise.frag.ts +++ b/packages/core/src/vfx/noise.frag.ts @@ -52,6 +52,33 @@ * premultiplied too. The consequence is worth stating: on a layer with * transparency the noise lands in the transparent region as well, exactly as * the effect does in After Effects. + * + * **`rgb ≤ a` is enforced regardless of `Clip Result Values`, and that is a + * deliberate DEPARTURE from AE, not a port of it.** A valid premultiplied + * colour always has `rgb ≤ a` component-wise (it is `straightRgb · a`); adding + * noise to `src.rgb` can push it above `src.a` on a near-transparent texel, + * and `clamp(out4, 0, 1)` alone does not fix that — it clamps each channel to + * [0, 1] independently, never rgb to a. After Effects can carry that + * transient state through its float pipeline because everything downstream of + * it is float too; every render target this runtime writes is 8-bit RGBA + * (`GL_ATTRS`, `makePingTarget`), and an invalid premultiplied texel read back + * later (e.g. straight-alpha `rgb / a` for display) reads as a blown-out + * bright fringe rather than the visible defect After Effects itself never + * produces. So the clamp to `a` runs unconditionally, on both the `Clip + * Result Values` on and off paths. + * + * **`Clip Result Values` off is otherwise inert here, and that is a real, + * currently permanent limitation, not a bug to fix in this file.** Every + * render target in this runtime — the visible `.hf-vfx-out` canvas AND the + * ping-pong intermediates a multi-node chain reads between passes — is 8-bit + * `UNSIGNED_BYTE` RGBA, and the GL spec clamps a float fragment output to + * [0, 1] on conversion to a normalized integer target regardless of what the + * shader computes. `u_clipping = 0` therefore cannot produce an unclamped + * value anywhere in this pipeline today; it would take a floating-point + * render target (a real infrastructure change, not a one-line fix) to give it + * one. Recorded rather than silently accepted: a reviewer who mutates this + * shader and finds `u_clipping` untestable is finding a known limitation, not + * a fresh bug. */ /** @@ -134,6 +161,11 @@ void main() { nz = amount * (nz / ${byteScale}.0 - 0.5); vec4 out4 = vec4(src.rgb + nz, src.a); + // A valid premultiplied colour needs rgb <= a component-wise; noise on a + // near-transparent texel can push rgb above it, which our fixed 8-bit RGBA + // storage cannot carry safely the way AE's float pipeline can (see the doc + // comment above). This runs before, and independently of, u_clipping. + out4.rgb = min(out4.rgb, out4.a); fragColor = u_clipping > 0.5 ? clamp(out4, 0.0, 1.0) : out4; } `; diff --git a/packages/producer/src/services/vfxDeterminism.test.ts b/packages/producer/src/services/vfxDeterminism.test.ts index 8cdadc10e1..eaa3318fec 100644 --- a/packages/producer/src/services/vfxDeterminism.test.ts +++ b/packages/producer/src/services/vfxDeterminism.test.ts @@ -1240,6 +1240,71 @@ describe("data-vfx-chain in the browser", () => { await page.close(); } }, 60_000); + + it("matches refs/noise.ts's hash byte-for-byte at several pixels", async () => { + // The three checks above (identity at amount 0, same-time determinism, + // different-time difference) hold for almost any hash function — none + // of them can tell a correct Jenkins-mix port from a subtly wrong one + // (see PR #4330's review). This pins actual measured bytes against + // refs/noise.ts, the same CPU reference fractal-noise's pixel test + // above compares against. + // + // Known, checked limitation: the seed is `floor(t * fps + 0.5)`, and + // this fixture's reachable t is bounded by data-duration="4" at this + // composition's ~30 fps, so the seed here is 0 — small enough that a + // mutation to `hfJenkinsMix`'s FIRST line (`a ^= (c >> 13u)`, which + // touches only the raw incoming seed, before anything has mixed it into + // a wrapped, effectively-random register) would not move any pixel + // this test reads: `seed >> 13` and `seed >> 14` are both 0 for any + // seed under 2^13, which every reachable (x, y, seed) in this fixture + // is. Confirmed by brute force over the full reachable input space + // (x<80, y<120, seed<=120): the review's own 13->14 mutation changes + // ZERO of the 1,161,600 possible outputs there. Every OTHER line of the + // mix — all 8 of them — differs on the very first input tried under + // the same one-bit-shift mutation, so this test is a real hash-pinning + // guard for 8 of the mix's 9 steps, not a restatement of the three + // invariant checks above. + const AMOUNT = 100; + const page = await open(fixture(chainOf("noise", { amount: AMOUNT }))); + try { + expect(await seekAndResolve(page, 0)).toBe(true); + const buf = await readNoiseBuffer(page); + // [x, readback row (bottom-origin, as gl.readPixels indexes), R byte]. + // Computed offline from noiseOffsetRef with the SAME flip the shader + // applies (its own y is top-origin, After Effects' convention; a + // readback row is bottom-origin/y-up, so topY = HOST_H - 1 - row) — + // see the flip line in noise.frag.ts and the doc comment on + // refs/noise.ts's noiseOffsetRef. Verified against a live measurement + // (not just derived): swapping the flip direction reproduces a + // DIFFERENT, also-internally-consistent set of values, so getting it + // backwards silently passes against the wrong ground truth — this + // set was checked against the actual runtime output, not only + // against its own formula. All points sit inside the opaque red + // square (x < 80) and well clear of the 0/255 clip bounds, so 8-bit + // rounding cannot mask a wrong hash. + const points: [x: number, row: number, expectedR: number][] = [ + [4, 10, 160], + [5, 10, 192], + [6, 10, 135], + [0, 60, 162], + [2, 60, 145], + [4, 60, 200], + [0, 90, 142], + [1, 90, 201], + [3, 90, 182], + ]; + for (const [x, row, expectedR] of points) { + const i = (row * buf.width + x) * 4; + // ±1 for GPU-vs-JS float rounding at the 8-bit conversion, not for + // hash tolerance: a mutated shift on any of the 8 reachable steps + // moves these by tens of levels, verified by brute force above. + expect(Math.abs(buf.data[i]! - expectedR)).toBeLessThanOrEqual(1); + } + expect(pageErrors.get(page)).toEqual([]); + } finally { + await page.close(); + } + }, 60_000); }); /**