mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
co-authored by
Claude Sonnet 5
parent
6579803074
commit
b6d51bd943
@@ -912,6 +912,30 @@ describe("vfx runtime — ref (second source) params", () => {
|
|||||||
expect(matteCtx.drawn).toHaveLength(1);
|
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", () => {
|
it("refuses a ref whose element carries no capture wrapper", () => {
|
||||||
makeRefTarget(undefined, "matte-1", false);
|
makeRefTarget(undefined, "matte-1", false);
|
||||||
makeCaptureHost(createMockCtx2d(), "cap", REF_NODE);
|
makeCaptureHost(createMockCtx2d(), "cap", REF_NODE);
|
||||||
@@ -1034,6 +1058,57 @@ describe("vfx runtime — ref (second source) params", () => {
|
|||||||
matte: 1,
|
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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -153,6 +153,13 @@ interface VfxPass {
|
|||||||
/** `u_src2`: the element this node's `ref` param named. Per NODE, not per
|
/** `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. */
|
* entry — two nodes in one chain may matte against different elements. */
|
||||||
ref?: VfxCaptureSource;
|
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. */
|
/** 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.
|
* 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
|
* runtime-created `<canvas layoutsubtree>` would be invisible to
|
||||||
* `detectRenderModeHints`, and the `htmlInCanvas` single-worker pin it derives
|
* `detectRenderModeHints`, and the `htmlInCanvas` single-worker pin it derives
|
||||||
* from the SOURCE markup is what keeps concurrent `drawElementImage` off.
|
* 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(
|
function resolveRefSource(
|
||||||
host: HTMLElement,
|
host: HTMLElement,
|
||||||
node: HfVfxNode,
|
node: HfVfxNode,
|
||||||
@@ -316,40 +352,41 @@ function resolveRefSource(
|
|||||||
params: HfVfxParamValues,
|
params: HfVfxParamValues,
|
||||||
gl: WebGL2RenderingContext,
|
gl: WebGL2RenderingContext,
|
||||||
cache: Map<HTMLElement, VfxCaptureSource>,
|
cache: Map<HTMLElement, VfxCaptureSource>,
|
||||||
): VfxCaptureSource | null | undefined {
|
): RefResolution {
|
||||||
const param = def.params.find((p): p is HfVfxRefParam => p.kind === "ref");
|
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];
|
const id = params[param.key];
|
||||||
if (typeof id !== "string" || id === "") {
|
if (typeof id !== "string" || id === "") return resolveEmptyRefParam(host, node, def, param);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
const target = host.ownerDocument.getElementById(id);
|
const target = host.ownerDocument.getElementById(id);
|
||||||
if (!isHtmlElement(target)) {
|
if (!isHtmlElement(target)) {
|
||||||
reportVfxError(
|
reportVfxError(
|
||||||
`${describeHost(host)}: node "${node.id}" (${def.id}) names "${param.key}" element ` +
|
`${describeHost(host)}: node "${node.id}" (${def.id}) names "${param.key}" element ` +
|
||||||
`#${id}, which is not in the composition.`,
|
`#${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
|
// 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
|
// holds those pixels. Capturing it again would cost a second
|
||||||
// frame for the same image, and on a `backdrop` host it would resolve the
|
// `drawElementImage` per frame for the same image, and on a `backdrop` host
|
||||||
// `data-vfx-for` sibling and read the layers BELOW as the map.
|
// it would resolve the `data-vfx-for` sibling and read the layers BELOW as
|
||||||
if (target === host) return undefined;
|
// 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);
|
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`);
|
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);
|
source.visible = source.canvas.hasAttribute(VFX_REF_VISIBLE_ATTR);
|
||||||
cache.set(target, source);
|
cache.set(target, source);
|
||||||
return source;
|
return { kind: "source", source };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPasses(
|
function buildPasses(
|
||||||
@@ -373,15 +410,16 @@ function buildPasses(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const params = normalizeVfxParams(def.id, node.params);
|
const params = normalizeVfxParams(def.id, node.params);
|
||||||
const ref = resolveRefSource(host, node, def, params, gl, refs);
|
const resolved = resolveRefSource(host, node, def, params, gl, refs);
|
||||||
if (ref === null) return null;
|
if (resolved.kind === "error") return null;
|
||||||
passes.push({
|
passes.push({
|
||||||
node,
|
node,
|
||||||
def,
|
def,
|
||||||
program,
|
program,
|
||||||
params,
|
params,
|
||||||
locations: resolveUniformLocations(gl, program, def),
|
locations: resolveUniformLocations(gl, program, def),
|
||||||
...(ref ? { ref } : {}),
|
...(resolved.kind === "source" ? { ref: resolved.source } : {}),
|
||||||
|
selfRef: resolved.kind === "self",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return passes;
|
return passes;
|
||||||
@@ -584,6 +622,21 @@ function isBackdropEntry(entry: VfxEntry): boolean {
|
|||||||
return entry.src?.backdrop === true;
|
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. */
|
/** Drop the GL objects the outgoing registry owns before replacing it. */
|
||||||
function releaseRegistry(): void {
|
function releaseRegistry(): void {
|
||||||
for (const entry of registry) {
|
for (const entry of registry) {
|
||||||
@@ -707,8 +760,9 @@ function setPassUniforms(
|
|||||||
gl.uniform1f(locations.fps, registryFps);
|
gl.uniform1f(locations.fps, registryFps);
|
||||||
// A def whose ref is optional needs to know which sampler holds its second
|
// 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
|
// input; on a shader without the uniform the location is null and this is a
|
||||||
// defined no-op.
|
// defined no-op. A self-referential ref counts as resolved too — `u_src2`
|
||||||
gl.uniform1f(locations.hasSrc2, pass.ref ? 1 : 0);
|
// 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) {
|
for (const param of pass.def.params) {
|
||||||
const value = paramUniformValue(param, pass, style);
|
const value = paramUniformValue(param, pass, style);
|
||||||
if (value === null) continue;
|
if (value === null) continue;
|
||||||
@@ -743,6 +797,12 @@ function bindPass(entry: VfxEntry, index: number, last: number, ping: PingPong |
|
|||||||
if (pass.ref) {
|
if (pass.ref) {
|
||||||
bindSampler(gl, pass.locations.src2, pass.ref.texture, 1);
|
bindSampler(gl, pass.locations.src2, pass.ref.texture, 1);
|
||||||
gl.activeTexture(gl.TEXTURE0);
|
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);
|
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
|
* What a capture does with what it drew. Three combinations exist, and each
|
||||||
* one is a different kind of source:
|
* 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
|
* cleared after the upload because a `layoutsubtree` canvas's BITMAP is
|
||||||
* painted by the page compositor even though its children are not, and
|
* painted by the page compositor even though its children are not, and
|
||||||
* leaving the raw capture there would show through `.hf-vfx-out`.
|
* leaving the raw capture there would show through `.hf-vfx-out`.
|
||||||
* - bitmap only — a `backdrop` wrapper whose host is not painting this frame.
|
* - 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
|
* (the bitmap IS the frame: it carries the layers below an adjustment layer
|
||||||
* that is currently off.
|
* that is currently off), or a VISIBLE ref whose owning host is not
|
||||||
* - both — a `ref` source the exporter marked visible. Its layer paints on its
|
* painting (the ref's own layer still has to show; no kernel will read the
|
||||||
* own AND feeds a kernel, so the capture is uploaded and left on screen.
|
* 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 {
|
interface CaptureMode {
|
||||||
upload: boolean;
|
upload: boolean;
|
||||||
@@ -830,6 +875,13 @@ interface CaptureMode {
|
|||||||
const CAPTURE_TO_TEXTURE: CaptureMode = { upload: true, keepBitmap: false };
|
const CAPTURE_TO_TEXTURE: CaptureMode = { upload: true, keepBitmap: false };
|
||||||
const CAPTURE_PASS_THROUGH: CaptureMode = { upload: false, keepBitmap: true };
|
const CAPTURE_PASS_THROUGH: CaptureMode = { upload: false, keepBitmap: true };
|
||||||
const CAPTURE_VISIBLE_SOURCE: CaptureMode = { upload: true, 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. */
|
/** Assigning `width`/`height` clears the bitmap, so only a real change does. */
|
||||||
function resizeCaptureCanvas(src: VfxCaptureSource, size: { width: number; height: number }): void {
|
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;
|
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(
|
function captureSource(
|
||||||
entry: VfxEntry,
|
entry: VfxEntry,
|
||||||
src: VfxCaptureSource,
|
src: VfxCaptureSource,
|
||||||
@@ -957,6 +1032,37 @@ function capturePassThrough(entry: VfxEntry, quiet = false): boolean {
|
|||||||
return captureSource(entry, src, size, quiet, CAPTURE_PASS_THROUGH);
|
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. */
|
/** Phase 2 of the page-composite protocol: the paint records are valid now. */
|
||||||
function resolveVfxCapture(): boolean {
|
function resolveVfxCapture(): boolean {
|
||||||
let painted = false;
|
let painted = false;
|
||||||
@@ -964,7 +1070,7 @@ function resolveVfxCapture(): boolean {
|
|||||||
if (entry.contextLost) continue;
|
if (entry.contextLost) continue;
|
||||||
if (entrySources(entry).length === 0) continue;
|
if (entrySources(entry).length === 0) continue;
|
||||||
if (!isPaintableHost(entry.host)) {
|
if (!isPaintableHost(entry.host)) {
|
||||||
if (isBackdropEntry(entry) && capturePassThrough(entry)) painted = true;
|
if (captureHiddenEntry(entry)) painted = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!captureEntry(entry)) 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
|
/** 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. */
|
* reported. In parallel because one canvas's wait does not inform another's. */
|
||||||
async function awaitSourcePaints(entry: VfxEntry, sources: VfxCaptureSource[]): Promise<boolean> {
|
async function awaitSourcePaints(entry: VfxEntry, sources: VfxCaptureSource[]): Promise<boolean> {
|
||||||
@@ -1094,10 +1187,31 @@ async function awaitSourcePaints(entry: VfxEntry, sources: VfxCaptureSource[]):
|
|||||||
return false;
|
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
|
/** The sources this frame needs: all of them to paint, the backdrop wrapper
|
||||||
* alone to pass through. */
|
* alone to pass through, or just the visible ref(s) to keep them current. */
|
||||||
function sourcesForFrame(entry: VfxEntry, passThrough: boolean): VfxCaptureSource[] {
|
function sourcesForFrame(entry: VfxEntry, mode: FrameCaptureMode): VfxCaptureSource[] {
|
||||||
const sources = passThrough ? (entry.src ? [entry.src] : []) : entrySources(entry);
|
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
|
// A hidden source never fires `paint`, and under a BeginFrame-controlled
|
||||||
// compositor the rAF fallback does not either — waiting on one would spend
|
// compositor the rAF fallback does not either — waiting on one would spend
|
||||||
// the whole ceiling, every frame, to arrive at the empty capture
|
// 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;
|
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(
|
async function capturePaintedHost(
|
||||||
entry: VfxEntry,
|
entry: VfxEntry,
|
||||||
t: number,
|
t: number,
|
||||||
@@ -1123,15 +1250,19 @@ async function capturePaintedHost(
|
|||||||
speculative: boolean,
|
speculative: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// A pass-through frame needs only the backdrop wrapper's paint record; a
|
// 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
|
// ref-only frame needs only the visible ref's; a painting frame needs one
|
||||||
// per canvas, so the waits are too.
|
// per source the kernels read. Records are per canvas, so the waits are too.
|
||||||
const passThrough = !isPaintableHost(entry.host);
|
const mode = frameCaptureMode(entry);
|
||||||
if (!(await awaitSourcePaints(entry, sourcesForFrame(entry, passThrough)))) return;
|
if (!(await awaitSourcePaints(entry, sourcesForFrame(entry, mode)))) return;
|
||||||
if (!stillOwnsFrame(entry, seq)) return;
|
if (!stillOwnsFrame(entry, seq)) return;
|
||||||
if (passThrough) {
|
if (mode === "passThrough") {
|
||||||
capturePassThrough(entry, speculative);
|
capturePassThrough(entry, speculative);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mode === "refOnly") {
|
||||||
|
captureVisibleRefsOnly(entry, speculative);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (captureEntry(entry, speculative)) paintEntry(entry, t);
|
if (captureEntry(entry, speculative)) paintEntry(entry, t);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1187,8 +1318,14 @@ export function paintVfx(t: number, options?: { engineMode?: boolean }): void {
|
|||||||
if (!isPaintableHost(entry.host)) {
|
if (!isPaintableHost(entry.host)) {
|
||||||
// A hidden `backdrop` host is an adjustment layer that is off, not a
|
// 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
|
// reason to drop the layers below it — those live in the wrapper
|
||||||
// OUTSIDE the host and are invisible until something draws them.
|
// OUTSIDE the host and are invisible until something draws them. A
|
||||||
if (isBackdropEntry(entry)) capturing.push(entry);
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
if (entrySources(entry).length > 0) capturing.push(entry);
|
if (entrySources(entry).length > 0) capturing.push(entry);
|
||||||
|
|||||||
@@ -52,6 +52,33 @@
|
|||||||
* premultiplied too. The consequence is worth stating: on a layer with
|
* premultiplied too. The consequence is worth stating: on a layer with
|
||||||
* transparency the noise lands in the transparent region as well, exactly as
|
* transparency the noise lands in the transparent region as well, exactly as
|
||||||
* the effect does in After Effects.
|
* 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);
|
nz = amount * (nz / ${byteScale}.0 - 0.5);
|
||||||
|
|
||||||
vec4 out4 = vec4(src.rgb + nz, src.a);
|
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;
|
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();
|
await page.close();
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user