fix(runtime): capture a visible ref while its host is hidden; review nits

Addresses PR #4330 review feedback (jrusso1020, CHANGES_REQUESTED).

Blocking: both the engine-mode and preview-mode capture paths treated
"host not paintable" as binary — a backdrop entry got a pass-through
capture, everything else was skipped outright, including a ref source
that lives on a different, independently-visible element (e.g.
retro-wave's Logo Anim layer, which is simultaneously layer 4's
displacement map and its own fully opaque visible layer). Adds
visibleRefSources()/captureVisibleRefsOnly() and a unified
FrameCaptureMode discriminator so a visible ref still gets captured
even while its own host is hidden, on both paths.

Non-blocking, addressed:
- luma-matte's mandatory ref that names its own host now resolves to
  an explicit RefResolution {kind:"self"} and binds u_src2 to unit 0,
  instead of relying on an unbound sampler's implicit default.
  displacement-map's optional self-ref is unaffected (still folds into
  {kind:"none"}, using its existing u_hasSrc2 shader branch).
- noise shader now enforces rgb<=a (valid premultiplied alpha) after
  adding noise, independent of u_clipping — documented why u_clipping
  is currently a permanent no-op on this runtime's 8-bit render
  targets.
- added a hash-pinning test for hfJenkinsMix; documented, with a
  brute-force proof, which one of the reviewer's suggested mutations
  is unobservable within this fixture's reachable seed range.
- fixed two misplaced doc comments.

fallow flagged resolveVfxCapture/resolveRefSource as newly over the
complexity threshold after this diff; extracted resolveEmptyRefParam()
and captureHiddenEntry() to bring both back under.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-09-23 22:13:00 -07:00
co-authored by Claude Sonnet 5
parent 6579803074
commit b6d51bd943
4 changed files with 384 additions and 75 deletions
+75
View File
@@ -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: [],
});
});
});
/**
+212 -75
View File
@@ -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 `<canvas layoutsubtree>` 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<HTMLElement, VfxCaptureSource>,
): 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<boolean> {
@@ -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<void> {
// 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);
+32
View File
@@ -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;
}
`;
@@ -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);
});
/**