Skip to content

feat(core): add vfx.capture() - #273

Open
fand wants to merge 17 commits into
mainfrom
claude/tender-goldberg-1z1nd2
Open

fand wants to merge 17 commits into
mainfrom
claude/tender-goldberg-1z1nd2

Conversation

@fand

@fand fand commented Nov 14, 2026 •

Copy link
Copy Markdown
Owner

Closes #266.

Summary

vfx.capture(el, opts) captures an element as a texture without drawing it, so any effect of the same VFX instance can read it.

const page = await vfx.capture(article, { hide: true });

page.texture; // EffectTexture, stays valid while the GL texture behind it is replaced
page.rect;    // where the element is, and the size the texture was captured at
await page.update();
page.dispose();

await vfx.add(minimap, { effect, uniforms: { page: page.texture } });

The spec and the plan are in docs/tasks/capture/.

What it does

  • Sources. <img> (uploaded once), <video> and <canvas> (every frame), and any HTML: html-in-canvas when supported, or one DOM snapshot as fallback.
  • hide. false (default) keeps the element visible; with html-in-canvas the wrapper canvas keeps showing the latest capture. true hides it; it keeps its layout and stays interactive.
  • texture. A stable EffectTexture, premultiplied like ctx.src. After dispose() it samples as transparent and warns once.
  • rect. Viewport CSS px. x / y are measured once per rendered frame; width / height are the size when the current texture was captured, so the texture is not stretched for a frame after a resize.
  • uniforms. VFXUniformValue gains EffectTexture, so capture.texture reaches effects through ctx.uniforms.

Rules

  • One capture per element. capture() rejects for an element that addHTML or a transition wraps (or is wrapping), and addHTML rejects for a captured element.
  • Effect path only. With shader, a texture uniform is ignored with one warning. Shader-path support waits for Shader path: make element shaders premultiplied alpha #271.
  • A post effect cannot take a capture through its uniforms, since those are fixed in the VFX constructor. Set the capture on the effect after await vfx.capture().
  • Drawing a texture from another VFX instance now throws.
  • With reduced motion, capture() resolves to a handle that leaves the element alone and samples as transparent.

Differences from the issue

  • rect is not a pure "rect at capture time": a viewport rect goes stale on scroll, and scrolling does not re-capture. Position follows the frame, size follows the texture.
  • uniforms accepts capture.texture only, not the VFXCapture object.
  • A second capture() of the same element rejects instead of returning the same handle, so no ref counting is needed.

Changes

  • capture.ts (new): one driver per source kind, the handle, and the per-frame tick. Dependencies are injected, so it is tested without WebGL.
  • html-in-canvas.ts: keepDrawn on setupCapture, for hide: false.
  • vfx-player.ts / vfx.ts: addCapture, VFX.capture, tick in render(), re-snapshot on window resize, conflict checks, cleanup in destroy().
  • effect-host.ts: the cross-instance check.
  • gl/program.ts: uploadUniforms skips texture handles on the shader path, with one warning.
  • Stories: Capture/Minimap, Capture/HiddenSource, Capture/Sources.
  • Docs: skill, docs/html-in-canvas.md, the custom effects page, doc comments, changeset.

Testing

  • pnpm run build, pnpm run lint, pnpm test and pnpm --filter docs test pass (core: 516 tests).
  • Checked in Chromium 153 (headless, swiftshader), with html-in-canvas on and off:
    • A captured element is drawn by an effect on another element, with the right orientation; dispose() restores the DOM.
    • The three stories draw. Minimap follows scroll and content changes; HiddenSource lines up with the element under the default scroll padding, and its link and input still work.
    • With hide: false, a resize shows no blank frame and the still copy is gone when capture() resolves.
    • Captures come back after a WebGL context loss.
    • Paint timing: in one frame the order is rAF, ResizeObserver, paint, so effects see a re-capture from the next frame.

Not covered

  • Chrome 159+ (content="drawable") was not available for the browser checks.
  • The VFXPlayer wiring (tick, resize, destroy order) has no unit tests; it is covered by the browser checks only.
  • The demo from the issue was not ported.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
Leaves later captures drawn on the wrapper canvas. vfx.capture() uses it
for hide: false.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
createCapture() builds the VFXCapture handle: a stable texture, the
per-frame rect, update() and dispose(). Not wired into VFX yet.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
Adds the hic and snapshot drivers to createCapture(), and resize() for
snapshots that go stale on a window resize.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
Captures an element as a shared EffectTexture without drawing it.
A texture from another VFX instance now throws when it is drawn.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
A texture such as capture.texture reaches effects through ctx.uniforms.
The shader path ignores it with one warning.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
Minimap (hide: false, rect), HiddenSource (hide: true, post effect) and
Sources (image, video, canvas).

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
… the still copy is gone

- A snapshot update() during one in flight queues one more, so later DOM
  changes are captured.
- With hide: false, capture() waits one frame so the still copy with
  duplicate ids is removed before it resolves.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
Skill, html-in-canvas notes, the custom effects page, doc comments and
a changeset.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
The stories no longer turn scroll padding off, and the skill shows the
rect math instead of calling it a limit.

Refs #266

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

fand and others added 2 commits November 14, 2026 15:23
- Checked texture uniforms once in Program.uploadUniforms instead of at 9 call sites; removed shader-uniform.ts
- Shared one premultiplied texture helper for captures and live textures; dropped setters for default wrap/filter
- Let the player dispose captures on destroy; VFX now tracks captured elements in a Set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Shader path: a null uniform passes through again instead of throwing
  and stopping the render loop (regression from the texture filter).
- capture() rejects an HTML element with 0 width or height, which would
  never paint, and a video that fails to load.
- vfx.destroy() aborts a pending capture and restores the element.
- hide applies only once the first capture is in.
- add and capture exclude each other on one element when both would set
  its opacity.
- texture.update() reports failures instead of leaving an unhandled
  rejection.
- Snapshot rect size matches the captured (ceiled) size.

Refs #266

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvSd5aqZjujqpkwJtpxSVf

This branch has not been deployed

No deployments
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.

Proposal: vfx.capture() — capture an element as a shared EffectTexture without drawing it

2 participants