Skip to content

Add output-encoding and media axes to the capture-spec identity - #6118

Merged
backspace merged 4 commits into
mainfrom
cs-12975-add-output-encoding-type-and-media-axes-to-the-capture-spec
Sep 16, 2026
Merged

backspace merged 4 commits into
mainfrom
cs-12975-add-output-encoding-type-and-media-axes-to-the-capture-spec

Conversation

@lukemelia

@lukemelia lukemelia commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Groundwork for PDF generation on the screenshot pipeline: the capture spec's identity gains two axes so the URLs and cache keys never migrate when the engine grows new output legs.

  • type — the output encoding (png/jpeg/webp/pdf, default png). The image roster is shared with declared screenshots; pdf is new and wire-only.
  • media — the CSS media the render settles under (screen/print, default screen).

Both are part of the canonical capture identity: they appear in canonicalCaptureSpecString / captureSpecHash / canonicalCaptureSpecQuery, the GET _screenshot/ URL grammar (type=, media=), the POST body's shared strict parse (singular and batch entries, with entry-over-batch merge), and the coalescing twin comparator. Defaults are elided, so every existing spec canonicalizes and hashes exactly as before — the all-defaults spec is still {}.

The engine only produces png over screen media today, so the shared parse refuses every other value by name ("not supported by this capture engine" — the same never-ignore contract the reserved envelope/target params follow). Unlocking a value later is a one-line removal from the unsupported set and cannot re-key any existing capture. ScreenshotPrerenderResponse.contentType widens from the 'image/png' literal to a CaptureContentType union so the persist and serving paths can discriminate on it.

Testing

  • media-cache-dsl-test.ts: explicit-default elision (type=png&media=screen{}), refusal wording for unsupported and malformed values on both surfaces, and identity-axis tests (canonical string/query/hash distinctness for directly-constructed type: 'pdf' / media: 'print' specs).
  • screenshot-card-test.ts: a spec differing only in type, media, or target never coalesces onto a twin.
  • Both files green locally; runtime-common and realm-server typecheck clean.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ± 0      1 suites  ±0   2h 42m 42s ⏱️ -31s
4 840 tests +34  4 826 ✅ +34  14 💤 ±0  0 ❌ ±0 
4 855 runs  +34  4 841 ✅ +34  14 💤 ±0  0 ❌ ±0 

Results for commit 36fd68b. ± Comparison against earlier commit e8dc36c.

Realm Server Test Results

    1 files  ± 0    226 suites  +1   1h 17m 21s ⏱️ - 5m 20s
3 114 tests +49  3 114 ✅ +49  0 💤 ±0  0 ❌ ±0 
3 160 runs  +56  3 160 ✅ +56  0 💤 ±0  0 ❌ ±0 

Results for commit 36fd68b. ± Comparison against earlier commit e8dc36c.

@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 🤖] Lens: traced the two new axes end-to-end — the shared parse (GET params, POST body, batch-entry merge), default elision, canonical string/query/hash, the persist identity, and the coalesce comparator — and swept ScreenshotCaptureSpec for fields that steer pixels while staying invisible to that identity. The pre-existing geometry axes were only re-checked where the new code touches them.

Bottom line: no blocking issues. The axes land consistently on every surface, and existing ledger keys cannot migrate — explicit defaults elide inside parseScreenshotCaptureSpec before a spec reaches the hash, a job's args, or the comparator. The sweep surfaced one pre-existing identity gap (target), with a one-line piece worth taking in this PR.

  1. Add the target compare to sameCaptureSpec; the root cause is a follow-up — detail in the inline thread on jobs/screenshot-card.ts.
  2. Evergreen nit: the PR description opens with a Linear ticket ID, which is opaque to outside readers of this repo; it reads the same without it.

Red check: Percy is the pending-snapshot-approval gate and re-arms on every push; nothing in this diff renders, so it needs a dashboard approval, not a code change.

Comment on lines +137 to +138
(a.type ?? null) === (b.type ?? null) &&
(a.media ?? null) === (b.media ?? null) &&

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 🤖] Add target to this comparator while the PR is extending it — one line alongside these: (a.target ?? null) === (b.target ?? null) &&. With type and media covered, target is the only ScreenshotCaptureSpec field the compare skips, and the comment above ("any spec field two jobs disagree on disqualifies the join") is false precisely there. Unlike envelope/captures, a target-carrying job can be persist-carrying: the POST surface accepts a singular target for isolated/embedded, and handle-screenshot-card.ts builds the ledger spec as { format, ...captureSpec }, so target rides on the spec object at runtime while canonicalOverrides drops it from the hash. Two jobs whose specs differ only in target therefore share a persist key, and this compare is all that stands between them — today they join, handing one caller the other caller's element crop.

The same mechanism has a wider consequence outside this PR's files — a follow-up, not this PR: a singular-target POST on an indexed instance persists the element crop under the canonical spec's ledger identity (the worker's rendered-hash guard in tasks/screenshot-card.ts re-derives the hash from the same spread and drops target identically, so it cannot refuse the persist), and the durable GET _screenshot/ URL then serves that crop until the source generation bumps. Conversely, the POST ledger fast path hands a target request the full canonical capture. Fix shape: treat a target-carrying spec as capture-only in handle-screenshot-card.ts (leave the ledger spec undefined, as batches are) and add !captureSpec?.target to the worker's rendered-hash condition.

Class: pre-existing — none of it is introduced here, and nothing pins the current behavior in tests. The comparator line is worth taking in this PR since it edits exactly this function; the handler/worker fix is the follow-up. 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 🤖] Done in 29815a9 — added (a.target ?? null) === (b.target ?? null) to sameCaptureSpec, so two specs differing only in target no longer join (they share a persist hash, since canonicalOverrides drops target, so this compare was the only guard). Pinned with an an element-target mismatch is never a twin coalesce test. The handler/worker fix (capture-only ledger identity for a target-carrying spec + the rendered-hash guard) stays a follow-up as noted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Resolves the comparator half. The new coalesce test is non-vacuous: with both specs carrying only target, every other compared field is undefined on both sides and the persist keys are identical, so without the added line the compare returns true and the decision is a join.

One surface to add to the deferred follow-up's scope: estimateScreenshotQueueWait's twin SQL in this same file matches on the persist target + runAs only, and its comment claims it "Mirrors the coalesce join's key exactly". Now that target disqualifies a join, a target-carrying persist job can report hasTwin: true, skip the congestion pre-check on the strength of a twin it will not actually join, and then insert and render anyway. Same root cause — target outside the hash — so it folds into the same fix rather than needing its own. Non-blocking, not this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Retracting the congestion-gate half of my note above — it no longer applies, so there is nothing to fold into a follow-up. main has since landed both halves of the deferred fix: handle-screenshot-card.ts leaves the ledger identity undefined for a target-carrying spec, and the worker's rendered-hash guard refuses one. A target capture is therefore persist: null on both surfaces, coalescing short-circuits on the persist check before it reaches sameCaptureSpec, and no such job ever reaches the twin SQL. That also makes the target compare here belt-and-braces of the same kind as envelope/captures rather than a live-bug fix, which is the right place for it to be.

I pushed two commits to this branch: main merged in, and the totality guard from the review — sameCaptureSpec now destructures the spec exhaustively with rest satisfies Record<string, never>, so a field added to ScreenshotCaptureSpec fails to compile here until the compare learns it. Verified by adding a probe field: one error, on the assertion, naming the field. Revert it if you would rather it went in separately.

@lukemelia
lukemelia marked this pull request as ready for review September 15, 2026 18:39
@lukemelia
lukemelia requested review from a team and FadhlanR September 15, 2026 18:39
@lukemelia
lukemelia force-pushed the cs-12975-add-output-encoding-type-and-media-axes-to-the-capture-spec branch from 29815a9 to 32ae7f2 Compare September 15, 2026 18:45
lukemelia and others added 2 commits September 15, 2026 15:55
The capture spec gains two first-class identity axes: `type` (the output
encoding — png/jpeg/webp/pdf, default png) and `media` (the CSS media the
render settles under — screen/print, default screen). Both thread through
the canonical string, hash, URL DSL grammar, batch merge, and the
coalescing twin comparator, with defaults elided so every existing spec
canonicalizes and hashes exactly as before.

The engine only honors png-over-screen today, so non-default values are
refused by name at the shared parse (the same never-ignore contract the
reserved DSL params follow); unlocking a value later is a one-line change
that cannot re-key existing captures. ScreenshotPrerenderResponse's
contentType widens from the image/png literal to the full output union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`target` is the one ScreenshotCaptureSpec field the coalesce comparator
skipped, and it is dropped from the persist hash — so two element-crop
jobs on the same card share a ledger identity, and the comparator was all
that stood between them. Differing only in target, they joined, handing
one caller the other caller's element crop. Compare it alongside the
other spec axes, and pin the behavior with a coalesce test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GsYGsuCqHJz9GAti4jheG
@lukemelia
lukemelia force-pushed the cs-12975-add-output-encoding-type-and-media-axes-to-the-capture-spec branch from 32ae7f2 to e8dc36c Compare September 15, 2026 19:55

@backspace backspace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Lens: convergence pass over the change since the previously reviewed commit — the target comparator line and its coalesce test, verified as a change rather than as compliance — plus a re-check of whether the capture-spec enumeration is now total and stays total.

Bottom line: no blocking issues. The target compare closes the gap the earlier thread named, and its test is non-vacuous: with both specs carrying only target, every other compared field is undefined on both sides and the persist keys are identical, so without the new line the compare returns true and the decision is a join.

  1. Non-blocking: sameCaptureSpec is now total over ScreenshotCaptureSpec, but nothing keeps it total — detail in the inline thread on jobs/screenshot-card.ts.
  2. The deferred follow-up has one surface beyond what the earlier thread named — see the reply there.

Comment on lines +137 to +139
(a.type ?? null) === (b.type ?? null) &&
(a.media ?? null) === (b.media ?? null) &&
(a.target ?? null) === (b.target ?? null) &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Add the compile-time guard that keeps this compare total. The comment above the function claims totality over ScreenshotCaptureSpec, and with target it now holds — but nothing enforces it: the next field added to ScreenshotCaptureOverrides compiles fine here while dropping silently out of the twin check, which is how target went uncompared through several widenings. canonicalOverrides in capture-spec.ts already carries the pattern (exhaustive destructure + rest satisfies Record<string, never>), and it transplants here by destructuring a:

let {
  viewport, deviceScaleFactor, fullPage, clip,
  envelope, type, media, target, captures,
  ...rest
} = a;
rest satisfies Record<string, never>;
return (
  (viewport?.width ?? null) === (b.viewport?.width ?? null) &&
  // …
  captures === undefined &&
  b.captures === undefined
);

Class: follow-up — the gap it guards is closed, this is what keeps it closed. Non-blocking, and small enough to take here.

backspace and others added 2 commits September 16, 2026 07:31
The twin comparator enumerates every ScreenshotCaptureSpec field by hand, so
a field added to the spec drops silently out of the join check instead of
failing loudly — and a spec field two jobs disagree on has to disqualify the
join, since joining hands the incoming caller the twin's render verbatim.

Destructure the spec exhaustively and assert the rest empty, the same guard
canonicalOverrides uses to keep the ledger identity honest, so a new field
cannot compile until this compare learns it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ncoding-type-and-media-axes-to-the-capture-spec
@backspace
backspace merged commit cb709b4 into main Sep 16, 2026
66 checks passed
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.

2 participants