Fable 5 / Opus 4.8 accept up to 2576px / 4784 visual tokens per image, but a request with >20 images (pxpipe always sends many) is held to the stricter <=2000px/side rule, so the real ceiling is ~1932x1932 (1928x1928 = 69x69 = 4761 tokens). MAX_HEIGHT_PX 1568->1932; dense tool/history pages now render at DENSE_CONTENT_COLS=384 / DENSE_CONTENT_CHARS_PER_IMAGE=92160 (1928x1928 full page) -- fewer image blocks at the same OCR-validated 5x8 cell. The static slab is unchanged (313 cols / 1573x1280). Also fixes a regression the code review caught: the break-even gate, truncateForBudget, and estimateImageCount predicted against the slab geometry (313 cols / 159 rows) while the dense renderer emits 384 cols / 240 rows, so large tool_results were truncated far earlier than the 10-image cap required -- silently dropping output that would have rendered. A per-page char budget is threaded through the prediction chain (defaults to READABLE so the slab and unit tests are byte-identical) plus a denseGateGeometry helper, so the gate prices the same page the renderer actually produces.
9.2 KiB
How pxpipe sizes a rendered image — rules, reasons, and history
This documents why a pxpipe PNG has the dimensions it does. It exists because the sizing looks arbitrary until you know what was tried and rejected. If you're tempted to "make it square" or "shrink to the text," read the history first — both were considered, and one was built and reverted.
TL;DR — current behavior
A rendered page is a fixed-width, variable-height image. It is not deliberately squared and never shrunk horizontally to fit short lines.
- Width is constant per path, set by the column count, not by content:
width = 2·PAD_X + cols·CELL_W. The static slab renders atDEFAULT_COLS=313→8 + 313·5 = 1573px; dense tool/history pages render atDENSE_CONTENT_COLS=384→8 + 384·5 = 1928px. - Height grows to fit the lines on the page, capped, then pages:
height = 2·PAD_Y + nLines·CELL_H→8 + 8·nLines. - Vertical cap → paging:
maxLines = floor((MAX_HEIGHT_PX − 2·PAD_Y) / CELL_H)= floor(1924/8) = 240lines. Overflow goes to the next image, it does not grow the canvas. A full dense page is1928 × 1928(≈square only because 384 cols ≈ 240 rows at the 5×8 cell — not deliberately squared); a full slab page is1573 × 1280(~159 rows, READABLE-bound below the 240 cap); a partial page (small tool_result, last page) is wide-and-short, e.g.1928 × 160.
This ~1932×1932 ceiling is the largest page Fable / Opus 4.8 accept without a
server-side resize. Those models take up to 2576 px on the long edge, but a
request with >20 images (pxpipe always sends many) is held to the stricter
≤2000 px/side rule, and 1928×1928 = 69×69 = 4761 visual tokens, just
under the 4784-token per-image cap. Going bigger gets the request rejected,
not downscaled.
Source of truth: renderChunkToPng in src/core/render.ts (the width /
height lines), constants PAD_X=PAD_Y=4, CELL_W=5, CELL_H=8 (the "5×8
cell"), DEFAULT_COLS=313, DENSE_CONTENT_COLS=384, MAX_HEIGHT_PX=1932,
READABLE_CHARS_PER_IMAGE=50000, DENSE_CONTENT_CHARS_PER_IMAGE=92160.
The cell
Each character occupies a 5px wide × 8px tall cell (ATLAS_CELL_W=5,
ATLAS_CELL_H=8 in src/core/atlas.ts). The atlas is a prebaked glyph sheet;
text is rendered white-on-black then inverted to black-on-white. The cell was
7×10 originally and shrunk to 5×8 — smaller cell, same legibility on the
target model, more chars per pixel.
Why full width, not shrink-to-content
shrinkColsToContent (src/core/render.ts) still exists but is a no-op — it
returns cols unchanged. Its docstring records the policy:
Always render at full canvas width — no shrink-to-content. Maximum chars per page = maximum image-token savings on dense content, and the unused canvas tail is just whitespace (cheap to encode).
The reasoning:
- The only content pxpipe images is dense content (it passes a
profitability gate; sparse/short-line content is left as text). After
reflowpacks that content into full-width rows, the longest line is already ~cols, so there is nothing left to shrink. Width-shrink only helps short-line content — which never reaches the renderer. - Simplicity + prediction parity. The cost gate has to predict image tokens before rendering. A fixed width makes prediction trivial and exact; a content-dependent width forced the gate to re-derive the canvas size (see the "hoist width-shrink before gate" commit below) for no real-world gain.
Why not "square with max width"
Anthropic bills images by pixel area (≈ w·h/750), not by the longest edge.
So a square is not cheaper than a wide-short image of the same area — what
minimizes cost is the tightest bounding box around the text, and for dense
reflowed content that box is exactly "full width × just enough rows," which is
what we render. We never pad to a square: a full dense page lands at
1928×1928 only because 384 cols and 240 rows are equal pixel extents at the
5×8 cell — it's the max-chars-per-page point (the ~1932² token ceiling), not an
aspect-ratio target, and a partial page stays wide-and-short. Aspect ratio is a
non-goal; chars-per-pixel is the goal, achieved by filling every row to
cols and paging vertically.
Two render paths
- tool_result / history images: single-column at
DENSE_CONTENT_COLS=384(1928px wide), paged at the 240-line cap → full pages are1928×1928(~92k chars each). - system-slab image: single-column at
DEFAULT_COLS=313(1573px wide), READABLE-bound to ~159 rows (~50k chars →1573×1280). Kept on a path that can use multi-column packing (shrinkWidth=false), but multi-col is disabled by default (multiCol: 1) because at 313 cols a single column already holds ~50k chars/page and multi-col adds OCR column-ordering risk without meaningful savings.
So in practice everything is single-column full-width today; the multi-col code is retained for backward compat.
Billing model (and an unresolved gap)
- Anthropic's documented formula:
tokens ≈ (w·h) / 750. - The gate's constant:
src/core/transform.tsanchors per-image cost on an empirical figure (~2500 tok for a 508×1559 canvas, i.e. claiming the doc formula underpredicts billing by ~2.4×). That measurement is from an earlier cell/column regime. - A fresh regression on production
~/.pxpipe/events.jsonl(2-var OLS,tokens ≈ a·text_chars + b·image_pixels, N≈1.5k cold-miss events) gives ~907 pixels/token and ~1.8 chars/token for text — i.e. much closer to the doc's 750 than to the gate's stale ~312 px/tok.
These disagree by ~3×. The gate is therefore likely pessimistic about image
cost on current traffic (it may pass up profitable compressions). Re-grounding
TOKENS_PER_IMAGE from a fresh regression is an open task — the data is in the
event log (image_pixels next to input_tokens + cache_create_tokens on
cold-miss rows). Don't trust any single hardcoded image-cost constant without
re-checking it against the log.
How sizing decisions are/were measured
- L1 OCR eval (
eval/eval-l1-ocr.mjs): per-character read accuracy across render styles (cell size, reflow on/off, instruction placement, grayscale). This is what gated reflow and the in-image instruction banner. - L2 session eval (
eval/eval-l2-session.mjs): comprehension over whole imaged sessions. - events.jsonl regression: the real per-pixel/per-char billing fit above.
- glyph-matrix (
eval/glyph-matrix/, PAUSED): a per-character confusion matrix across render styles, to find a zero-cost style that lowers read error so the gate can compress harder at the same fidelity. Paused because the reader model (Fable 5) is offline.
History (oldest → newest)
The sizing converged through measured iteration, not a single design. Key commits:
| date | commit | change | why |
|---|---|---|---|
| 2026-05-21 | 38e852a |
add R3 reflow (recover line-end dead margin, ~29% glyph fill → dense) | rows were mostly empty; pack them |
| 2026-05-22 | fbf32bb |
pack reflow across newlines + grayscale atlas + build L1/L2 eval harness | measure read fidelity of the packing |
| 2026-05-22 | ea68340 |
in-image instruction banner variant | L1 eval: +1.04pp char accuracy vs baseline |
| 2026-05-22 | dca7807 |
pack header prose to full row width, drop spurious ↵ markers | tidy reflow output |
| 2026-05-22 | a9b0996 |
docs: cell is now 5×8 (was 7×10) | denser cell |
| 2026-05-23 | 1afaa6c |
content-aware image cost + width-shrinking (WIP) | tried shrink-to-content |
| 2026-05-23 | 414f4bf |
hoist width-shrink before the cost gate so prediction matches render | make shrink predictable |
| 2026-05-25 | 3c8716c |
full-canvas single-column rendering, 50k chars/page — shrinkColsToContent becomes a no-op |
shrink gave no gain on dense content; full width packs more |
| 2026-05-25 | bb8e0d8 |
page dense tool/history images | enforce the 195-line cap, split overflow |
| 2026-05-26 | 28bc65c |
reduce dense page size | tuning |
| 2026-06-09 | cdfc99d |
drop Opus, Fable-5 only; dense render on bare 5×8 cell | Opus misread ~7% of renders |
| 2026-06-17 | (this change) | raise page ceiling to ~1932×1932: MAX_HEIGHT_PX=1932, dense pages DENSE_CONTENT_COLS=384 / 92160 chars; row cap follows each call's char budget |
Fable/Opus 4.8 accept ≤2000px & ≤4784 tok/image (not the old 1568 limit); baseline left ~3× headroom unused → bigger pages = fewer image blocks at the same 5×8 legibility |
The arc: reflow to stop wasting rows → eval harness to prove the packing is still readable → width-shrink experiment → reverted to full-canvas two days later because dense content already fills the width → paging for the height cap → model scope narrowed to the reader that hits 100% on the cell.
If you want to change the sizing
- Add a style variant to the L1 OCR eval and measure char accuracy first.
- Check the cost side against
events.jsonl(don't trust the hardcoded per-image constant — see the gap above). - Remember the gate must be able to predict the size cheaply; content-dependent geometry is what got width-shrink reverted.