Skip to content

Capture declared screenshots on file rows - #6027

Open
lukemelia wants to merge 6 commits into
mainfrom
cs-12231-filedef-poster-slots-capture-only-render-components-file
Open

Capture declared screenshots on file rows#6027
lukemelia wants to merge 6 commits into
mainfrom
cs-12231-filedef-poster-slots-capture-only-render-components-file

Conversation

@lukemelia

@lukemelia lukemelia commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What this does

Declared screenshots (static screenshots) now capture for the file rendering of a URL, not only the card-JSON instance rendering. A FileDef family can declare a slot (e.g. a poster) whose capture-only component decodes the file in-page; the prerender-html pass captures it on the same warm tab after the file's format renders and persists it through the MediaCache.

  • Visit protocol: one screenshots opt-in covers both of a URL's renderings. The args carry the shared content hash plus per-row prior manifests keyed by the same 'instance' | 'file' row type the storage layer speaks (read in a single query); each pass's capture result rides its own sub-response (card.screenshots / fileRender.screenshots), where it is produced and consumed. captureDeclaredScreenshots takes the rendering kind and selects its half's carry-forward inputs, so keyBy: 'file-content' works independently per half; the two capture steps in the render runner share one kind-parameterized helper.
  • Persistence: file captures land in the MediaCache ledger under the file's own URL, extension intact — only instance ids shed .json — and the manifest is stamped on the type-'file' row.
  • Serving: _screenshot/{path}?name= resolves against both of the URL's live rows, preferring whichever manifest actually holds the name (a path with a registered file extension reads the file row first).
  • Consumption: the file-meta GET and linked-file resources join the manifest into meta.screenshots, matching the card+json GET, so FileDef's screenshotURLs getter reads captures for files exactly as it does for cards. The render route injects declaration-derived meta.screenshots into file renders, so a family's own prerendered formats embed durable capture URLs on their very first pass (they 404 briefly, then self-heal — the same posture card renders have).

This is the machinery the per-family FileDef poster work (PDF / Office / video / 3D / image renditions) declares its slots against; those consumers are stacked as follow-up PRs.

Protocol shape rationale

Named per-pass fields fit this protocol where pass shapes are heterogeneous (card / fileExtract / fileRender). The screenshot halves are homogeneous — identical args and result shapes differing only by which row they belong to — so they use the keyed encoding instead of a parallel field pair, and the per-half opt-in was dropped as never independently meaningful: both derive from adapter presence, and each half is already gated by whether its pass runs.

Standing cost

Every file in every prerender-html pass pays a render.screenshots roster transition on the warm tab, slotted family or not — the roster is class-derived in-page, so no server-side gate is possible. Accepted: the per-visit cost is one route transition on a settled page, and it is observable per file under the file rendering's screenshots format timing.

Scope boundary

A capture-only component's linked-data loads during a file capture do not refresh the file row's deps (file-row deps remain the extract pass's); the per-family poster components decode the file itself, which is already the row.

Test plan

New declared-screenshots-file-test.ts (realm-server) covers: manifest lands on the file row with the declared identity + source content hash and a ledger row keyed by the file URL; ?name= serves the capture; the file-meta GET joins meta.screenshots; the file's own embedded HTML carries the durable URL from the declaration-derived render context; a dependency-triggered re-render of unchanged bytes carries forward with no new ledger row while a content change recaptures.

🤖 Generated with Claude Code

The prerender-html pass runs the declared-screenshot capture step for the
file rendering of every URL, mirroring the card half: the FileDef family's
static screenshots roster is read on the same warm tab after the file
formats render, captures persist to the MediaCache under the file's own
URL (extension intact — only instance ids shed .json), and the manifest
lands on the URL's type-'file' prerendered_html row with carry-forward
keyed on the file row's own prior manifest.

Serving and consumption follow suit: the _screenshot/{path}?name= route
resolves the file row's manifest (preferring whichever live row actually
holds the named slot), the file-meta GET and linksTo file resources join
the manifest into meta.screenshots the way a card+json GET does, and the
render route injects declaration-derived meta.screenshots into file
renders so a family's own prerendered formats can embed durable capture
URLs on their very first pass.

Part of CS-12231.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Preview deployments

Host Test Results

    1 files      1 suites   2h 28m 36s ⏱️
4 626 tests 4 612 ✅ 14 💤 0 ❌
4 641 runs  4 627 ✅ 14 💤 0 ❌

Results for commit a1c6e0d.

Realm Server Test Results

    1 files  ±0    202 suites  ±0   1h 10m 8s ⏱️ - 2m 21s
2 628 tests ±0  2 628 ✅ ±0  0 💤 ±0  0 ❌ ±0 
2 667 runs  ±0  2 667 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit a1c6e0d. ± Comparison against earlier commit 080df05.

lukemelia and others added 4 commits September 4, 2026 22:16
A write of bytes identical to the fixture's is a no-op that enqueues no
indexing pass, so the settle helper had nothing to wait on: each test now
writes distinct content, the carry-forward re-render comes from touching
the family module (a dependency every .mismatch file row carries) instead
of rewriting identical bytes, and the ledger assertions scope to the
render's generation since the fixture build already captured its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One screenshots opt-in now covers both of a URL's renderings instead of a
screenshots/fileScreenshots field pair: the args carry the shared content
hash plus per-row prior manifests keyed by the same 'instance' | 'file'
row type the storage layer speaks (read in one query via
priorScreenshotManifests), and each pass's capture result rides its own
sub-response (card.screenshots / fileRender.screenshots) where it is
produced and consumed, rather than a second top-level pair.
captureDeclaredScreenshots takes the rendering kind and selects its half's
prior manifest; the card and file capture steps in the render runner share
one kind-parameterized step helper.

Named per-pass fields fit the protocol where pass shapes are heterogeneous
(card / fileExtract / fileRender); the screenshot halves are homogeneous —
identical args and result shapes differing only by row — so the keyed
encoding is the honest one, and the per-half opt-in was never
independently meaningful (both derived from adapter presence, with each
half already gated by whether its pass runs).

Part of CS-12231.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A file render learns its realm from the prerender server's stash rather
than a response header (the card branch reads x-boxel-realm-url off the
card GET; a file render performs no such fetch, and an extract-built
resource carries no meta.realmURL) — without it the declaration-derived
meta.screenshots injection had nothing to compose durable URLs against.
The serving test pins the artifact by its ETag and verifies the bytes in
the object store: the ?name= response body is a node stream, which
arrayBuffer() reads as empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retry-lane bookkeeping composes with the unified per-rendering capture
protocol: priorScreenshotStates reads both rows' manifests AND recorded
failures in one query keyed by row type, each half's persist seeds its own
consecutive-failure runs and row-level failing-render counter, and file
rows now carry screenshotErrors / screenshotCaptureFailureRenders /
screenshotTimingsMs diagnostics exactly like instance rows — which is what
lets the reconcile sweep's bounded retry lane (which joins ph.type =
i.type over every index row) cover file-capture failures with no lane
changes. The shared capture-step helper records the whole-step elapsed
time as the roster-level '*' error's captureMs.

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

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] This review went after the seams the file-row capture lane opens: the persist/serve spelling contract across both row kinds, the carry-forward and failure-bookkeeping inputs per rendering, and doc-contract drift. It did not re-review the capture engine internals that predate this change.

Bottom line: no blocking issues. One thing belongs in this PR rather than the stack above it: two doc comments that this PR falsifies.

  1. Docs move with the contract. packages/base/card-api.gts (FileDef.screenshotURLs: "The prerender pass captures only instance rows, so a file's declared names read undefined until file rows capture too") and packages/runtime-common/resource-types.ts (FileMetaResourceResourceMeta.screenshots: "nothing stamps this on file-meta responses") both describe the world this PR ends. The card-api correction currently rides the stacked image-renditions branch; pull both corrections into this PR so main never carries comments that contradict the shipped behavior. Non-blocking, but this PR's to fix.
  2. Alias-addressed file captures resolve a manifest and then miss the ledger — see the comment on the ?name= fallback in realm.ts.
  3. The ledger-spelling rule now has three homes — see the comment on ledgerKey in index-writer.ts.
  4. The in-browser twin's silent injection no-op is load-bearing and unpinned — see the comment on fileDeclarationScreenshotsMeta in render.ts.

Decision to confirm: every file in every prerender-html pass now pays a render.screenshots roster transition, slotted family or not — the roster is class-derived in-page, so no server-side gate is possible. The cost is observable per visit under the file rendering's screenshots format timing. Confirm this standing per-file cost is accepted; if not, the alternative is threading a declares-screenshots hint through the extract pass, which is real machinery for a small win.

Adjacent, out of scope: the capture-spec DSL path (query-param captures) remains instance-only — a DSL capture addressed at a file rendering is an uncaptured miss. That matches its design; noting it for whoever extends the DSL to files.

Comment thread packages/runtime-common/realm.ts Outdated
Comment on lines +4221 to +4229
let reads = urlNamesFile(rawFileURL)
? [readFileRow, readInstance]
: [readInstance, readFileRow];
// The first live row whose manifest holds the name wins — a URL both
// rows answer to (a `.json` file that is also a card) must resolve to
// the row that actually captured this slot, not 404 against the other
// row's empty manifest.
let manifestEntry: ScreenshotManifestEntry | undefined;
let manifestSourceURL = instanceURL;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] A file capture addressed by its alias spelling resolves the manifest and then misses the ledger — a guaranteed 404 with the manifest in hand. liveFileScreenshots matches url OR file_alias, and a file row's alias sheds .json and executable extensions (file_alias: trimExecutableExtension(...).replace(/\.json$/, '') in index-writer.ts) — but manifestSourceURL stays the request's spelling while the persist keyed the ledger on the row's raw url. So _screenshot/foo?name=poster for a slotted foo.json (or foo.gts) finds the manifest via the alias and then findMediaCacheEntry looks up a source URL the ledger never held.

Unreachable today — no family with declared slots lives on an alias-bearing extension — and nothing will fail loudly the day one does (a slot on JsonFileDef or GtsFileDef, or a realm-supplied FileDef on such an extension).

Fix: have liveFileScreenshots / liveInstanceScreenshots return the row's canonical url alongside the manifest, and use that as the ledger source here instead of the request-derived spelling. Latent gap introduced by the file branch of this lookup; non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in a1c6e0d. liveInstanceScreenshots / liveFileScreenshots now return the matched row's canonical url alongside the manifest (shared #liveRowScreenshots underneath), and the serving loop derives the ledger key from it via screenshotLedgerSourceURL(row.url, row.kind) — an alias-addressed hit now looks up the same spelling the persist keyed. This resolves it.

Comment thread packages/runtime-common/index-writer.ts Outdated
// rows use the extensionless card-id form, file rows the file's own
// URL (a `.json` suffix is an instance-id spelling, so only the
// instance half strips it).
let ledgerKey = (value: string) =>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] The "only instance ids shed .json" spelling rule now lives in three homes: this closure, the two sourceURL call sites in prerender-html-visit.ts (fileURL.replace(/\.json$/, '') vs raw fileURL), and implicitly the ?name= route's stripped-instance-URL vs raw-file-URL split in realm.ts. The first time the rule changes, one copy learns it and the others drift — and the ledger key is exactly the kind of contract that fails silently when they do.

Suggest one exported helper, e.g. screenshotLedgerSourceURL(url, kind) beside screenshotNameURLFor in capture-spec.ts, consumed by all three. Fine as an immediate follow-up if you'd rather not touch three files here — but the fix for the alias-addressed lookup (see the comment on the ?name= fallback in realm.ts) will land on this same rule, so doing both together is cheapest. Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in a1c6e0d. The rule now has one home: screenshotLedgerSourceURL(url, kind) in capture-spec.ts, consumed by both persist call sites in prerender-html-visit.ts, this copy path, and the serving route (where it also carries the alias fix from the realm.ts thread). This resolves it.

let id = resource.id;
// The stashed visit realm is the authority; an extract-built resource
// carries no meta.realmURL of its own.
let realmURL = visitRealmURL ?? resource.meta?.realmURL;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] The silent no-op when realmURL is absent is load-bearing and nothing pins it. The in-browser prerender twin stashes __boxelFileRenderData without a realm (card-prerender.gts), so this helper skips injection there — which is the correct behavior: that twin never captures, so baked durable URLs would 404 with no later capture to self-heal them. But as written the fallback chain reads like a defensive default, and the natural "fix" for someone debugging missing URLs in-browser is to add realmURL to the twin's stash — reintroducing permanently-dead links.

Ask: one sentence in this comment naming the twin's omission as deliberate ("the in-browser twin stashes no realm on purpose: it never captures, so injection must stay off there"). Non-blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Claude Code 🤖] Fixed in a1c6e0d — the helper's comment now names the in-browser twin's missing realm as a deliberate no-op and why (no captures there, so baked URLs would never self-heal). This resolves it.

The named-capture lookups return the row's canonical url alongside the
manifest, and serving derives the ledger source from it — the lookups also
match file_alias, so an alias-addressed hit (a slotted .json or
executable-extension file addressed without its suffix) previously
resolved its manifest and then queried the ledger under a spelling it was
never keyed by. The spelling rule itself now has one home,
screenshotLedgerSourceURL in capture-spec, consumed by the persist, the
realm-copy, and the serving paths. Doc comments on FileDef.screenshotURLs
and FileMetaResourceResourceMeta.screenshots now describe file-row capture
as it ships, and the render route's injection helper names the in-browser
twin's missing realm as a deliberate no-op.

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

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] Review body items addressed in a1c6e0d: both stale doc comments now describe file-row capture as shipped (FileDef.screenshotURLs in card-api.gts, FileMetaResourceResourceMeta.screenshots in resource-types.ts — the card-api correction moved here from the stacked image-renditions branch, which merges clean since both carry identical text). The per-file roster-transition cost is accepted and now called out in the description's Standing cost section, with the format timing that makes it observable.

@lukemelia
lukemelia marked this pull request as ready for review September 6, 2026 03:54
@lukemelia
lukemelia requested review from a team and habdelra September 6, 2026 03:55
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.

1 participant