Skip to content

feat(registry): add filmed-screen component - #5487

Merged
vanceingalls merged 7 commits into
mainfrom
feat/registry-filmed-screen
Oct 12, 2026
Merged

vanceingalls merged 7 commits into
mainfrom
feat/registry-filmed-screen

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

What

A new registry component, filmed-screen. It films a live-DOM scene the way a phone camera films a monitor:

  • a tilted screen plane in perspective, with a slow handheld drift and a push-in per shot;
  • depth of field that follows an element you name (36-tap golden-angle bokeh, highlights weighted);
  • chromatic fringing toward the edges, LED sub-pixels, bloom, vignette, grain;
  • a 5-colour palette per shot (or true colour), with an optional hue-preserving invert so light pages read as dark screens;
  • shots that glide into each other (camera, grade and colour mode ease over glide seconds), or cut with a horizontal tear when a shot asks for transition: "cut";
  • focus that racks instead of snapping: the focus box is a Hann-weighted average of where it was over the last rack seconds, so a new target eases in on an S-curve. It is a pure function of time, so any seek order renders the same frame.

The element in focus stays legible: inside its box the pass turns off blur, LED-cell snapping and fringing, and the camera look fades back in over ~160 px around it. There are no overlays; the only thing on screen is the author's scene.

The shader is a port of the virtual-camera pass in ff-tracking (lens.sksl, built on fframes), with one change to its maths: the bokeh average weights each colour channel by its own sample. The original weights all three by the green sample, which tints blurred texture green; its thermal palette hides that, a grey one shows it.

The demo

Three scenes in the style of other compositions, a code editor typing a line, an analytics card counting up with a bar chart, and a chat thread, cross-fade inside one filmed screen. Each gets its own shot and grade (amber, true colour, night blue); the camera glides between them and racks focus to whatever just changed.

How it plugs in

Paste-in snippet, like motion-blur and grain-overlay. The author puts their scene inside a static <canvas layoutsubtree> and lists shots:

HFFilmedScreen.mount({
  canvas: ".hf-filmed-screen",
  shots: [
    { until: 3, palette: [...5 hex], tilt: [0.16, -0.22, 0.03], zoom: [1.0, 1.2], blur: 1.0,
      focus: (t) => document.getElementById(t < 1.4 ? "title" : "sub") },
    { until: 6, palette: null, focus: (t, local) => tiles[Math.min(2, local | 0)] },
  ],
});

Each frame it reads the focus element's box from the live DOM in __hf_page_composite_prepare, then captures with drawElementImage and shades in __hf_page_composite_resolve, the same handshake data-vfx-chain uses, composed with any prior owner. Shot times come from the owning composition's timeline, so it works inside a data-composition-src sub-composition. A plain canvas (p5, three.js) can be passed instead and is sampled directly.

Why not a <template data-slot> primitive like ascii-render-pass: the point is the live DOM. Focus follows elements as they animate and video plays through it, which a once-rasterized slot cannot do.

Verified (CLI 0.8.147, renderer Chrome 152)

  • node scripts/lint-registry-items.mjs filmed-screen: 0 errors.
  • bun run check:catalog-drift: passes; the catalog page generates (/catalog/components/filmed-screen). Generated files are not committed.
  • The demo, as a composition: check passes (0 layout issues, 27/27 text checks WCAG AA), snapshot and full render correct, screenshot capture.
  • Inside a sub-composition starting at 1 s: snapshot and render correct; frames match the root render shifted by 1 s.
  • Without HTML-in-Canvas (drawElementImage removed): the scene is moved back into the page and renders unfilmed, with one console warning.
  • Earlier versions of the same pass, rendered on three existing projects (DOM with <video>, a light editorial page, a p5 canvas), were deterministic: 180/180 byte-identical frames across two renders.
  • Not verified: hyperframes add filmed-screen end to end, since add reads from main.

Things found while building it, worth knowing outside this component

  • Inside a sub-composition, window is a Proxy that returns functions bound. The compiler runs sub-composition scripts with a scoped window, and reading window.__hf_page_composite_prepare back through it never equals the function you assigned. An "is this still my hook?" check therefore re-wraps on every seek, and a wrapper that reads a shared prior variable ends up calling itself: Maximum call stack size exceeded in snapshot/render, while preview is fine. This component installs engine hooks on globalThis. Anything else that owns the composite protocol from a sub-composition is exposed to the same thing.
  • __hf.seek is an accessor that wraps whatever is assigned, so a marker property on a wrapper never survives; a hooker that polls for the seek functions must track what it hooked separately.
  • check reports filmed text as occluded. The layout audit honours data-layout-allow-occlusion on the text element only (the contrast audit uses closest), and text inside a canvas[layoutsubtree] is never painted directly. The component stamps the attribute on the scene's elements at mount; a follow-up could have the audits skip canvas[layoutsubtree] descendants instead, which would also cover data-vfx-chain scenes.

Costs and limits

  • Compositions that declare a layoutsubtree canvas render with screenshot capture on one worker, so this is slower than drawElement capture.
  • .hf-filmed-screen-scene needs an explicit pixel size; inset: 0 inside a layoutsubtree canvas measures 0×0.
  • Elements added to the scene after mount are not stamped for the occlusion audit.

🤖 Generated with Claude Code

vanceingalls and others added 2 commits October 11, 2026 23:21
Films a live-DOM scene like a camera pointed at a monitor: a tilted screen
plane in perspective, depth of field that follows a named element, chromatic
fringing, LED sub-pixels, per-shot palettes and cut tears. The focused element
stays sharp. Ported from ff-tracking's lens.sksl, with each colour channel
weighted by its own sample in the bokeh average.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The catalog poster is captured at min(3 s, 0.6 × duration), which was the
demo's first cut, so it showed a tear over a blurred title. The first cut
moves to 3.8 s and the first shot racks back to the title before it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Catalog preview media is already on the CDN, so the docs page has its poster and video once the catalog publish PR lands:

Both were made with scripts/generate-catalog-previews.ts --only filmed-screen from this branch's demo.html, uploaded with the same cache headers as upload-docs-images.sh, and invalidated on just those two paths.

The second commit moves the demo's first cut from 3 s to 3.8 s, because the poster is captured at min(3 s, 0.6 × duration) and the first version landed on the cut tear. The re-run Render catalog previews artifact now shows the title in focus.

Shots now glide into each other by default: camera, palette and colour mode
ease over `glide` seconds, and `transition: "cut"` keeps the hard cut with its
tear. Focus racks instead of snapping: the focus box is a Hann-weighted
average of where it was over the last `rack` seconds, so a new target eases in
on an S-curve and every seek order still renders the same frame.

The demo now films three scenes in the style of other compositions, a code
editor, an analytics card and a chat thread, one shot and grade each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Preview media refreshed for the new demo at 205d83180: the poster is this head's Render catalog previews artifact, and the video is a full render of the same demo.html encoded with the generator's ffmpeg settings (1280 wide, crf 28). Both are on the CDN at the same URLs, invalidated.

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

Reviewed at 205d8318. Requesting changes for two blockers. The rest is solid.

Blocker 1: with data-vfx-chain on the same page, the resolve chain recurses forever from frame 2

filmed-screen's arm() (filmed-screen.html:200-235) runs on every seek. When the slot isn't its own, it saves the current resolver in the module-level priorResolve and installs a new myResolve that reads priorResolve when it is called. vfx.ts armPageComposite (packages/core/src/runtime/vfx.ts:1134-1146) does the same with priorResolver, and it also runs on every engine-mode seek (:1394). Two re-armers that both read the prior slot at call time make a cycle.

Here's how it plays out with vfx arming first. The other order ends the same way.

  • Frame 1: vfx installs V1 (prior null). filmed-screen installs F1 (priorResolve = V1).
  • Frame 2: vfx sees F1 ≠ V1, sets priorResolver = F1 and installs V2. filmed-screen sees V2 ≠ F1, sets priorResolve = V2 and installs F2.
  • Resolving frame 2: F2 calls V2, V2 calls F1, and F1 reads priorResolve, which is now V2. That loops V2 → F1 → V2 until a RangeError is thrown inside the engine's resolve call.

I reproduced this by copying both arm functions into Node and running three frames. Both install orders give frame 1 ok and then frame 2 RangeError: Maximum call stack size exceeded. prepare has the same structure. vfx.test.ts only covers a late resolver that doesn't chain, so it doesn't catch this.

Capturing the prior function inside each closure isn't enough on its own, because the chain would then grow by one link every frame. Some options:

  • a reentrancy guard on myResolve/myPrepare together with per-closure capture;
  • only chaining a resolver that isn't already somewhere in the chain;
  • an explicit multi-owner registration point in core, so neither side has to wrap the other.

Blocker 2: a second mount films the first canvas, and the second canvas renders blank

if (G.HFFilmedScreen) return; (:62) means only the first copy of the library runs, so mount() keeps that copy's document. Inside a sub-composition that's the first instance's scoping proxy. The second snippet calls HFFilmedScreen.mount({ canvas: ".hf-filmed-screen" }), and document.querySelector (:284) finds the first instance's canvas. That canvas gets two lenses. The second canvas is never mounted, and because layoutsubtree children never paint, it renders empty.

This happens whenever the same sub-composition is used twice. It also happens with two root-level copies that keep the default class. The README's "works … inside a data-composition-src sub-composition" only holds for a single use.

Possible fixes:

  • have the snippet pass its own element (canvas: document.currentScript…, or the scoped document.querySelector result) rather than a selector string;
  • make mount skip canvases it has already marked as mounted.

Checked and fine

  • (a) Hook identity: the runtime's scoping proxy (compositionScoping.ts:439-490) swaps window/document but not globalThis. Putting the hooks on G = globalThis makes the identity check work, and a single instance never wraps itself.
  • (b) Determinism: there's no Math.random, Date.now, performance.now or rAF in either file. Shot glides, sway and the rack are all functions of t, and fs is recomputed if missing. The rack measures where earlier focus targets are now (rectOf on the current DOM), not where they were then. That's still deterministic under any seek order, but the comment at :471-473 claims a bit more than that.
  • (c) Occlusion flag: layout-audit.browser.js:1004-1005 (hasAllowOcclusionFlag) reads only the text element itself, and the contrast audit uses closest(). The PR stamps the scene root and every descendant (:310-313), so both audits see it. Only nodes created after mount (SplitText, script-built) miss it, as the PR's comment says.
  • Fallback: without drawElementImage, the scene goes back into the page with one warning (:297-304).
  • Colour: per-channel bokeh is weighted by the luma of each channel's own offset sample (:140-148), as described. Texture orientation is right.
  • registry-item.json: same shape as its sibling blocks.
  • check-catalog-source-pr.mjs: passes.

Nits

  • There's no unmount. Hooks are never restored, the GL context is never released, and lenses is never pruned, so a remounted scene keeps a dead lens that throws and logs every frame.
  • __hf_page_composite_pending = true is set even with zero lenses or on the fallback path, which turns off static-frame dedup.
  • Without WebGL2, gl is null and createShader throws, leaving the scene blank. That's a second gap next to the drawElementImage fallback.
  • fadeOut (on by default, last 0.4 s) and zIndex aren't documented. The README table and the registry description still say "cut" and "cut tears", and the tags include "crt", but shots glide by default now.

I didn't run a live render, and lint:registry-items couldn't run here (no bun).

— Rames

… instance

Review on #5487:

- The composite hooks no longer cycle with data-vfx-chain. Both re-arm every
  seek, and core's resolver reads its prior when called, so a stale wrapper
  of ours is re-entered through it. Each wrapper now keeps its own prior and
  returns at once when re-entered, so the chain stays a constant depth. The
  slot is only claimed once a lens is mounted.
- A second instance films its own canvas. mount() takes the snippet's
  caller-scoped query result and picks the first canvas not already filmed;
  the library's own document belongs to whichever copy loaded first.
- No WebGL2 now steps aside like no HTML-in-Canvas. mount() returns
  { destroy() }, a lens whose canvas leaves the page is dropped, and a lens
  whose composition is not on screen skips its capture instead of logging.
- Docs: fadeOut and zIndex, the rack comment, and the description and tags
  now describe glides instead of cuts.

filmed-screen.test.mjs runs both arm orders against core's arming pattern,
two mounts over two canvases, and that the demo pastes the snippet's library.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vanceingalls

Copy link
Copy Markdown
Collaborator Author

Thanks, both blockers were real. Fixed in efcf1084.

Blocker 1: resolve cycle with data-vfx-chain. Each of my wrappers now captures its own prior and returns at once when re-entered (preparing/resolving guards). A stale wrapper reached through core's priorResolver stops there, so the chain stays a constant depth. Neither side has to wrap the other into a growing chain. In the engine's order (vfx arms inside the seek, this hook after it returns) each owner runs exactly once per frame. In the reverse order, vfx's stale resolver also runs, which costs one extra capture and still terminates. filmed-screen.test.mjs copies armPageComposite's shape verbatim and runs six frames in both orders. On 205d8318 it fails with the same Maximum call stack size exceeded you hit, and it passes now. I'd still take the core multi-owner registration point as the real fix; this guard is the component-side part.

Blocker 2: second mount. mount() now takes the snippet's own caller-scoped document.querySelectorAll(".hf-filmed-screen") and uses the first canvas without data-filmed-screen-mounted. Live check: the demo mounted twice through data-composition-src (0–4.5 s and 4.5–9 s) with a fractal-noise vfx-chain box on the same page. At 205d8318 the second instance is blank. Now both are filmed, each on its own clock, and the render finishes in 30 s with no console errors.

Nits

  • Unmount: mount() returns { destroy() }. It drops the lens, loses the GL context and removes the output canvas. A lens whose canvas leaves the page is pruned on the next frame. I left the seek hooks in place, because every lens on the page shares them.
  • Pending with zero lenses: arm() returns early until a lens is mounted, so __hf_page_composite_pending is never set by an empty or fallen-back page.
  • No WebGL2: steps aside the same way as no HTML-in-Canvas, with the scene back in the page and one warning.
  • Docs: fadeOut, zIndex, destroy() and the canvas-list form are documented. The description no longer mentions cut tears, and crt is gone from the tags. The rack comment now says it measures where earlier targets sit in the DOM now.

Also found while re-testing

  • A composition that isn't on screen yet, or any more, has no paint record. Its lens tried to capture every frame and logged "No cached paint record" (540 lines in the two-instance render). draw() now skips when the canvas fails checkVisibility({ opacityProperty, visibilityProperty }).
  • demo.html pastes the library inline, and I had let it drift from the snippet. A test now fails if the two differ (whitespace aside).

node --test registry/components/filmed-screen/filmed-screen.test.mjs passes 5/5, and the test is registered in test:scripts. lint-registry-items and check on the demo both pass.

The vfx stand-in returned `true || prior`, which oxlint rejects as a
constant-truthiness expression.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…reen test

CodeQL reads the extraction regexps as HTML filters (js/bad-tag-filter).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Re-reviewed at e1f2ac9a against my change request at 205d8318. Same base, 3 new commits. Both blockers are fixed, so I'm clearing it.

1. Resolve cycle with data-vfx-chain: fixed.

  • Each myPrepare/myResolve now closes over its own priorPrepare/priorResolve, instead of reading a shared variable when it's called.
  • A wrapper that's re-entered returns false straight away, through the page-level preparing/resolving flags.
  • I traced both arm orders against core's armPageComposite, which still reads its module-level priorResolver at call time:
    • vfx arms inside the seek, filmed-screen after it (the engine's order): F(n) → V(n) → F(n-1), which returns at once. Every owner runs once per frame.
    • The other order: V(n) → F(n) → V(n-1) → F(n), which returns at once. That's still constant depth, but resolveVfxCapture runs twice per frame. The test says so, and it costs a capture, not a loop.
  • Mutant check: dropping if (resolving) return false; makes both co-owner tests throw Maximum call stack size exceeded at frame 2, which is the same failure my repro hit.

2. Second mount: fixed.

  • The snippet now passes its caller-scoped document.querySelectorAll(".hf-filmed-screen").
  • mount() takes the first candidate without data-filmed-screen-mounted and sets that attribute on it.
  • Each sub-composition's scoped document only sees its own canvas, so the library's module-level document no longer matters.
  • Mutant check: dropping the hasAttribute(MOUNTED) filter fails the next unmounted canvas test.

The rest of the delta looks right:

  • arm() returns before claiming the slot when no lens is live.
  • live() drops lenses whose canvas left the page. destroy() loses the GL context, removes the output canvas and unmarks the source.
  • The WebGL2 fallback steps aside the same way the HTML-in-Canvas fallback does.
  • now() also checks the top-level __timelines.
  • draw() returns false for a composition that isn't visible.
  • The demo is held to the snippet's library by a test.

Non-blocking

  • For a plain (non-layoutsubtree) canvas, stepAside returns a no-op destroy() and leaves data-filmed-screen-mounted on it, so a later mount() can't retry that canvas. It's unlikely to matter.

Tests

  • Locally: node --test filmed-screen.test.mjs passes 5/5.
  • CI: in Test (support) the four new tests ran and passed (# pass 728, # fail 0). The 11 required checks are green (the edit accuracy gate skipped), out of 68 runs with nothing failing.
  • I didn't do a live render. The two-mount and vfx-on-the-same-page live check is from the PR thread.

— Rames

…d of erroring

In Studio's preview the first draw can run before Chrome has painted the
layoutsubtree scene once (the paint wait gives up after 250 ms on a busy
machine). drawElementImage then throws "No cached paint record", which was
logged with console.error and surfaced as a preview console error. That
error is now a wait: the lens requests a paint and draws again when it
lands. Other capture failures still log.

Render output is unchanged: the demo renders byte-identical to before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.

3 participants