Skip to content

fix(producer): capture page-side shader composites via screenshot#1656

Merged
miguel-heygen merged 1 commit into
mainfrom
magi/fix-page-side-shader-capture
Jun 22, 2026
Merged

fix(producer): capture page-side shader composites via screenshot#1656
miguel-heygen merged 1 commit into
mainfrom
magi/fix-page-side-shader-capture

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Force screenshot capture when producer selects page-side shader compositing.
  • Discard any already-loaded probe session after adding the page-side compositor pre-head script, so final capture reloads the page with __hf_page_composite_prepare/resolve installed.
  • Add an MP4-baseline producer regression fixture that exercises the probe-session reuse path plus page-side shader compositing.
  • Fix the unsafe external-asset test fixture so the full renderOrchestrator test file passes on Linux instead of checking host /etc/passwd.

Root Cause

There were two stacked issues:

  1. The producer selected page-side shader compositing, but Linux/headless-shell could still use BeginFrame capture. frameCapture.ts only runs the page-side compositor handshake (prepare -> micro screenshot paint -> resolve) when session.captureMode !== "beginframe", so BeginFrame captured the fallback DOM state instead of the resolved shader canvas.

  2. The in-process producer often reuses the browser probe session for final capture. The probe page is loaded before HF_PAGE_SIDE_COMPOSITING_STUB is added to the file server, so even after switching to screenshot capture the reused page did not have __hf_page_composite_prepare/resolve. A fresh capture session after the pre-head script is added does have the compositor and renders the shader warp correctly.

Fix

When page-side compositing is selected, the orchestrator now:

  • forces captureForceScreenshot=true; and
  • closes any existing probe session so final capture creates a fresh session from the updated file server with the page-side compositor pre-head script loaded.

The new regression fixture (page-side-shader-compositor-render-compat) uses scripted audio volume to force the browser probe path, then renders a real shader transition through the producer. It fails against the stale BeginFrame/reused-probe behavior and passes once final capture is recreated in screenshot mode with the page-side compositor pre-head script loaded.

Verification

  • Reproduced on the linked workflow HTML: default Linux capture selected beginframe, left __hf_page_composite_pending=true, and kept the compositor canvas hidden.
  • Verified the same frame with forced screenshot capture: __hf_page_composite_pending=false, compositor canvas visible, and the warped shader transition rendered.
  • Verified compiled producer HTML still renders the warp in screenshot mode, ruling out compilation as the cause.
  • Rendered the full linked composition through the actual producer streaming path after this patch:
    • log shows Recreating capture session so page-side compositing pre-head script is loaded.
    • capture strategy: usePageSideCompositing=true, forceScreenshot=true, capture mode screenshot
    • output: /tmp/hf-shader-debug/fixed-full-render-final.mp4, 1293 frames at 30fps, 43.1s
    • extracted transition contact sheet shows shader warps around 8.25s and later transition points
  • Uploaded the full corrected producer-path MP4 to the requester for visual verification.

Tests

  • bunx oxfmt --check packages/producer/src/services/renderOrchestrator.ts packages/producer/src/services/renderOrchestrator.test.ts
  • bunx vitest run src/services/renderOrchestrator.test.ts --root . from packages/producer
  • bun run --filter @hyperframes/producer typecheck
  • bun run --cwd packages/producer docker:test:update page-side-shader-compositor-render-compat
  • bun run --cwd packages/producer docker:test page-side-shader-compositor-render-compat
  • pre-commit: lint, format, fallow, largefiles, commitlint

@miguel-heygen miguel-heygen force-pushed the magi/fix-page-side-shader-capture branch from 9e86215 to 5671c30 Compare June 22, 2026 22:02

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 5671c30d. Tight, well-scoped fix — root-cause writeup matches the diff and the verification trail is concrete.

Verdict matrix

Item Status
Fix shape ✅ PREEMPT (narrow) — gated to usePageSideCompositingForTransitions only
Performance impact ✅ No regression — screenshot path only fires on the already-narrow page-side compositing path; default BeginFrame untouched
Colorspace / scaling ✅ No drift — page-side compositing already required forceScreenshot to honor the prepare/resolve handshake; PR makes that requirement explicit instead of silently violating it on Linux/headless-shell
Probe-session discard ✅ Safe — captureStreamingStage accepts `probeSession: CaptureSession
Observability updateCaptureObservability({ forceScreenshot }) + captureMode auto-flip + [Render] Recreating capture session… log + capture_strategy checkpoint includes both forceScreenshot and usePageSideCompositing
Tests ✅ Both helpers have happy + inactive coverage; security fixture fix is a clean sandbox-nesting improvement
Cross-cutting ✅ Mutation happens at renderOrchestrator.ts:1404 before any capture stage runs, so chunked-encode + streaming-encode both inherit correct mode

Notes

Probe-discard ordering looks right. The pre-existing workerCount > 1 discard at renderOrchestrator.ts:1327 runs before the new compositing discard at L1389-1403. If parallel capture already nulled probeSession, the new block correctly no-ops the discard branch (hasProbeSession=false → helper returns false) but still installs the pre-head script and flips captureForceScreenshot. Two independent reasons to discard, composed cleanly.

Test fixture fix is genuinely load-bearing. The pre-PR join(workDir, \"..\", \"..\", \"etc\", \"passwd\") would have written onto the host root if isPathInside() ever regressed silently — tmpdir()/../../etc/passwd resolves to /etc/passwd. New sandbox-rooted shape (sandboxRoot/work/inner → escape target sandboxRoot/etc/passwd) keeps the test contained. Worth the 4 extra lines.

Nit

  • renderOrchestrator.ts:1393,1406 — passing usePageSideCompositing: true as a hardcoded literal inside the if (usePageSideCompositingForTransitions) branch is slightly redundant. Either drop the param (helpers become single-input) or pass usePageSideCompositingForTransitions. Cosmetic — current shape is fine.

Question

  • Is there a follow-up to also fix the BeginFrame compositor-handshake so __hf_page_composite_prepare/resolve works under BeginFrame? The PR body frames it as "page-side compositing only runs the handshake when captureMode !== beginframe" — that's a permanent constraint vs an addressable bug. Useful to know which it is for the perf budget conversation later (screenshot is the slower path).

What I didn't verify

  • The Windows regression + Windows render verification shards are still in progress at review time; CI verdict to confirm on completion.
  • Visual fidelity of the actual shader warp — taking the PR's MP4 + frame-extraction evidence on trust.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: LGTM

Head SHA 5671c30d, base main. Reviewed after Rames' LGTM at the same SHA — I converge on his verdict and on his nit (hardcoded usePageSideCompositing: true literal at renderOrchestrator.ts:1393,1406). The angle I'll add that Rames didn't cover is the #1618 interaction analysis, since that PR landed two days ago in the same code area and the question of whether #1656 duplicates / contradicts / supersedes it matters.

Where this sits relative to #1618

#1618 (merged a85a507c, 2026-06-21) fixed two engine-side defects inside the page-side compositor: forcing opacity:1; visibility:visible on cloned scenes in prepareComposite, and un-hiding the settled scene in the seek wrapper. Those fixes only run when the compositor handshake actually runs — i.e. when packages/engine/src/services/frameCapture.ts:1285 (if (hasPendingComposite && session.captureMode !== "beginframe")) gates open.

The gap #1656 closes is upstream of that gate: on Linux + headless-shell, the producer was selecting usePageSideCompositingForTransitions=true, registering HF_PAGE_SIDE_COMPOSITING_STUB, and then acquireBrowser resolved captureMode="beginframe" per frameCapture.ts:357. The !== "beginframe" short-circuit at frameCapture.ts:1285 meant the prepare → micro-screenshot → resolve protocol never ran, so __hf_page_composite_pending stayed true, the compositor canvas stayed hidden, and BeginFrame captured the fallback DOM. #1618's fixes inside engineModePageComposite.ts were dead code on this path.

So #1656 is purely additive to #1618 — it makes #1618's mechanism actually reach the page on Linux producer renders. Not a duplicate signal, not a contradictory rule, not a superseding mechanism. The two symptoms #1618 enumerated (dropped shader transitions + final-scene content drop) are both addressed at their root cause by #1618; #1656 ensures that root-cause fix runs in producer-streaming Linux renders. The disclosure framing in this PR's body ("BeginFrame captured the fallback DOM state instead of the resolved shader canvas") matches that delta exactly.

Independent verification at HEAD

  • renderOrchestrator.ts:1382-1413: page-side gate (a) installs the pre-head script, (b) discards the probe session if live, (c) flips captureForceScreenshot=true. Order matters and is correct — the file server's pre-head script is registered before closeCaptureSession(probeSession), so the fresh session that downstream stages will create picks up the stub on next load.
  • renderOrchestrator.ts:1404 is the populate side of the read at frameCapture.ts:357: setting forceScreenshot=true flips preMode from "beginframe" to "screenshot", which is what unlocks frameCapture.ts:1285. Dispatch chain checks out end-to-end across the producer→engine boundary.
  • Probe-discard pattern (close + null + ferry browserConsoleBuffer into lastBrowserConsole) is mirrored from the existing workerCount > 1 discard at renderOrchestrator.ts:1327-1331. Rames already noted these two discard reasons compose cleanly; agreed.
  • Test fixture sandboxing (renderOrchestrator.test.ts:338-371): hermetic <sandbox>/work/inner workDir, hf-ext/../../etc/passwd resolves to <sandbox>/etc/passwd. With compiledDir = join(workDir, "compiled") at renderOrchestrator.ts:1084, the two .. segments cancel compiled and hf-ext, landing at <workDir>/etc/passwd — matches escapeTarget = join(workDir, "etc", "passwd"). Math is right, host /etc/passwd is no longer touched. Solid hermetic fix.

Scope-gap check (#1618 symptom coverage)

#1618 enumerated (a) dropped shader transitions and (b) final-scene content drop. #1656 unblocks the compositor handshake on Linux — once unblocked, both of #1618's fixes apply (prepareComposite force-visible covers (a), settled-scene un-hide covers (b)). The PR verification log demonstrates the shader warp landing in the producer-streamed MP4 ((a)); (b) isn't explicitly screenshotted but is covered by the same handshake reaching the page — no separate fix needed.

Note on Rames' follow-up question

Rames asked Miguel whether the BeginFrame-skips-handshake constraint at frameCapture.ts:1285 is a permanent design choice or an addressable bug. Endorsing the question — if BeginFrame ever learns the handshake, Linux page-side renders could drop the screenshot fallback and reclaim the perf delta. Out of scope here; useful to know which it is for downstream planning.

CI: required Linux suites green at time of review; Windows render verification + regression shards still in progress (non-blocking for the page-side Linux path this PR targets).

Review by Via

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 5671c30d — Rames D Jusso R1 verified the load-bearing checks (7 white-check / 1 nit / 1 yellow question, no blockers). The fix is narrowly gated to usePageSideCompositingForTransitions, so the default BeginFrame path is untouched and the 99% case sees no perf regression. Probe-session discard is safe (captureStreamingStage rehydrates via ?? createCaptureSession(...)), observability is wired correctly (captureMode flip + recreating-session log + capture_strategy checkpoint), tests cover both helpers' happy + inactive paths, and the sandbox-nesting fixture fix is a clean improvement.

Carry-forward question worth tracking (Rames flagged): the PR body frames "page-side compositing only runs the handshake when captureMode !== beginframe" as a permanent constraint. Worth knowing whether there's a follow-up to fix the BeginFrame compositor-handshake so __hf_page_composite_prepare/resolve works under BeginFrame too — affects the perf-budget conversation since screenshot is the slower path. Non-blocking; author can address whenever convenient.

— Jerrai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants