Repository navigation
feat(registry): add filmed-screen component - #5487
Conversation
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]>
|
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 The second commit moves the demo's first cut from 3 s to 3.8 s, because the poster is captured at |
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]>
|
Preview media refreshed for the new demo at |
jrusso1020
left a comment
There was a problem hiding this comment.
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, setspriorResolve= 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 aRangeErroris 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/myPreparetogether 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 scopeddocument.querySelectorresult) rather than a selector string; - make
mountskip canvases it has already marked as mounted.
Checked and fine
- (a) Hook identity: the runtime's scoping proxy (
compositionScoping.ts:439-490) swapswindow/documentbut notglobalThis. Putting the hooks onG = globalThismakes the identity check work, and a single instance never wraps itself. - (b) Determinism: there's no
Math.random,Date.now,performance.nowor rAF in either file. Shot glides, sway and the rack are all functions oft, andfsis recomputed if missing. The rack measures where earlier focus targets are now (rectOfon the current DOM), not where they were then. That's still deterministic under any seek order, but the comment at:471-473claims 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 usesclosest(). 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
lensesis never pruned, so a remounted scene keeps a dead lens that throws and logs every frame. __hf_page_composite_pending = trueis set even with zero lenses or on the fallback path, which turns off static-frame dedup.- Without WebGL2,
glis null andcreateShaderthrows, leaving the scene blank. That's a second gap next to thedrawElementImagefallback. fadeOut(on by default, last 0.4 s) andzIndexaren'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]>
|
Thanks, both blockers were real. Fixed in Blocker 1: resolve cycle with Blocker 2: second mount. Nits
Also found while re-testing
|
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
left a comment
There was a problem hiding this comment.
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/myResolvenow closes over its ownpriorPrepare/priorResolve, instead of reading a shared variable when it's called. - A wrapper that's re-entered returns
falsestraight away, through the page-levelpreparing/resolvingflags. - I traced both arm orders against core's
armPageComposite, which still reads its module-levelpriorResolverat 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
resolveVfxCaptureruns twice per frame. The test says so, and it costs a capture, not a loop.
- Mutant check: dropping
if (resolving) return false;makes bothco-ownertests throwMaximum call stack size exceededat 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 withoutdata-filmed-screen-mountedand sets that attribute on it.- Each sub-composition's scoped
documentonly sees its own canvas, so the library's module-leveldocumentno longer matters. - Mutant check: dropping the
hasAttribute(MOUNTED)filter fails thenext unmounted canvastest.
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()returnsfalsefor 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,stepAsidereturns a no-opdestroy()and leavesdata-filmed-screen-mountedon it, so a latermount()can't retry that canvas. It's unlikely to matter.
Tests
- Locally:
node --test filmed-screen.test.mjspasses 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]>
What
A new registry component,
filmed-screen. It films a live-DOM scene the way a phone camera films a monitor:glideseconds), or cut with a horizontal tear when a shot asks fortransition: "cut";rackseconds, 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-blurandgrain-overlay. The author puts their scene inside a static<canvas layoutsubtree>and lists shots:Each frame it reads the focus element's box from the live DOM in
__hf_page_composite_prepare, then captures withdrawElementImageand shades in__hf_page_composite_resolve, the same handshakedata-vfx-chainuses, composed with any prior owner. Shot times come from the owning composition's timeline, so it works inside adata-composition-srcsub-composition. A plain canvas (p5, three.js) can be passed instead and is sampled directly.Why not a
<template data-slot>primitive likeascii-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.checkpasses (0 layout issues, 27/27 text checks WCAG AA),snapshotand fullrendercorrect, screenshot capture.snapshotandrendercorrect; frames match the root render shifted by 1 s.drawElementImageremoved): the scene is moved back into the page and renders unfilmed, with one console warning.<video>, a light editorial page, a p5 canvas), were deterministic: 180/180 byte-identical frames across two renders.hyperframes add filmed-screenend to end, sinceaddreads frommain.Things found while building it, worth knowing outside this component
windowis a Proxy that returns functions bound. The compiler runs sub-composition scripts with a scopedwindow, and readingwindow.__hf_page_composite_prepareback 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 sharedpriorvariable ends up calling itself:Maximum call stack size exceededin snapshot/render, while preview is fine. This component installs engine hooks onglobalThis. Anything else that owns the composite protocol from a sub-composition is exposed to the same thing.__hf.seekis 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.checkreports filmed text as occluded. The layout audit honoursdata-layout-allow-occlusionon the text element only (the contrast audit usesclosest), and text inside acanvas[layoutsubtree]is never painted directly. The component stamps the attribute on the scene's elements at mount; a follow-up could have the audits skipcanvas[layoutsubtree]descendants instead, which would also coverdata-vfx-chainscenes.Costs and limits
layoutsubtreecanvas render with screenshot capture on one worker, so this is slower than drawElement capture..hf-filmed-screen-sceneneeds an explicit pixel size;inset: 0inside a layoutsubtree canvas measures 0×0.🤖 Generated with Claude Code