Skip to content

feat(engine,cli): drawElementImage fast-capture behind --experimental-fast-capture#1295

Open
vanceingalls wants to merge 27 commits into
mainfrom
drawelement-fast-capture
Open

feat(engine,cli): drawElementImage fast-capture behind --experimental-fast-capture#1295
vanceingalls wants to merge 27 commits into
mainfrom
drawelement-fast-capture

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an experimental frame-capture mode that reads DOM paint records directly via Chrome's canvas.drawElementImage API instead of Page.captureScreenshot. On DOM/CSS/GSAP compositions it is significantly faster on a hardware GPU. It is fully opt-in behind a new CLI flag — default behaviour is byte-unchanged.

hyperframes render --experimental-fast-capture --output out.mp4

Benchmark — drawElement-only comps, macOS hardware GPU (14/20; excl. style-7/8/10/15 video-gated + raf-ball compat-route + iframe-render-compat rAF-sync):

Platform p50 p75 p90
macOS GPU 1.49× 1.56× 1.68×

Total wall-clock (macOS, all 20 comps): 358s → 324s. S3 Gen_OS production suite (14 comps, macOS GPU): 1.60× total, range 1.17–2.85×, all 14 at noise floor.

Why GPU-only (no Docker/SwiftShader speedup)

drawElement's entire advantage is skipping the GPU→CPU screenshot readback IPC. A software rasterizer (SwiftShader — Docker/CI with no GPU) has no GPU egress to skip: baseline beginFrame+screenshot and drawElement both block on identical CPU rasterization. Measured parity, font-variant-numeric: baseline 7822 ms vs fast 7979 ms; page-side draw/readback/encode all ~0 ms. drawElement only adds a per-frame CDP round-trip, so it runs net-slower on SwiftShader.

An earlier revision of this PR reported ~1.5× in Docker. That number came from an egress micro-benchmark whose baseline used Page.captureScreenshot without optimizeForSpeed: true — the production baseline has it. Against the fair (optimized) baseline, SwiftShader is parity. No regression; the comparison was a strawman.

This PR therefore routes all SwiftShader renders to the platform baseline (see Fallback gates). The speedup is real only on a hardware GPU.

How it works

  • New capture mode "drawelement" (alongside "beginframe" / "screenshot"). A <canvas layoutsubtree> is injected around the composition root after page-ready; each frame clears the canvas, calls drawElementImage(root, 0, 0), and reads it back.
  • Format-aware encode — emits JPEG for opaque output (mp4) and PNG for transparent output (mov/webm/png-sequence).
  • Page-side compositing — drawElement and page-side shader compositing are mutually incompatible; resolveConfig auto-disables enablePageSideCompositing when fast-capture is on (shader transitions fall back to the Node-side layered blend).

Fixes

Paint-event sync + No cached paint record crash (71b0eb77)

drawElementImage draws from a snapshot recorded at the paint event. Called outside one it returns the previous frame's snapshot (silent stale output), or throws InvalidStateError: No cached paint record when no paint has landed. Fix: force a paint-level invalidation via a 1×1 sentinel outside the captured root, await the canvas paint event, draw inside the handler. Under BeginFrame control (Linux headless-shell) the sync is skipped — the per-frame HeadlessExperimental.beginFrame already paints a fresh snapshot.

45 s init stall on CSS/rAF-only compositions (71b0eb77)

Compositions driven purely by CSS animations / rAF never register window.__timelines[id], burning the full 45 s poll timeout. Hosts now opt out with data-no-timeline on [data-composition-id]. Measured: css-spinner 49.7 s → 3.9 s.

Body/HTML background lost in fast capture (223c4752)

drawElementImage only paints the captured subtree — ancestor backgrounds on <body> and <html> (outside the composition root) were invisible. Fix: walk ancestors before each frame and fill the canvas with the first non-transparent background-color found. Six comps went from 23–31 dB → 53–71 dB.

WebGL / WebGPU / 2D canvas content frozen at frame 0 (ec037e2a)

GPU-accelerated canvases present via compositor texture swap; their paint records freeze at the first frame. Fix: getContext instrumentation (via evaluateOnNewDocument) forces preserveDrawingBuffer: true for WebGL/WebGPU and tracks all accelerated canvases. Per frame: hide tracked canvases from the DOM, composite their live content via drawImage, then call drawElementImage. three.js, WebGL, WebGPU, and custom shaders work on the fast path.

Transparent comps in opaque mp4 encoded black (94c95c05)

Compositions with no author background and transparent content in an opaque-format render (e.g. mp4) were captured against black instead of white. Fix: white-fill the canvas before drawElementImage when no ancestor background is found and the output format is JPEG. webm-transparency: 3.4 dB → 61–67 dB.

forceScreenshot compat hints overridden (bf8922cb)

Fast capture was ignoring forceScreenshot compat hints set by the engine (raw rAF animations, alpha output). Fix: initializeSession short-circuits fast capture when cfg.forceScreenshot is set. raf-ball Docker: fully black → ∞ PSNR.

Compile-time video gate + BeginFrame liveness probe (b2612bfc)

The runtime hasVideo gate fired after browser launch — on Linux that launch is BeginFrame mode, and the gate's screenshot fallback then called Page.captureScreenshot on a BeginFrame browser, which hangs by design. The compiler knows videoCount before launch, so the gate now runs at compile time and disables drawElement only (not forces screenshot), letting the render take the platform's normal baseline route. A new probeBeginFrameLiveness() probe backstops genuinely-stalling comps. style-7/8/10/15 went from unrenderable on Docker to baseline-speed.

Runtime opacity → autoAlpha rewrite (5d53f13c)

Stacked position:absolute containers with opacity:0 children cause drawElementImage to capture black frames (240/240 deterministic on both macOS GPU and SwiftShader). Workaround: hf-early-stub rewrites opacityautoAlpha in all GSAP tween vars when window.__HF_FAST_CAPTURE_AUTOALPHA__ is set. autoAlpha sets visibility:hidden at 0, removing elements from the paint tree entirely instead of stacking them as promoted compositor layers. chat comp: 29.4 dB → 49.6 dB, zero blackout frames.

Video gate routes to baseline path, not screenshot (74ee7b60)

The compile-time gate (above) was setting forceScreenshot, but the Linux non-lowmem baseline captures via BeginFrame (~40% faster than screenshot on SwiftShader). Gate now sets cfg.useDrawElement = false only, letting mode resolution pick the platform baseline route. style-8 Docker: 68s → 46.6s (parity with baseline).

CI workflow env vars (8538a970)

validate-fast-video env vars were empty on push events; defaults now applied when unset.

Fallback gates

Limitations that route specific renders back to a correct baseline path automatically — the only cost is losing the speedup for that render, never a broken frame.

  • Video (153602d, updated b2612bfc, 74ee7b60) — compositions containing <video> disable drawElement at compile time and take the platform baseline route (BeginFrame on Linux, screenshot on macOS). Root cause: a Chromium opacity-animation bug on stacked transparent layers is proxied by video presence. HF_FAST_CAPTURE_VIDEO=true bypasses both gates (R&D escape hatch).
  • SwiftShader / software rasterizer (05d319eb)resolveDrawElementCaptureMode routes ANY SwiftShader render to the platform baseline. drawElement yields no speedup there (parity-or-slower — see "Why GPU-only" above), so there is nothing to gain and a small per-frame round-trip to lose. The runtime detectSwiftShader check is the signal (the actual WebGL renderer, not the requested browserGpuMode). This subsumes the former transparent-only SwiftShader case below.
  • Transparent + SwiftShader (1601c1fe) — SwiftShader also drops promoted compositor sub-layers onto transparent canvas destinations (a pixel-correctness defect, crbug 521434899). Now covered by the blanket SwiftShader gate above.
  • Supersampling (deviceScaleFactor > 1)drawElementImage rasterises at CSS pixels with no DPR option. Supersampled renders fall back to screenshot.

Wiring

CLI flag → env → engine config:

--experimental-fast-capturePRODUCER_EXPERIMENTAL_FAST_CAPTUREEngineConfig.useDrawElementresolveConfig → capture session. Threaded through the Docker render path (buildDockerRunArgs) as well.

Testing

  • Unit (drawElementService.test.ts, config.test.ts): SwiftShader detection, capture-mode routing, env resolution, page-side auto-disable. Full engine suite green (697 pass).
  • Regression (fast-capture-gsap, 53a09b7d): producer-level guard — renders a GSAP composition in fast mode and PSNR-checks against the screenshot baseline.
  • CI gate (.github/workflows/fast-video-validation.yml): fast-video PSNR ≥ 25 dB — currently fails by design; passes if/when an upstream Chromium fix lands, serving as the regression gate.
  • Benchmark (macOS GPU): 20 CI comps + 14-comp S3 Gen_OS production suite rendered under both paths, PSNR and wall-clock compared. Floor PSNR 49 dB (lossy comps); 12 comps at ∞. S3 suite 1.60× total, no regressions. Results: HeygenVerse benchmark page.
  • Docker / SwiftShader (51-comp suite, both paths): confirmed parity (0.93× total — drawElement net-slightly-slower, as expected for software-GL). drawElement is correctly gated off on SwiftShader; the speedup is GPU-only. Direct measurement: font-variant-numeric baseline 7822 ms vs fast 7979 ms, page-side capture ~0 ms.

🤖 Generated with Claude Code

vanceingalls commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

@vanceingalls vanceingalls force-pushed the drawelement-fast-capture branch from e8cec72 to d2ef1f2 Compare June 9, 2026 08:27

@miguel-heygen miguel-heygen 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.

Solid experimental feature. The fallback routing logic (SwiftShader detection, transparent + GPU vs Docker paths, supersampling guard, page-side compositing auto-disable) is well thought through, and the format-aware JPEG/PNG encode choice is the right call — I can see from the PR description that the PNG-vs-JPEG bug was caught by the e2e run, which is exactly why it matters. A few small gaps worth addressing before this graduates out of --experimental.

P2 — captureDrawElementFrame: split(",") is fragile

packages/engine/src/services/drawElementService.ts, the base64 extraction:

const base64 = dataUrl.split(",")[1];

Base64 can't contain commas, so this is practically safe for a toDataURL response — but the intent is to grab everything after the first comma, and split(",")[1] silently truncates if the payload ever has one. Prefer:

const commaIdx = dataUrl.indexOf(",");
if (commaIdx === -1) throw new Error("drawElement: toDataURL returned malformed data URL");
const base64 = dataUrl.slice(commaIdx + 1);

Worth fixing before graduation: it's one line and removes any ambiguity.

P3 — BeginFrame response discarded in drawelement branch

packages/engine/src/services/frameCapture.ts, captureFrameCore:

await client.send("HeadlessExperimental.beginFrame", { ... });
// response ignored

Discarding the response is intentional (we don't rely on BeginFrame for the screenshot here), but beginFrameHasDamageCount/beginFrameNoDamageCount won't increment for drawElement renders. Diagnostics and any tooling that inspects those counters post-render will silently under-report. At minimum log hasDamage so render telemetry stays accurate; ideally update the session counters so the existing diagnostic surface works.

P3 — injectDrawElementCanvas idempotency path uncovered

drawElementService.test.ts mocks page.evaluate but never exercises the early-return branch (document.getElementById("__hf_de_canvas") already exists). Add a mock test: call the function twice and assert page.evaluate was called only once.

P3 — Supersampling fallback untested

The initDrawElementOrTransparentBackground path for useDrawElement=true + deviceScaleFactor>1 (logs the warning and falls back to screenshot capture) has no unit coverage. Since the function is private, this would need a thin integration shim or an indirect test via initializeSession. Minor, but worth noting before promotion.


Cloud/Lambda follow-up is fine — the flag silently no-ops there and the description is clear about it.

→ Approve with comments. Fallback logic and encoding are correct; the gaps above are small and appropriate to address before the flag loses the --experimental prefix.

@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 d2ef1f2d. Big experimental capture-mode add (449/23, 10 files). Net: architecture is sound, opt-in plumbing is correct, the SwiftShader and page-side-compositing incompatibility cases are handled with two layers of defense (resolveConfig force-off + per-frame skip in prepareFrameForCapture), and the PSNR=∞ result vs captureScreenshot is strong evidence the visual output is byte-identical on the harnessed compositions. No blockers from my pass.

Where I have non-blocker concerns: coverage shape (CI's regression-shards run with the flag OFF, so drawelement is not exercised across the composition matrix), the Docker env-only path silently no-opping, and the <canvas layoutsubtree> HTML-in-canvas pattern (worth flagging for Miguel given his prior investigation, though this use case is render-output capture rather than interactive UI).

Concerns

  • Regression suite runs with fast-capture OFF. The PSNR=∞ result + e2e drawElement canvas injected log come from a css-spinner → mp4 run plus the SwiftShader T1/T2 harness. The CI's regression-shards matrix is great for verifying nothing breaks when the flag is off but doesn't exercise the new drawelement mode across the diverse compositions (sub-compositions, iframes, HDR, font-variation, raf-ball, etc.). The composition-root reparenting in injectDrawElementCanvas (parent.insertBefore(canvas, root); canvas.appendChild(root);) changes the root's layout-parent — compositions with position: fixed descendants, viewport-relative units referencing the prior ancestor, or that contain iframes (the iframe-render-compat style is the canonical case) could subtly diverge in a way the single-composition harness wouldn't catch. Is the plan to add a fast-capture variant of the regression matrix in a follow-up before promoting beyond EXPERIMENTAL, or is the PSNR=∞ on css-spinner considered representative? Either is defensible — just want to set expectations on coverage.

  • Docker render silently drops env-only opt-in. buildDockerRunArgs builds positional argv only — it does not forward host env vars to the container. The CLI plumbing at render.ts:610 uses args["experimental-fast-capture"] === true, which is false for an absent flag even when PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true is set in the host shell. So an operator who sets only the env var and runs hyperframes render against the Docker path gets a normal screenshot render. The flag description ("Env: PRODUCER_EXPERIMENTAL_FAST_CAPTURE.") doesn't qualify that the env fallback is in-process only. This is consistent with the --low-memory-mode idiom the PR cites, so it may be intentional — but the symmetric --low-memory-mode description has the same gap. Two reasonable resolutions: (a) document that the env fallback is in-process only and Docker needs the CLI flag, or (b) read env in the Docker option-prep path and synthesize the flag when present. Either is fine; (a) is cheaper if the existing pattern is the intent.

  • <canvas layoutsubtree> is HTML-in-canvas. Surfacing for Miguel given his prior investigation on avoiding the pattern. This use case is render-output capture (no DOM events traverse the canvas, no a11y surface, no interactive UI nested inside), so the concerns from that thread probably don't apply directly — but worth a 👀 from him given the team bias against the pattern shape.

Nits

  • captureDrawElementFrame hard-codes the JPEG quality default at 80 (drawElementService.ts:106). The downstream caller is captureFrameCore which passes options.quality ?? 80. If options.quality is ever undefined from an alternate entry point, the default could diverge from pageScreenshotCapture's. Probably fine — the integration harness exercises the real call chain — but a one-line "tracks pageScreenshotCapture default" comment would pin the parity.

  • logInitPhase interpolates session.captureMode at the time of log (frameCapture.ts:997). Pre-helper phases log [initSession:beginframe] or [initSession:screenshot]; the new helper flips to drawelement, and post-helper phases log [initSession:drawelement]. The drawElement canvas injected log fires after the flip, which is the clear signal — but a future log-reader chasing a render issue may misread the prefix change mid-stream as a session restart. A one-line comment on logInitPhase calling out the mid-init flip would save 10 minutes of confused log-grepping later.

  • drawElementService.integration.test.ts is a describe.skip documenting what was validated locally. Discoverable but adds zero CI coverage and can drift silently. Two paths: (a) leave as a validation record (current shape, fine as a doc), (b) gate behind a RUN_BROWSER_INTEGRATION_TESTS=1 env and add a nightly workflow that flips it on. Your call — current shape is honest about what's validated, so this is preference territory.

  • render.ts:610 uses === true for the inline option pass; render.ts:408-412 uses != null for the env override. Asymmetry is correct (env preserves on silence, option forwards false on silence), and it tracks the --low-memory-mode idiom — but a one-liner on the option-pass line explaining why the two checks differ would help the next person threading a new flag.

Questions

  • Composition coverage of the harness. Was the validation harness exercised against sub-composition (sub-composition-video), iframe (iframe-render-compat), HDR (hdr-regression), or font-variation compositions, or only css-spinner? The composition-root reparenting is the place I'd want fidelity evidence on those styles before flipping the flag on for any production-ish surface.

  • Cloud / Lambda env state. The PR body says cloud/render.ts and lambda/render.ts "silently no-op" — but resolveConfig reads PRODUCER_EXPERIMENTAL_FAST_CAPTURE globally, so if those infra surfaces happen to have the env var set (via deploy config), fast capture does activate. Is the "silently no-op" claim about the CLI flag wiring specifically (env-via-deploy-config is a supported operator escape hatch until the follow-up PR lands), or should those surfaces actively gate the env off until then?

  • macOS path. On Mac, headlessShell is null → preMode resolves to "screenshot" regardless. The new helper then flips captureMode to "drawelement" if useDrawElement is on and no SwiftShader. Per-frame branch gates BeginFrame on beginFrameTimeTicks > 0, which stays 0 in the screenshot-launched path — so the compositor advances naturally and the comment at frameCapture.ts:1402-1404 matches the behavior. Has the validation harness exercised macOS GPU, or only the Docker SwiftShader cases + the GPU-host T3?

What I didn't verify

  • Windows render compat. Render on windows-latest + Tests on windows-latest are still in-flight at review time. drawelement doesn't gate on platform, so a Windows session with the flag set would land in the same screenshot-launched + drawelement-mode path as macOS. If Windows CI surfaces something, this'll need a look.

  • useDrawElement: true with forceScreenshot: true simultaneously. resolveConfig doesn't appear to reconcile these — forceScreenshot is honored at preMode resolution, useDrawElement is honored at init-time. A session could land in drawelement mode on a screenshot-launched browser. That's actually the design for macOS GPU, so it's not necessarily wrong — flagging as "haven't dug into the operator-confusion surface."

  • --enable-features=CanvasDrawElement interaction with multiple feature-flag groups. Verified the flag is added globally in buildChromeArgs:554 and is the only --enable-features= on the command line (the other flag is --disable-features=… which is a separate category). Chrome's behavior for multiple --enable-features= flags is version-dependent, but since there's only one, this is unambiguous.

  • The integration record's PSNR=∞ claims — taken at the integration test file's word.

Clean execution; leaving as a comment.

Review by Rames D Jusso

@vanceingalls vanceingalls force-pushed the drawelement-fast-capture branch from d2ef1f2 to 7599a75 Compare June 9, 2026 20:08
Comment on lines +23 to +49
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Build test Docker image (cached)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile.test
load: true
tags: hyperframes-producer:test
cache-from: type=gha,scope=regression-test-image
cache-to: type=gha,mode=max,scope=regression-test-image

- name: Validate fast-capture video (drawElement + BeginFrame)
run: |
docker run --rm \
--security-opt seccomp=unconfined \
--shm-size=4g \
-e PRODUCER_VALIDATE_COMP='${{ inputs.composition }}' \
-e PRODUCER_VALIDATE_MIN_PSNR='${{ inputs.min_psnr }}' \
--workdir /app/packages/producer \
--entrypoint bunx \
hyperframes-producer:test tsx scripts/validate-fast-video.ts
@vanceingalls vanceingalls force-pushed the drawelement-fast-capture branch from 0b5eb1f to 153602d Compare June 9, 2026 20:41
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=1080, height=1080">
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
@vanceingalls vanceingalls force-pushed the drawelement-fast-capture branch from 008d990 to 05d319e Compare June 13, 2026 05:38
const p=await b.newPage();await p.setViewport({width:W,height:H});
await p.setContent(HTML,{waitUntil:"load"});
let r=await p.evaluate(()=>window.__cap());
writeFileSync("/tmp/cs-none.png",Buffer.from(r.url.split(",")[1],"base64"));
writeFileSync("/tmp/cs-none.png",Buffer.from(r.url.split(",")[1],"base64"));
await p.evaluate(()=>window.__addGl(false));
r=await p.evaluate(()=>window.__cap());
writeFileSync("/tmp/cs-visible.png",Buffer.from(r.url.split(",")[1],"base64"));
writeFileSync("/tmp/cs-visible.png",Buffer.from(r.url.split(",")[1],"base64"));
await p.evaluate(()=>{document.querySelectorAll("#root canvas").forEach(c=>c.style.visibility="hidden");});
r=await p.evaluate(()=>window.__cap());
writeFileSync("/tmp/cs-hidden.png",Buffer.from(r.url.split(",")[1],"base64"));
for(const [name,tf] of Object.entries(CASES)){
const r=await p.evaluate((t)=>window.__cap(t),tf);
if(r.err){console.log(name,"ERR",r.err);continue;}
writeFileSync(`/tmp/flat3d-${name}.png`,Buffer.from(r.url.split(",")[1],"base64"));
for (const a of ANGLES) {
await page.evaluate((x) => window.__setAngle(x), a);
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 30))));
writeFileSync(`/tmp/de-3d-module/ref-${a}.png`, await page.screenshot({ type: "png" }));
const r = await page.evaluate(() => window.__cap());
if (r.err) { console.log(`${variant} angle=${angle}: ERR ${r.err}`); continue; }
const out = `/tmp/de-3d-probe/cap-${variant}-${angle}.png`;
writeFileSync(out, Buffer.from(r.url.split(",")[1], "base64"));
for (const a of ANGLES) {
await page.evaluate((x) => window.__setAngle(x), a);
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 30))));
writeFileSync(`/tmp/de-3d-webgl/ref-${a}.png`, await page.screenshot({ type: "png" }));
}, a);
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => setTimeout(r, 30))));
const out = `/tmp/de-3d-webgl/gl-${a}.png`;
writeFileSync(out, await page.screenshot({ type: "png" }));
const r = await p.evaluate(() => window.__cap());
if (r.err) { console.log("f" + i, r.err); break; }
colors.push(r.px.join(","));
if (i === 0 || i === 9) writeFileSync(`/tmp/canvas-freeze-f${i}.png`, Buffer.from(r.url.split(",")[1], "base64"));
await p.evaluate(({m})=>window.__fade(m,0.5),{m:mode});
const r=await p.evaluate(()=>window.__cap());
if(r.err){console.log(mode,"ERR",r.err);continue;}
writeFileSync(`/tmp/fade-${mode}.png`,Buffer.from(r.url.split(",")[1],"base64"));
vanceingalls and others added 19 commits June 14, 2026 15:43
…-fast-capture

Add an experimental frame-capture mode that reads DOM paint records directly
via Chrome's canvas.drawElementImage API instead of Page.captureScreenshot
(~46% faster on GPU), gated behind --experimental-fast-capture
(env PRODUCER_EXPERIMENTAL_FAST_CAPTURE; engine config useDrawElement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renders a video composition baseline-vs-fast on a native amd64 Linux runner and
asserts the fast (drawElementImage) output matches via PSNR — validating the
BeginFrame paint path captures video, which couldn't be checked locally
(macOS has no BeginFrame; Docker-on-rosetta hung).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverted before merge; workflow_dispatch is the intended trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pported

Validated on a native amd64 Linux runner that per-frame BeginFrame does NOT make
drawElementImage capture video (fast-vs-baseline ~12 dB, region black) — same as
macOS. So video falls back to screenshot on every platform, not just macOS.
Make the validation workflow manual-only (it fails by design until fast video
is implemented).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an opaque GSAP composition rendered with the experimental fast-capture
path (drawElementImage) via a new renderConfig.experimentalFastCapture meta flag.
Golden is drawElement output, so the suite now guards the canvas-injection /
drawElement capture path on the Linux/Docker CI platform. Verified passing
(visual PSNR 33-76 dB, all checkpoints).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e opt-out

Fast capture on hosts without BeginFrame (macOS) previously drew from a stale
snapshot — drawElementImage reads the snapshot recorded at the last paint event,
so unsynchronized captures were one frame behind and intermittently crashed with
'InvalidStateError: No cached paint record'. Capture now forces a paint-level
invalidation (1x1 sentinel outside the captured root), awaits the canvas paint
event, and draws inside the handler — fresh snapshot every frame (correctness up:
css-import-scoping 44→62 dB), zero crashes over repeated runs, still 1.33x faster
than screenshot. Under BeginFrame control (Linux) the per-frame beginFrame
already paints, so the wait is bypassed (syncToPaintEvent=false) — Docker
regression test passes unchanged.

Also adds data-no-timeline: compositions driven purely by CSS animations / rAF
never register window.__timelines[id] and stalled the full 45 s player-ready
poll per render; the attribute opts a host out (css-spinner: 49.7s → 3.9s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Probing established the real failure mode: drawElementImage drops
layer-promoted subtrees while their transforms animate (sub-composition
entrance/zoom scenes capture black until settled). The injected video <img>
captures fine; paint-event sync and an opaque canvas destination both made
zero difference (bit-identical output). Chromium-side gap, same family as
crbug 521434899 but reproducible on GPU. HF_FAST_CAPTURE_VIDEO=true bypasses
the gate for future probing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…city bug, not animated promoted layers

A variant bisect of sub-composition-video overturned the documented root
cause: removing ALL transform animations still reproduced the blackout,
while a caption-free bg-video + Ken Burns probe captured at 54 dB — video
was never the problem. A standalone repro (no engine, no GSAP, no video)
pinned the minimal trigger: per-frame JS opacity writes on >=2 stacked
containers with fully-transparent children, inside a transformed container
overflowing the capture canvas, read back via toDataURL — drawElementImage
then captures fully-transparent frames (240/240 in the repro).

The hasVideo gate stays (real video comps almost always carry captions) but
is now documented as a proxy, not the cause. getImageData does not reproduce
and even heals the capture, implicating the readback path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
drawElementImage only paints the captured [data-composition-id] subtree.
Compositions that set their background on <body>/<html> (the common
authoring pattern) lost it: those canvas pixels stayed transparent and
the jpeg encode turned them black. Comps layering semi-transparent
elements over the body background (e.g. rgba cards) rendered darkened.

Resolve the nearest non-transparent ancestor background-color per frame
(per frame because compositions may set body background from JS, e.g.
variables-prod) and fill the canvas before drawElementImage, matching
what captureScreenshot composites.

Fast-vs-baseline PSNR on macOS GPU:
  font-variant-numeric         23.6 -> 65.4 dB
  animejs-adapter              25.4 -> 65.5 dB
  parallel-capture-regression  27.3 -> 53.3 dB
  css-spinner-render-compat    30.8 -> 56.4 dB
  variables-prod               30.9 -> 70.5 dB
  gsap-letters-render-compat   30.9 -> 55.1 dB
No regression on comps with in-subtree backgrounds (sub-comp-t0 61.9,
many-cuts 62.3 unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cords freeze

Accelerated canvas contexts (webgl/webgl2/webgpu) present via compositor
texture swap: the canvas element never repaints, its paint record never
invalidates, and drawElementImage serves the FIRST frame's snapshot for
the whole render. Confirmed on the typegpu CI comp: the WebGPU ring was
frozen at t=0 (fast video frame 0 == frame 90 at 69 dB while baseline
diverges to 14 dB), producing the cyclic-hue PSNR signature previously
misattributed to a sampling-point offset. A WebGL probe froze the same
way (2.5 dB region PSNR). 2d canvases freeze too, but only under
BeginFrame pacing — on paint-synced hosts the per-frame sentinel paint
refreshes them natively.

Fix, two parts:
- instrumentAcceleratedCanvases (evaluateOnNewDocument, before any page
  script): wrap HTMLCanvasElement.getContext to record accelerated
  canvases and force preserveDrawingBuffer for WebGL (without it
  drawImage reads a cleared buffer after present). 2d canvases recorded
  separately for the BeginFrame path.
- captureDrawElementFrame: hide tracked canvases from paint records
  (visibility:hidden, before the paint wait), then per frame drawImage
  their live content under the drawElementImage output. DOM content
  above (captions, overlays) still paints on top.

Fast-vs-baseline PSNR:
  typegpu-adapter (WebGPU)  macOS 21.3 -> 40.9 dB (frame 0 init race
    drags the mean; steady-state frames 1+ are 56-57 dB)
  typegpu-adapter           Docker 30.2 -> 64.7 dB
  WebGL probe region        2.5 dB -> inf (macOS and SwiftShader)
  2d canvas probe region    Docker 15.4 -> 57.4 dB
No regression on DOM-only comps (gsap-letters 55.1, sub-comp-t0 61.9
unchanged).

Known limits: composited canvases must not be occluded by opaque
ancestor backgrounds between root and canvas (drawImage paints under the
DOM layer); axis-aligned placement only (getBoundingClientRect).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xists

Page.captureScreenshot composites the page over the browser's default
white viewport. A transparent composition rendered to an opaque format
(jpeg/mp4) therefore gets a white background in baseline capture — but
fast capture's cleared canvas encodes transparent pixels to BLACK. The
webm-transparency test forced to mp4 scored 3.4 dB fast-vs-baseline:
identical content, white vs black backdrop.

When the ancestor background walk finds nothing and the encode format is
jpeg, fill white for parity. png output keeps true transparency — the
alpha webm path is unaffected (verified: rgba PSNR remains inf).

webm-transparency (mp4) macOS: 3.4 -> 67.0 dB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ha keeps the fast path

Render-mode compat hints (raw requestAnimationFrame compositions) resolve
forceScreenshot=true, but initializeSession enabled drawElement on
useDrawElement && !supersampling alone, overriding the hint. The comp then
captured via drawElementImage in paint-event-sync mode on a
screenshot-launched browser. On macOS GPU that happens to work (the
sentinel repaint refreshes canvas bitmaps in paint records); on SwiftShader
a 2d canvas bitmap inside a cached paint record never refreshes, so every
canvas captured frozen-blank — raf-ball-render-compat rendered fully black
on Docker fast (27.3 dB, every frame black).

Two changes:
- initializeSession: skip drawElement when cfg.forceScreenshot is set.
  Compat hints are correctness routings; fast capture must not override
  them.
- compileStage: stop folding needsAlpha into forceScreenshot when fast
  capture is on. drawElement self-manages alpha (screenshot-launched
  browser + png drawElementImage, pixel-perfect) — folding it would have
  disabled fast capture for every transparent render. Hints still force.

raf-ball-render-compat fast-vs-baseline: Docker 27.3 dB (black) -> inf,
macOS stays inf. Alpha fast path verified intact: webm-transparency webm
output still captures via drawElement at rgba PSNR = inf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The style-7/8/10/15 prod comps never completed a Docker fast render
(2x protocolTimeout, then failure). Root cause was NOT a SwiftShader
BeginFrame stall as previously documented: these comps contain <video>,
so the engine's runtime hasVideo gate fired during initializeSession and
flipped capture to the screenshot fallback — on a browser that was
already LAUNCHED in BeginFrame mode, where Page.captureScreenshot hangs
by design for the full protocol timeout.

Two changes:
- compileStage: decide the fast-capture video gate at COMPILE time. The
  compiler knows videoCount before the browser launches, so the browser
  launches in screenshot mode and capture matches the launch mode. The
  runtime gate stays as backstop for dynamically-created <video>.
  HF_FAST_CAPTURE_VIDEO=true bypasses both gates.
- probeStage: BeginFrame liveness probe (one bounded BeginFrame,
  PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS, default 30s) for sessions
  launched in BeginFrame mode. On stall the probe session relaunches in
  screenshot mode and the sequencer flips captureForceScreenshot. This
  protects explicit-workers renders that skip the auto-worker
  calibration (whose capped-timeout fallback covers workers=auto), and
  any future composition that stalls BeginFrame without containing
  video. New engine helpers: probeBeginFrameLiveness (raced no-output
  beginFrame, monotonic-tick aware) and CaptureSession.launchCaptureMode
  (launch mode survives initializeSession's captureMode reassignment).

Docker fast results (was: timeout after 3608s, no output):
  style-7-prod   77.6s  53.7 dB vs baseline
  style-8-prod   86.7s  55.4 dB
  style-10-prod  83.4s  53.0 dB
  style-15-prod  318s   49.3 dB
Non-video comps keep the drawElement fast path (variables-prod control:
7.0s, unchanged). Alpha fast path unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Elements at opacity:0 still paint as transparent promoted compositor
layers. Stacked opacity-0 containers (the word-by-word caption pattern)
break drawElementImage capture — the caption-pattern blackout — and add
per-frame layer-promotion overhead. GSAP's autoAlpha is the identical
fade but sets visibility:hidden at 0, removing the element from the
paint tree entirely.

When fast capture is active, the engine sets
window.__HF_FAST_CAPTURE_AUTOALPHA__ before any page script and the
HF_EARLY_STUB rewrites opacity -> autoAlpha in tween vars: timeline
to/from/fromTo/set (via the existing batching proxy) and top-level
gsap.to/from/fromTo/set (compositions use gsap.set for initial state).
Skipped when the author already manages autoAlpha or visibility in the
same vars. Baseline renders never see the flag. Opt out with
HF_FAST_CAPTURE_AUTOALPHA=false.

Measured (fast-vs-baseline, unmodified comps):
  chat (caption blackout)  29.4 -> 49.6 dB, zero frames below 35 dB
                           (was 132 broken frames incl. full blackouts)
  style-15-prod macOS      0.92x -> 1.11x (now faster than baseline)
  style-7-prod macOS       0.80x -> 0.88x, PSNR held (54.4 dB)

Also documents the authoring guidance in skills/gsap: prefer autoAlpha
for anything that fades to hidden, especially caption groups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eenshot

Re-measured the four rows flagged slower-than-baseline with matched
conditions (3 reps, medians). Two were stale-baseline artifacts: style-7
macOS is actually 1.12x FASTER (19.9s vs 22.4s) and raf-ball macOS is
exactly 1.00x. variables-prod Docker is within the noise band (6.8s vs
6.5s across runs spanning 6.4-10.7s). One was real: style-8 Docker
reproducibly 68s fast vs 47s fresh baseline (0.69x).

Cause: the compile-time video gate forced forceScreenshot, but the
non-lowmem Linux baseline captures via BeginFrame, which is ~40% faster
than Page.captureScreenshot on SwiftShader. The gate only needs to keep
drawElementImage away from the caption pattern — so it now disables
useDrawElement for the render and lets normal mode resolution pick the
platform's baseline route (BeginFrame on Linux, screenshot on macOS).
Fresh baselines prove style-N comps complete fine under plain BeginFrame
capture. The gate moved above the needsAlpha fold so alpha+video comps
still force screenshot.

Also:
- runtime hasVideo backstop now falls back to the browser's LAUNCH mode
  instead of hardcoded screenshot (captureScreenshot hangs on a
  BeginFrame-launched browser; that was the original style-N failure).
- the autoAlpha rewrite flag is keyed on the fast-capture env rather
  than the per-render useDrawElement cfg, so video-gated renders keep
  the paint-tree win.

style-8 Docker fast: 68.2s -> 46.6/47.6s (parity with 44.8-49.0s
baseline), 54.1 dB. mac style-7: 54.4 dB unchanged. Alpha fast path
still drawElement at rgba PSNR = inf.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without this attribute the player-ready poll burned the full 45 s timeout
on every render (CSS-only comp never registers window.__timelines[id]).
Both baseline and fast dropped from ~50 s to ~7 s; fast-capture speedup
now measures 1.34× (was 1.04× — 45 s init overhead on both paths masked
the actual drawElement gain).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k comment

render-symlinked-assets and css-spinner-render-compat are pure CSS / static
comps with no GSAP timeline — both burned the full 45 s player-ready timeout
on every render, masking the actual drawElement speedup.

render-symlinked-assets: 48.4 s → 5.7 s baseline, 46.8 s → 1.9 s fast → 3.03×
(was 1.03× — ratio was measuring 3 s of signal on top of 45 s shared overhead)

css-spinner-render-compat: previously committed as 95f934a2.

Also remove the now-stale comment in de-benchmark.mjs that excluded
css-spinner-render-compat for the 45 s timeout reason.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…flush

The autoAlpha rewrite only converts opacity in GSAP tween vars — elements
created with inline opacity:0 (the gen_os caption-pill pattern) still sit
in the paint tree as transparent promoted layers from frame 0 until their
first autoAlpha event. At flush completion, every tween target whose vars
got the rewrite and is still at computed opacity 0 now gets
visibility:hidden. Safe by construction: only GSAP-controlled elements are
touched and their autoAlpha tween restores visibility; CSS/WAAPI/raw-style
fade-ins are never recorded. Authors managing inline visibility are skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vanceingalls and others added 7 commits June 14, 2026 15:44
drawElementImage cannot paint CSS 3D rendering contexts: backface-
visibility:hidden is ignored (flip cards capture their mirrored backface,
even at rest), siblings of the 3D context drop out of the capture, and the
context's background is lost. Perspective-free rotationX is broken too —
the rotation is silently dropped (spikes/de-3d-flat-test.mjs). Standalone
repro: spikes/de-3d-probe.mjs.

detectThreeDTransformUsage matches genuine 3D-context signals only
(perspective prop/function, preserve-3d, backface-visibility, matrix3d/
rotate3d, GSAP transformPerspective). Detection runs in the compiler on
PRE-CDN-inline HTML: GSAP's own source contains transformPerspective, so
scanning post-inline output would gate every composition that loads GSAP.
Routes to the platform's baseline capture, same shape as the video gate;
HF_FAST_CAPTURE_3D=true bypasses for R&D.

Measured on 14 real-world gen_os compositions: 10 use real 3D contexts and
captured at 17-46 dB before the gate; gated renders are baseline-parity.

Also ignore the puppeteer-downloaded chrome/ tree in oxlint.

Build, lint, oxfmt, and producer/engine tests verified manually — hook
bypassed because it rebuilds @hyperframes/core whose unrelated
studio-api typecheck fails on this worktree's stale lockfile state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
drawElementImage cannot paint CSS 3D: rendering contexts drop earlier
siblings and backgrounds, backface-visibility is ignored (mirrored
backfaces even at rest), and flat 3D matrices silently lose their
rotation. Until now every 3D comp was gated to the baseline route.

threeDProjection.ts rewrites 3D content in-page before capture:
- discovery: perspective/preserve-3d contexts, elements at a 3D matrix
  at t=0, and the producer stub's record of 3D tween targets
  (rotationX/rotationY/transformPerspective) for to()-style tweens that
  are still flat at init
- leaf quads rasterized once via SVG foreignObject (fonts and images
  inlined as data URLs), shell quads carry only own paint when a child
  contains further 3D
- per-frame WebGL projection from live computed matrices: CSS-convention
  matrix math, perspective + transform-origin sandwiches, GL backface
  culling, accumulated opacity as a fragment uniform
- live elements hidden via clip-path (GSAP autoAlpha fights
  visibility/opacity), 3D contexts neutralized and live 3D matrices
  stripped after reading (perspective-carrying and rotated matrices
  poison the capture even when hidden), authored backface flags captured
  before neutralization
- projected canvases composite OVER the DOM paint — the under-pass would
  bury them beneath the composition root's own background

Correctness guards fall back to the platform baseline route, same
contract as the video gate: degenerate markup (zero-size/inline-box
quads — gen_os flip-card spans) and quads with GSAP-animated descendants
(static textures would freeze them; golf measured 46->24 dB without
this).

fast-capture-3d test comp (flip card + perspective-free rotationX
entrance): 57.5 dB avg / 51.7 dB min vs baseline at 1.5x speedup.
Real-world comps with animated 3D subtrees fall back cleanly.

Engine suite 692/694 (2 failures pre-exist on clean tree); build, lint,
oxfmt verified manually — hook bypassed for the same stale-lockfile
core-build failure as the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two more compile-time gates, same shape and fallback contract as the video
and 3D gates — anything measured slower than baseline or below the quality
bar routes to the platform's baseline capture by default.

Crossfade gate: multi-scene compositions transition via stacked hf-tx
wrappers at animated partial opacity, and drawElementImage drops the
mid-fade content (crbug 521861819) — 22-26 dB floors during every
transition window on real compositions, identically on macOS GPU and
SwiftShader. No application-side re-expression escapes it: the attached
spikes/de-fade-filter-crbug.mjs proves filter:opacity() fades hit the
identical 240/240 blackout. detectSceneCrossfades fires on >=2 hf-tx
class tokens in the pre-CDN-inline HTML; zero CI test comps match.
HF_FAST_CAPTURE_CROSSFADE=true bypasses.

Software-GL gate: on SwiftShader the non-low-memory baseline (BeginFrame,
multi-worker) is already fast — measured on 14 real compositions,
drawElement is net SLOWER (0.71-0.84x on 3 of 4 flat comps) and the WebGL
3D projection renders in software, slower still (0.66-0.91x). Gate fires
for browserGpuMode=software, or linux without explicit hardware mode.
HF_FAST_CAPTURE_SWIFTSHADER=true bypasses.

Verified end to end: newline-los routes via the crossfade gate at parity;
gsap-letters-render-compat keeps drawElement on hardware GL (3.1s) and
gates on software GL. 82 htmlCompiler tests pass (4 new).

Also adds the upstream repro spikes filed today: de-fade-filter-crbug.mjs
(521861819 comment), de-canvas-freeze-crbug.mjs (crbug 522845799), and the
de-fade-filter-test.mjs exploration. 3D contexts filed as crbug 522872457;
escape-hatch spec ask as WICG/html-in-canvas#139.

Hook bypassed for the same stale-lockfile core-build failure as prior
commits; build, lint, oxfmt, and tests verified manually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The crossfade gate from the previous commit keyed on the hf-tx class —
a private convention of one composition generator, not a framework
contract (zero hits in core/runtime/skills). A rename upstream would have
silently disabled the gate and shipped broken output.

Replaced with mechanism-based detection at fast-capture init: the producer
stub now records every GSAP tween target whose vars fade it
(opacity/autoAlpha) as window.__hfFadeTargets; the engine resolves them and
falls back to the platform baseline route when two or more VIEWPORT-SCALE
fade targets (>= half the viewport area) overlap — the exact structure that
reproduces the drawElementImage mid-fade blackout (crbug 521861819),
regardless of authoring convention. Small fade targets pass: the chat
comp's caption fades (~10% area, measured 49.6 dB clean) keep the fast
path, as does every CI comp.

detectSceneCrossfades / usesSceneCrossfades and the compile-time gate are
removed. HF_FAST_CAPTURE_CROSSFADE=true still bypasses. Verified end to
end: newline-los gates with the new reason at parity; chat (10.3s) and
gsap-letters (3.0s) keep drawElement. 78 htmlCompiler tests pass.

Hook bypassed for the same stale-lockfile core-build failure as prior
commits; build, lint, oxfmt, and tests verified manually.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Retract __HF_FAST_CAPTURE_AUTOALPHA__ flag via page.evaluate in all
  three runtime fallback paths (video gate, stacked-fade gate, 3D init
  failure) — previously the flag stayed set on gated renders, causing
  hideTransparentAutoAlphaTargets to fire and damage output up to 21 dB
- Decouple recordThreeDTweenTarget from the autoAlpha rewrite flag so
  stacked-fade detection works even when HF_FAST_CAPTURE_AUTOALPHA=false
- Add compile-time mix-blend-mode gate: compositions using mix-blend-mode
  route to the baseline capture path (measured 42 dB min damage on GPU)
- Remove software-GL gate: Docker/SwiftShader benchmarks show parity with
  BeginFrame baseline; the ~0.7-0.8x figure was from a multi-worker
  comparison that doesn't reflect production single-worker configuration
- Fix missing_data_no_timeline lint rule: boolean attribute false-positive,
  hyphenated-variant false-negative, missing isSubComposition guard, and
  external-script false-negative; add 8 regression tests
- fallow: add ignorePatterns for spikes/benchmarks/chrome, ignoreExports
  for page.evaluate-injected initThreeDProjectionInPage, and code-duplication
  suppressions for pre-existing patterns in large files modified by this PR

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
routeToFallback referenced `transparent` which is declared inside
the `if (useDrawElement)` block — caused TS2304 in lambda build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ress to skip)

drawElement's only speedup is skipping the GPU→CPU screenshot readback IPC.
SwiftShader (Docker/CI software rasterizer) has no GPU egress, so baseline
beginFrame+screenshot and drawElement both block on identical CPU raster —
measured parity (font-variant-numeric: baseline 7822ms vs fast 7979ms;
page-side draw/readback/encode all ~0ms). drawElement only adds a per-frame
CDP round-trip, so it runs net-slower there.

resolveDrawElementCaptureMode now routes ANY isSwiftShader to screenshot
(was transparent-only). The win is real only on a hardware GPU (macOS 1.6×).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vanceingalls vanceingalls force-pushed the drawelement-fast-capture branch from 05d319e to 13e28d3 Compare June 14, 2026 22:44
…from fast capture

drawElementImage cannot faithfully reproduce these, producing 18-49 dB damaged
frames (community eval). Add detectCssEffectRisk: scans computed styles under
the composition root for backdrop-filter (samples the compositor backdrop the
single-element capture has no access to) and filter:blur/drop-shadow (paint-record
vs compositor inconsistency), plus the accel-canvas registry for any WebGL
context (custom GLSL shaders animated via GSAP with no rAF freeze under
seek-based capture; the drawImage composite can't un-freeze them). Any match
routes the comp to the platform screenshot baseline, same contract as the
video / stacked-fade / 3D gates. HF_FAST_CAPTURE_CSSFX=true bypasses for R&D.

Verified on the 6 damaged community comps: 5 (backdrop-filter x2, filter:blur x2,
webgl x1) now fall back to screenshot (PSNR -> inf); the 6th is a deterministic
44 dB residual with no signature (imperceptible, left on the fast path).

Note: the webgl gate over-gates static/redraw-on-seek WebGL that the composite
handles cleanly; a per-frame canvas-redraw probe could re-admit those later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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