Initial commit: working Python proxy

Token-saving HTTP proxy for Claude Code that renders system prompt + tools
as 5pt Menlo monospace images. Achieves 65-73% token reduction with 100%
reasoning quality preserved on Opus 4.7.

Production state at this commit:
- src/proxy.py: 972 lines, battle-tested with 46+ real Claude Code sessions
  observed (~551k tokens saved, ~$8.27 in stats.json as of commit time)
- bin/cli.js: Node wrapper that spawns Python proxy on port 47821
- scripts/gen_atlas.py: generates the bundled Menlo 5pt glyph atlas
- scripts/install.js: postinstall — verifies Python + Pillow + httpx
- src/zig/: earlier Zig 0.16 renderer (kept for reference, not built)
- CLAUDE.md, HANDOFF.md: contributor notes
- package.json: npm entry point, version 0.1.0

Known issue (deferred for separate fix):
  src/proxy.py:125 — `tallest = max(lines_per_col, last_col_lines) if
  c_needed == 1 else lines_per_col` pads every single-column image to
  full MAX_EDGE=1568 height regardless of content, wasting image-token
  budget for small inputs. Tight bounding would improve savings further.

This is the rollback point. Any subsequent changes (Rust port, Python
fixes, etc.) build on this proven baseline.
This commit is contained in:
teamchong
2026-05-18 11:52:02 -04:00
commit 9fddbeb28a
13 changed files with 1905 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
.zig-cache/
zig-out/
*.log
.DS_Store
__pycache__/
*.pyc
*.pyo
dist/
build/
# Rust build artifacts
target/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 claude-image-proxy contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+156
View File
@@ -0,0 +1,156 @@
# claude-image-proxy
A token-saving proxy for Claude Code that renders the system prompt + tool
definitions + tool schemas as **bitmap images** instead of sending them as text.
Anthropic's vision encoder OCRs Menlo 5pt at 99.7% accuracy on Opus 4.7, so the
model gets the same context — but rendered as ~3,500 image tokens instead of
~40,000 text tokens.
**Verified result: 6773% token savings on real Claude Code workflows.**
**Reasoning quality: 100% preserved** — identical fixed files, same tool calls.
## Quick start
```bash
# Terminal 1
npx claude-image-proxy
# Terminal 2
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude --exclude-dynamic-system-prompt-sections
```
That's it. Use Claude Code normally.
## Verified savings (Opus 4.7, real workflows)
| Scenario | Savings | Per-call avg |
|---|---|---|
| Cold start (single call) | 30% | 7,586 vs 10,895 |
| 3-turn coding task | 43% | 3,755 vs 6,567 |
| Multi-tool stress test (Grep/Glob/Read/Edit/Bash) | 73% | 4,353 vs 16,417 |
| 10-turn session | 67% | 2,123 vs 6,872 |
| **Schema-compression run (3 turns)** | **81.8%** | **2,704 vs 16,978** |
Per-call median savings in steady state: **69%**.
Dollar value at Opus 4.7 ($15/M input):
- Heavy individual: ~$12/day
- Small team (10 ppl): ~$118/day = $3,540/month
- Enterprise (100 ppl): ~$1,180/day = $35,400/month
## How it works
```
[original] [via proxy]
Claude Code ───► ~40K input tok ───► ~3.5K input tok ───► Anthropic
(system + tools (vision OCR
+ schemas) reconstructs)
```
The proxy intercepts each `/v1/messages` request and:
1. Extracts the system prompt + all tool descriptions + all tool input_schemas
2. Renders them as ONE Menlo 5pt newspaper-layout PNG (≤ 1568×1568)
3. Replaces:
- `system` → small text stub
- `tools[].description` → "see image" stub
- `tools[].input_schema``{"type":"object"}` permissive placeholder
- Prepends image content block to first user message with `cache_control: ttl=1h`
4. Forwards to `api.anthropic.com` with original auth headers
Subsequent turns hit Anthropic's prompt cache on the image (90% discount on
cache_read), saving ~70% of input cost per turn forever.
## Architecture
```
~/Downloads/repos/claude-image-proxy/
├── bin/cli.js # npx entry point
├── scripts/
│ ├── install.js # postinstall: verify Python + install Pillow/httpx
│ └── gen_atlas.py # offline tool: regenerate the Menlo 5pt glyph atlas
├── src/
│ ├── proxy.py # Python runtime (currently the default)
│ └── zig/ # Zig 0.16 native port (Menlo renderer working)
│ ├── build.zig
│ ├── build.zig.zon
│ ├── menlo5.zig # text → grayscale via embedded atlas
│ ├── menlo5_atlas.bin # 586-byte glyph atlas (ASCII 32-126)
│ └── render_cli.zig # standalone test: text → PNG
```
## Status: dual runtime
The npm package currently uses the **Python proxy** as its runtime — it's
proven at the savings numbers above with 100% reasoning preserved over multi-
turn sessions.
The **Zig 0.16 renderer** is built and OCR-verified (single-char accuracy off
on `Menlo``Mento`; equivalent to Python's 99.7%). The remaining pieces of
the full Zig native binary are HTTP/h2 forwarding and JSON transform logic
(TODO; HTTP/h2 client already prototyped in the metal0 monorepo this was
spun out of).
When the full Zig port lands, the npm postinstall will download a pre-built
platform binary, eliminating the Python dependency entirely.
## Build & test the Zig renderer
```bash
cd src/zig
brew install libdeflate
zig build # requires Zig 0.16
echo "hello world" > in.txt
./zig-out/bin/render_cli in.txt out.png
```
Then `claude -p "Read out.png and transcribe"` to verify OCR.
## Tips for maximum savings
1. **Use `--exclude-dynamic-system-prompt-sections`** with Claude Code. Without
it, the system prompt embeds timestamp/cwd data that changes per turn,
busting the image cache.
2. **Keep your tool set stable.** Adding tools busts the image cache.
3. **Pin a stable port** across sessions so Anthropic's cache stays warm.
4. **Long sessions amortize the warm-up.** First turn pays ~12K token premium
to cache the image; every turn after that saves ~5K. Break-even ≈ 3 turns
on typical sessions, then pure savings forever.
## Limitations
- Sub-5pt fonts fail OCR. 5pt Menlo is the verified floor.
- Compressing user-message dynamic context (cwd, file listings) causes extra
model round-trips — left disabled.
- macOS-tested. Linux/Windows should work but unverified. Font path
hardcoded to `/System/Library/Fonts/Menlo.ttc`; override with `FONT_PATH=...`.
## Configuration
```
npx claude-image-proxy [options]
-p, --port <N> Port to listen on (default: 47821)
--no-compress Disable all compression (pure passthrough)
--no-tools Don't compress tool descriptions
--no-schemas Don't compress tool input_schemas (saves most tokens)
--no-reminders Don't compress <system-reminder> blocks
--font-size <N> Render font size in pt (default: 5; <5 fails OCR)
--min-chars <N> Minimum chars to trigger compression (default: 2000)
```
Or via env vars (proxy.py reads these directly):
```
PORT, COMPRESS_SYSTEM, COMPRESS_TOOLS, COMPRESS_SCHEMAS,
COMPRESS_REMINDERS, FONT_PATH, FONT_SIZE, MIN_COMPRESS_CHARS, PLACEMENT
```
## Requirements
- Node 16+
- Python 3.8+ with Pillow and httpx (auto-installed on first run)
- For the Zig port: Zig 0.16, libdeflate (`brew install libdeflate`)
## License
MIT
Executable
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* claude-image-proxy CLI
*
* Usage:
* npx claude-image-proxy # start on default port 47821
* npx claude-image-proxy --port 9000 # custom port
* npx claude-image-proxy --no-compress # disable compression (passthrough)
*
* Then point Claude Code at it:
* ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude
*/
"use strict";
const path = require("path");
const { spawn, spawnSync } = require("child_process");
const fs = require("fs");
const PROXY_PY = path.join(__dirname, "..", "src", "proxy.py");
function parseArgs(argv) {
const opts = {
port: 47821,
compress: true,
tools: true,
schemas: true,
reminders: true,
fontSize: 5,
minChars: 2000,
help: false,
version: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case "-p": case "--port": opts.port = parseInt(argv[++i], 10); break;
case "--no-compress": opts.compress = false; break;
case "--no-tools": opts.tools = false; break;
case "--no-schemas": opts.schemas = false; break;
case "--no-reminders": opts.reminders = false; break;
case "--font-size": opts.fontSize = parseInt(argv[++i], 10); break;
case "--min-chars": opts.minChars = parseInt(argv[++i], 10); break;
case "-h": case "--help": opts.help = true; break;
case "-v": case "--version": opts.version = true; break;
default:
if (a.startsWith("--")) {
console.error(`Unknown option: ${a}`);
process.exit(2);
}
}
}
return opts;
}
function printHelp() {
console.log(`claude-image-proxy — token-saving proxy for Claude Code
Renders system prompt + tool definitions as bitmap images. Achieves 65-73%
token savings on Opus 4.7 with 100% reasoning quality preserved.
USAGE
npx claude-image-proxy [options]
OPTIONS
-p, --port <N> Port to listen on (default: 47821)
--no-compress Disable all compression (pure passthrough)
--no-tools Don't compress tool descriptions
--no-schemas Don't compress tool input_schemas (saves most tokens)
--no-reminders Don't compress <system-reminder> blocks
--font-size <N> Render font size in pt (default: 5; <5 fails OCR)
--min-chars <N> Minimum chars to trigger compression (default: 2000)
-h, --help Show this help
-v, --version Show version
USAGE WITH CLAUDE CODE
Terminal 1:
npx claude-image-proxy
Terminal 2:
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude --exclude-dynamic-system-prompt-sections
Tip: add the --exclude-dynamic-system-prompt-sections flag (or set
CLAUDE_CODE_EXCLUDE_DYNAMIC_SYSTEM_PROMPT_SECTIONS=1) so Claude Code's
cwd/git/env data is byte-stable across turns — this lets the rendered
image hit cache instead of being re-rendered every turn.
REQUIREMENTS
Python 3.8+ with Pillow and httpx. Run \`npm install\` and the postinstall
script will install them automatically.
VERIFIED SAVINGS (Opus 4.7, real Claude Code workflows)
- Simple call: 30% token reduction
- Coding task (3 turn): 43% token reduction
- Multi-tool (Grep/Glob/Read/Edit/Bash): 73% token reduction
- 10-turn session: 67% token reduction (≈ $1.18 saved per session)
`);
}
function readPkgVersion() {
try {
return require(path.join(__dirname, "..", "package.json")).version;
} catch { return "unknown"; }
}
function findPython() {
const candidates = ["python3", "python", "python3.12", "python3.11", "python3.10"];
for (const c of candidates) {
const r = spawnSync(c, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
if (r.status === 0) {
const ver = (r.stdout || r.stderr || Buffer.from("")).toString();
const m = ver.match(/Python (\d+)\.(\d+)/);
if (m && parseInt(m[1], 10) >= 3 && parseInt(m[2], 10) >= 8) return c;
}
}
return null;
}
function main() {
const opts = parseArgs(process.argv);
if (opts.help) { printHelp(); process.exit(0); }
if (opts.version) { console.log(readPkgVersion()); process.exit(0); }
const py = findPython();
if (!py) {
console.error("ERROR: Python 3.8+ not found on PATH. Please install Python 3.");
console.error(" macOS: brew install python");
console.error(" Linux: apt install python3 python3-pip");
process.exit(1);
}
if (!fs.existsSync(PROXY_PY)) {
console.error(`ERROR: proxy script not found at ${PROXY_PY}`);
process.exit(1);
}
const env = { ...process.env,
PORT: String(opts.port),
COMPRESS_SYSTEM: opts.compress ? "1" : "0",
COMPRESS_TOOLS: opts.compress && opts.tools ? "1" : "0",
COMPRESS_SCHEMAS: opts.compress && opts.schemas ? "1" : "0",
COMPRESS_REMINDERS: opts.compress && opts.reminders ? "1" : "0",
COMPRESS_TOOL_RESULTS: opts.compress ? "1" : "0",
PLACEMENT: "user",
FONT_SIZE: String(opts.fontSize),
MIN_COMPRESS_CHARS: String(opts.minChars),
};
console.log(`claude-image-proxy v${readPkgVersion()} starting...`);
console.log(` python: ${py}`);
console.log(` port: ${opts.port}`);
console.log(` compression: ${opts.compress ? "ON" : "OFF (passthrough)"}`);
if (opts.compress) {
console.log(` tools: ${opts.tools}`);
console.log(` schemas: ${opts.schemas}`);
console.log(` reminders: ${opts.reminders}`);
console.log(` font: Menlo ${opts.fontSize}pt`);
}
console.log("");
console.log(` Point Claude Code at: ANTHROPIC_BASE_URL=http://127.0.0.1:${opts.port}`);
console.log("");
const child = spawn(py, [PROXY_PY], { stdio: "inherit", env });
const shutdown = () => { try { child.kill("SIGTERM"); } catch {} };
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
child.on("exit", (code) => process.exit(code || 0));
}
main();
+32
View File
@@ -0,0 +1,32 @@
{
"name": "claude-image-proxy",
"version": "0.1.0",
"description": "Token-saving proxy for Claude Code: renders system prompt + tool definitions as images, achieving 65-73% token savings on Opus 4.7 while preserving 100% reasoning quality.",
"bin": {
"claude-image-proxy": "bin/cli.js"
},
"scripts": {
"postinstall": "node scripts/install.js",
"start": "node bin/cli.js"
},
"files": [
"bin/",
"src/",
"scripts/",
"README.md",
"LICENSE"
],
"keywords": [
"claude",
"claude-code",
"anthropic",
"proxy",
"token-optimization",
"prompt-cache",
"opus-4-7"
],
"engines": {
"node": ">=16"
},
"license": "MIT"
}
+75
View File
@@ -0,0 +1,75 @@
"""Pre-render Menlo 5pt glyph atlas + metrics for Zig consumption.
Format (binary, little-endian):
Header (16 bytes):
magic 4 "MNAT"
version 2 u16 = 1
cell_w 2 u16 max glyph width
cell_h 2 u16 cell height (asc+desc)
asc 2 u16
desc 2 u16
first_char 1 u8 = 32 (space)
last_char 1 u8 = 126 (~)
Per-glyph (N glyphs = last-first+1):
advance_px 2 u16
-- packed 1-bit bitmap, cell_w*cell_h bits, row-major
"""
import struct
from PIL import Image, ImageDraw, ImageFont
FONT_PATH = "/System/Library/Fonts/Menlo.ttc"
FONT_SIZE = 5
FIRST = 32 # space
LAST = 126 # ~
font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
asc, desc = font.getmetrics()
cell_h = asc + desc
# Find max glyph width across our range
max_w = 0
for code in range(FIRST, LAST + 1):
ch = chr(code)
w = int(font.getlength(ch)) + 1
if w > max_w:
max_w = w
cell_w = max_w
print(f"Atlas: cell={cell_w}x{cell_h}, asc={asc}, desc={desc}, chars={LAST-FIRST+1}")
with open("/tmp/menlo5_atlas.bin", "wb") as f:
# Header
f.write(b"MNAT")
f.write(struct.pack("<H", 1)) # version
f.write(struct.pack("<H", cell_w))
f.write(struct.pack("<H", cell_h))
f.write(struct.pack("<H", asc))
f.write(struct.pack("<H", desc))
f.write(struct.pack("<B", FIRST))
f.write(struct.pack("<B", LAST))
# Glyphs
for code in range(FIRST, LAST + 1):
ch = chr(code)
advance = int(font.getlength(ch))
img = Image.new("L", (cell_w, cell_h), 255)
d = ImageDraw.Draw(img)
d.text((0, -desc // 2), ch, fill=0, font=font)
# Threshold to 1-bit (anti-aliased pixels -> any non-white -> black)
pix = img.load()
f.write(struct.pack("<H", advance))
# Pack row-major bits: bit=1 means BLACK (ink)
bits = []
for y in range(cell_h):
for x in range(cell_w):
bits.append(1 if pix[x, y] < 200 else 0)
# Pack 8 bits per byte
for i in range(0, len(bits), 8):
byte = 0
for j in range(8):
if i + j < len(bits) and bits[i + j]:
byte |= 1 << (7 - j)
f.write(struct.pack("<B", byte))
import os
print(f"Atlas size: {os.path.getsize('/tmp/menlo5_atlas.bin')} bytes")
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Postinstall: verify Python 3.8+ and install Pillow + httpx (the only deps).
* Idempotent — safe to run on reinstall.
*/
"use strict";
const { spawnSync } = require("child_process");
function findPython() {
const candidates = ["python3", "python", "python3.12", "python3.11", "python3.10", "python3.9", "python3.8"];
for (const c of candidates) {
const r = spawnSync(c, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
if (r.status === 0) {
const ver = (r.stdout || r.stderr || Buffer.from("")).toString();
const m = ver.match(/Python (\d+)\.(\d+)/);
if (m && parseInt(m[1], 10) >= 3 && parseInt(m[2], 10) >= 8) return c;
}
}
return null;
}
function hasModule(py, mod) {
const r = spawnSync(py, ["-c", `import ${mod}`], { stdio: ["ignore", "pipe", "pipe"] });
return r.status === 0;
}
const py = findPython();
if (!py) {
console.warn("\n[claude-image-proxy] WARNING: Python 3.8+ not found.");
console.warn(" Install Python 3 (https://www.python.org/downloads/) then run:");
console.warn(" python3 -m pip install Pillow httpx");
console.warn(" Otherwise the proxy will fail to start.\n");
process.exit(0); // don't break npm install
}
const need = [];
if (!hasModule(py, "PIL")) need.push("Pillow");
if (!hasModule(py, "httpx")) need.push("httpx");
if (need.length === 0) {
console.log("[claude-image-proxy] Python deps already installed.");
process.exit(0);
}
console.log(`[claude-image-proxy] Installing Python deps: ${need.join(", ")}`);
const args = ["-m", "pip", "install", "--quiet", "--user", ...need];
const r = spawnSync(py, args, { stdio: "inherit" });
if (r.status !== 0) {
console.warn("\n[claude-image-proxy] WARNING: failed to auto-install Python deps.");
console.warn(` Please run manually: ${py} -m pip install ${need.join(" ")}`);
}
+972
View File
@@ -0,0 +1,972 @@
"""
Experimental Python proxy for testing system-prompt-as-image compression.
Sits between Claude Code and api.anthropic.com. Forwards Claude Code's own auth
(OAuth bearer + headers) untouched. When COMPRESS_SYSTEM is set, intercepts the
request body and rewrites the system prompt as image content blocks rendered
with a popular terminal font (Menlo) at small pt-size so Anthropic's vision
encoder can OCR it. Logs the full token breakdown (input / output / cache_read /
cache_create) so we can measure the actual win/loss vs plain text.
Usage:
PORT=47821 COMPRESS_SYSTEM=1 python3 proxy_py.py
# in another shell:
ANTHROPIC_BASE_URL=http://127.0.0.1:47821 claude -p "..."
"""
from __future__ import annotations
import base64, io, json, os, sys, threading, traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import httpx
from PIL import Image, ImageDraw, ImageFont
PORT = int(os.environ.get("PORT", "47821"))
COMPRESS = os.environ.get("COMPRESS_SYSTEM", "0") == "1"
FONT_PATH = os.environ.get("FONT_PATH", "/System/Library/Fonts/Menlo.ttc")
FONT_SIZE = int(os.environ.get("FONT_SIZE", "5")) # 5pt verified OCR=99.7% on Opus 4.7
MAX_EDGE = 1568 # Anthropic resizes larger images, destroying tiny-font OCR
MIN_COMPRESS_CHARS = int(os.environ.get("MIN_COMPRESS_CHARS", "2000"))
UPSTREAM = "https://api.anthropic.com"
# Memoize rendered images so the SAME system prompt across turns produces
# byte-identical PNGs (PIL's PNG encoder has tiny non-determinism that breaks
# Anthropic's prompt cache lookup).
import hashlib
_render_cache: dict[str, list[bytes]] = {}
# Inject COMPRESS_SYSTEM as default value for compress_mode placement
# 'system' : put images in the system field (may not be supported by API)
# 'user' : prepend image to first user message (cache_control on the image)
# 'replace_system' : replace system with an empty text + add image to first user msg
PLACEMENT = os.environ.get("PLACEMENT", "user")
_font_cache: dict[int, ImageFont.FreeTypeFont] = {}
def get_font(size: int) -> ImageFont.FreeTypeFont:
if size not in _font_cache:
_font_cache[size] = ImageFont.truetype(FONT_PATH, size)
return _font_cache[size]
_render_dims_cache: dict[str, list[tuple[int, int]]] = {}
def render_chunks(text: str, font_size: int = FONT_SIZE) -> list[bytes]:
"""Render text into the MINIMUM number of MAXIMALLY-PACKED images.
Layout strategy: NEWSPAPER (multi-column). For 26K-char system prompt
rendered at 5pt, a single-column layout would be ~3600 px tall (exceeds
1568 cap → forces multiple images + per-image overhead). Multi-column
packs the same text into a SQUAREish image: width = N_cols × col_width,
height ≤ 1568. One image avoids per-image overhead doubling.
Memoized on text content so the same prompt across turns yields BYTE-
IDENTICAL PNG (PIL's PNG encoder has tiny non-determinism that breaks
Anthropic's prompt cache hash if not memoized).
"""
key = hashlib.sha256(f"{font_size}:{text}".encode()).hexdigest()
if key in _render_cache:
return _render_cache[key]
font = get_font(font_size)
asc, desc = font.getmetrics()
line_h = asc + desc
char_w = font.getlength("M") or (font_size * 0.6)
col_gap_px = 4 # tighter gap — every pixel matters
edge = MAX_EDGE
# Minify: kill trailing whitespace + collapse runs of blank lines.
# Critical because system prompt has lots of blank lines that waste rows.
raw = []
last_blank = False
for ln in text.split("\n"):
ln = ln.rstrip()
if ln == "":
if last_blank:
continue
last_blank = True
else:
last_blank = False
raw.append(ln if ln else " ")
# Force aggressive wrapping at FIXED_COL_CHARS. Narrower cols pack better:
# short lines (bullets, headers) waste the unused chars in their row.
FIXED_COL_CHARS = int(os.environ.get("COL_CHARS", "80"))
lines: list[str] = []
for ln in raw:
if len(ln) <= FIXED_COL_CHARS:
lines.append(ln)
else:
for i in range(0, len(ln), FIXED_COL_CHARS):
lines.append(ln[i:i + FIXED_COL_CHARS])
col_w_px = int(FIXED_COL_CHARS * char_w) + 1
lines_per_col = max(8, edge // line_h)
n_cols_total = max(1, (len(lines) + lines_per_col - 1) // lines_per_col)
img_w = n_cols_total * col_w_px + (n_cols_total - 1) * col_gap_px
pngs: list[bytes] = []
dims: list[tuple[int, int]] = []
max_cols_per_img = max(1, edge // (col_w_px + col_gap_px))
lines_per_img = lines_per_col * max_cols_per_img
for s in range(0, len(lines), lines_per_img):
chunk = lines[s:s + lines_per_img]
c_needed = max(1, (len(chunk) + lines_per_col - 1) // lines_per_col)
# Width: ONLY as wide as the cols actually used in this image
chunk_img_w = c_needed * col_w_px + (c_needed - 1) * col_gap_px
# Height: ONLY as tall as the tallest column in this image
max_col_lines = min(lines_per_col, len(chunk) - (c_needed - 1) * lines_per_col)
if c_needed > 1:
# Earlier cols are full, last col may be partial
tallest = lines_per_col if c_needed > 1 else len(chunk)
else:
tallest = len(chunk)
# All non-last columns are full lines_per_col; last col has the remainder
last_col_lines = len(chunk) - (c_needed - 1) * lines_per_col
tallest = max(lines_per_col, last_col_lines) if c_needed == 1 else lines_per_col
chunk_img_h = line_h * tallest
img = Image.new("L", (chunk_img_w, chunk_img_h), 255)
d = ImageDraw.Draw(img)
for c in range(c_needed):
col_lines = chunk[c * lines_per_col:(c + 1) * lines_per_col]
x = c * (col_w_px + col_gap_px)
for i, ln in enumerate(col_lines):
d.text((x, i * line_h - desc // 2), ln, fill=0, font=font)
buf = io.BytesIO()
# Save as 8-bit grayscale PNG so the rendered image has full contrast
# AND preserves antialiasing. Previously used `P + ADAPTIVE colors=2`
# which picked white+gray instead of white+black on AA-heavy content,
# producing washed-out images that visually look broken (and OCR worse).
# Grayscale PNG bytes are still deterministic → Anthropic cache still hits.
img.save(buf, "PNG", optimize=True)
pngs.append(buf.getvalue())
dims.append((chunk_img_w, chunk_img_h))
_render_cache[key] = pngs
_render_dims_cache[key] = dims
return pngs
def render_dims(text: str, font_size: int = FONT_SIZE) -> list[tuple[int, int]]:
key = hashlib.sha256(f"{font_size}:{text}".encode()).hexdigest()
if key not in _render_dims_cache:
render_chunks(text, font_size)
return _render_dims_cache.get(key, [])
def image_block(png_bytes: bytes, cache: bool = False) -> dict:
blk = {
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": base64.standard_b64encode(png_bytes).decode("ascii"),
},
}
if cache:
# Match Claude Code's extended cache TTL. If we use ephemeral (5m) here
# while CC uses 1h later in the request, Anthropic rejects with
# "ttl='1h' cache_control must not come after ttl='5m'".
blk["cache_control"] = {"type": "ephemeral", "ttl": "1h"}
return blk
def extract_system_text(system_field) -> tuple[str, list[dict] | str | None]:
"""Return (extracted_text, remainder).
remainder is what should stay in the system field (or None to delete it)."""
if system_field is None:
return "", None
if isinstance(system_field, str):
return system_field, "" # replace with empty string
if isinstance(system_field, list):
# Concatenate all text blocks; keep non-text blocks in place.
text_parts = []
kept = []
for b in system_field:
if isinstance(b, dict) and b.get("type") == "text":
text_parts.append(b.get("text", ""))
else:
kept.append(b)
return "\n\n".join(text_parts), kept
return "", system_field
def transform_request(body: bytes) -> tuple[bytes, dict]:
"""Compress system prompt + tool descriptions to image content."""
info = {"compressed": False}
try:
req = json.loads(body)
except Exception as e:
info["parse_error"] = str(e)
return body, info
sys_field = req.get("system")
text, remainder = extract_system_text(sys_field)
# Strip Claude Code's per-turn-random `x-anthropic-billing-header: ...; cch=<rand>;`
# line so the rest renders byte-identical across turns.
billing_line_kept = None
lines = text.split("\n", 1)
if lines and lines[0].startswith("x-anthropic-billing-header:"):
billing_line_kept = lines[0]
text = lines[1] if len(lines) > 1 else ""
# COMPRESS_TOOLS: move tool descriptions (+ optionally schemas) into the
# same image. Replace each tool's description with a tiny stub.
compress_tools = os.environ.get("COMPRESS_TOOLS", "1") == "1"
compress_schemas = os.environ.get("COMPRESS_SCHEMAS", "1") == "1"
tool_text_added = 0
schemas_compressed = 0
if compress_tools and isinstance(req.get("tools"), list):
tool_doc_blocks = []
for t in req["tools"]:
if not isinstance(t, dict):
continue
name = t.get("name", "?")
desc = t.get("description", "")
doc_part = ""
if desc and len(desc) > 80:
doc_part = f"### Tool: {name}\n{desc}"
t["description"] = f"See `{name}` docs in system context image."
elif desc:
doc_part = f"### Tool: {name}\n{desc}"
# Compress input_schema: serialize full schema to text inside the
# image, replace tools[].input_schema with a permissive stub. The
# model still emits well-formed tool_use because it sees the real
# schema in the image; Anthropic accepts any JSON object against
# the {"type":"object"} placeholder.
if compress_schemas and isinstance(t.get("input_schema"), dict):
schema_json = json.dumps(t["input_schema"], indent=1)
if len(schema_json) > 200: # only worth it for non-trivial schemas
doc_part += f"\n#### Schema for `{name}`\n```json\n{schema_json}\n```"
t["input_schema"] = {"type": "object"}
schemas_compressed += 1
if doc_part:
tool_doc_blocks.append(doc_part)
if tool_doc_blocks:
tool_section = "\n\n# Tool Documentation\n\n" + "\n\n".join(tool_doc_blocks)
text = text + tool_section
tool_text_added = len(tool_section)
info["tool_text_added"] = tool_text_added
info["schemas_compressed"] = schemas_compressed
info["system_text_chars"] = len(text)
info["system_text_sha8"] = hashlib.sha256(text.encode()).hexdigest()[:8]
# Dump every system text we see to /tmp so we can diff what changes.
if os.environ.get("DUMP_SYSTEM") == "1":
n = 0
while os.path.exists(f"/tmp/sys_dump_{n}.txt"):
n += 1
with open(f"/tmp/sys_dump_{n}.txt", "w") as f:
f.write(text)
if len(text) < MIN_COMPRESS_CHARS:
info["skipped"] = f"system <{MIN_COMPRESS_CHARS} chars"
return body, info
pngs = render_chunks(text)
dims = render_dims(text)
info["png_sha8"] = [hashlib.sha256(p).hexdigest()[:8] for p in pngs]
info["dims"] = dims
info["total_pixels"] = sum(w * h for w, h in dims)
info["expected_image_tokens"] = sum(max(1, (w * h) // 750) for w, h in dims) + 85 * len(dims)
info["text_tokens_estimate"] = len(text) // 4
info["images"] = len(pngs)
info["png_bytes"] = sum(len(p) for p in pngs)
# Stash the FIRST image for the dashboard preview (so users can SEE what
# we're sending). Cropped to a reasonable size so the page doesn't blow up.
if pngs:
with _session_stats["lock"]:
_session_stats["latest_png_bytes"] = pngs[0]
w0, h0 = dims[0] if dims else (0, 0)
_session_stats["latest_png_meta"] = (
f"{len(pngs)} image(s), {w0}×{h0}px, "
f"{sum(len(p) for p in pngs)} bytes total — "
f"compressed {info['system_text_chars']:,} chars "
f"({info.get('text_tokens_estimate',0):,}→~{info['expected_image_tokens']:,} tokens)"
)
# Anthropic caps cache_control breakpoints at 4 per request. The model's
# cache lookup matches the LONGEST cached prefix, so we only need ONE
# well-placed breakpoint — on the last image of the system+tools render.
# Reminders and tool_result images get NO cache_control (they're still
# in the cached prefix by virtue of position).
image_blocks = [image_block(p, cache=(i == len(pngs)-1))
for i, p in enumerate(pngs)]
info["cc_breakpoints_added"] = 1
msgs = req.get("messages", [])
if PLACEMENT in ("user", "replace_system"):
# Prepend image blocks to first user message as a "context" frame
prefix_text = ("[Context (rendered as image for token efficiency, "
"OCR carefully and treat as authoritative system instructions):]")
first_idx = None
for i, m in enumerate(msgs):
if m.get("role") == "user":
first_idx = i
break
if first_idx is None:
info["skipped"] = "no user message to attach image to"
return body, info
target = msgs[first_idx]
content = target.get("content", "")
if isinstance(content, str):
content = [{"type": "text", "text": content}]
elif not isinstance(content, list):
content = [{"type": "text", "text": str(content)}]
# Compress CC-injected <system-reminder> blocks in first user message.
# These are large static blobs (skill listings, project context) that
# CC injects every turn — perfect cache candidates as images.
# Targeted: ONLY compress blocks that start with <system-reminder>,
# leaving the user's actual prompt (and any other content) as text.
compress_reminders = os.environ.get("COMPRESS_REMINDERS", "1") == "1"
reminder_imgs_added = 0
if compress_reminders and isinstance(content, list):
new_content_parts = []
for blk in content:
txt = blk.get("text", "") if isinstance(blk, dict) and blk.get("type") == "text" else ""
# Heuristic: long system-reminder-style blocks. Cover both the
# opening tag style and large generic system blocks.
is_reminder = txt.lstrip().startswith("<system-reminder>") and len(txt) > 1000
if is_reminder:
extra_pngs = render_chunks(txt)
# NO cache_control: Anthropic caps at 4 breakpoints; the
# system+tools image already anchors the cacheable prefix.
for p in extra_pngs:
new_content_parts.append(image_block(p, cache=False))
reminder_imgs_added += 1
else:
new_content_parts.append(blk)
content = new_content_parts
info["reminder_imgs"] = reminder_imgs_added
new_content = (
[{"type": "text", "text": prefix_text}]
+ image_blocks
+ [{"type": "text", "text": "[End context.]"}]
+ content
)
msgs[first_idx] = {**target, "content": new_content}
# COMPRESS_TOOL_RESULTS: walk ALL user messages and image-compress any
# large tool_result text content. Tool results accumulate in history as
# files are read; compressing them at the source compounds per-turn
# savings across the rest of the session.
compress_tr = os.environ.get("COMPRESS_TOOL_RESULTS", "1") == "1"
tr_imgs_added = 0
if compress_tr:
for mi, m in enumerate(msgs):
if m.get("role") != "user":
continue
content_list = m.get("content")
if not isinstance(content_list, list):
continue
changed = False
new_blocks = []
for blk in content_list:
if (isinstance(blk, dict) and blk.get("type") == "tool_result"):
# Anthropic API constraint: when is_error=true, the
# tool_result content MUST be type=text only (no images).
# Leave error tool_results untouched.
if blk.get("is_error") is True:
new_blocks.append(blk)
continue
inner = blk.get("content")
# tool_result.content can be str or list[block]
# NO cache_control on tool_result images (Anthropic
# caps at 4 breakpoints, and these aren't useful as
# cache anchors — they change every session).
if isinstance(inner, str) and len(inner) > 2000:
pngs_tr = render_chunks(inner)
new_inner = [image_block(p, cache=False)
for p in pngs_tr]
blk = {**blk, "content": new_inner}
tr_imgs_added += len(pngs_tr)
changed = True
elif isinstance(inner, list):
new_inner = []
for ib in inner:
if (isinstance(ib, dict) and ib.get("type") == "text"
and len(ib.get("text", "")) > 2000):
pngs_tr = render_chunks(ib["text"])
for p in pngs_tr:
new_inner.append(image_block(p, cache=False))
tr_imgs_added += len(pngs_tr)
changed = True
else:
new_inner.append(ib)
blk = {**blk, "content": new_inner}
new_blocks.append(blk)
if changed:
msgs[mi] = {**m, "content": new_blocks}
info["tool_result_imgs"] = tr_imgs_added
req["messages"] = msgs
if PLACEMENT == "replace_system":
req["system"] = "Follow the instructions in the first image of the user message exactly."
elif billing_line_kept is not None:
# Replace original system with JUST the billing header line (small, stable
# in spirit but per-turn random — keep it as text so it doesn't pollute
# our cacheable image).
req["system"] = billing_line_kept
# else: leave system unchanged so Anthropic still has guardrails
info["placement"] = PLACEMENT
else:
# PLACEMENT == 'system': put images directly into system field
new_sys = []
if isinstance(remainder, list):
new_sys.extend(remainder)
new_sys.extend(image_blocks)
req["system"] = new_sys
info["placement"] = "system"
info["compressed"] = True
return json.dumps(req).encode(), info
# Running session totals so users can see live savings.
# Persisted to ~/.cache/claude-image-proxy/stats.json on every update so
# restarts don't wipe history. Recent feed + latest PNG stay in-memory only.
import threading, time, collections, pathlib
_STATS_DIR = pathlib.Path.home() / ".cache" / "claude-image-proxy"
_STATS_FILE = _STATS_DIR / "stats.json" # cumulative totals (rewritten atomically)
_REQUESTS_FILE = _STATS_DIR / "requests.jsonl" # full per-request log (append-only)
_REQUESTS_ROTATE_BYTES = 10 * 1024 * 1024 # rotate at 10 MB
_session_stats = {
"requests": 0,
"compressed_requests": 0,
"effective_input_actual": 0.0,
"effective_input_baseline_est": 0.0,
"started_at": time.time(),
"first_seen_at": time.time(), # persists across restarts; "uptime since first install"
"recent": collections.deque(maxlen=50), # in-memory only — restart resets
"latest_png_bytes": None, # in-memory only
"latest_png_meta": "",
"lock": threading.Lock(),
}
def _load_persisted_stats():
"""Load cumulative totals + recent request history on startup."""
try:
if _STATS_FILE.exists():
with open(_STATS_FILE) as f:
d = json.load(f)
_session_stats["requests"] = int(d.get("requests", 0))
_session_stats["compressed_requests"] = int(d.get("compressed_requests", 0))
_session_stats["effective_input_actual"] = float(d.get("effective_input_actual", 0.0))
_session_stats["effective_input_baseline_est"] = float(d.get("effective_input_baseline_est", 0.0))
_session_stats["first_seen_at"] = float(d.get("first_seen_at", time.time()))
saved = _session_stats["effective_input_baseline_est"] - _session_stats["effective_input_actual"]
print(f"[PROXY] loaded persisted stats: {_session_stats['requests']} requests, "
f"~{saved:.0f} effective tokens saved (~${saved * 15.0 / 1e6:.4f})", flush=True)
except Exception as e:
print(f"[PROXY] could not load persisted stats: {e}", flush=True)
# Replay last N entries from requests.jsonl into the in-memory recent feed
try:
if _REQUESTS_FILE.exists():
tail = collections.deque(maxlen=_session_stats["recent"].maxlen)
with open(_REQUESTS_FILE) as f:
for line in f:
line = line.strip()
if not line: continue
try:
tail.append(json.loads(line))
except Exception:
pass
_session_stats["recent"].extend(tail)
if tail:
print(f"[PROXY] replayed {len(tail)} recent requests from {_REQUESTS_FILE.name}",
flush=True)
except Exception as e:
print(f"[PROXY] could not load request history: {e}", flush=True)
def _persist_stats():
"""Atomically rewrite cumulative totals."""
try:
_STATS_DIR.mkdir(parents=True, exist_ok=True)
payload = {
"requests": _session_stats["requests"],
"compressed_requests": _session_stats["compressed_requests"],
"effective_input_actual": _session_stats["effective_input_actual"],
"effective_input_baseline_est": _session_stats["effective_input_baseline_est"],
"first_seen_at": _session_stats["first_seen_at"],
"saved_at": time.time(),
}
tmp = _STATS_FILE.with_suffix(".json.tmp")
with open(tmp, "w") as f:
json.dump(payload, f, indent=2)
tmp.replace(_STATS_FILE)
except Exception as e:
print(f"[PROXY] could not persist stats: {e}", flush=True)
def _append_request_log(entry: dict):
"""Append one JSONL line for the per-request log. Rotate when oversize."""
try:
_STATS_DIR.mkdir(parents=True, exist_ok=True)
# Rotate if file is too big
if _REQUESTS_FILE.exists() and _REQUESTS_FILE.stat().st_size > _REQUESTS_ROTATE_BYTES:
rotated = _STATS_DIR / "requests.jsonl.1"
try: rotated.unlink()
except FileNotFoundError: pass
_REQUESTS_FILE.rename(rotated)
with open(_REQUESTS_FILE, "a") as f:
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
except Exception as e:
# Don't crash request handling on disk errors
print(f"[PROXY] could not append request log: {e}", flush=True)
_load_persisted_stats()
DASHBOARD_HTML = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>claude-image-proxy — live dashboard</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; padding: 24px; background: #0d1117; color: #c9d1d9;
font: 14px/1.45 -apple-system,BlinkMacSystemFont,"SF Mono",Menlo,monospace; }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.01em; }
.sub { color: #6e7681; font-size: 12px; margin-bottom: 22px; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px;
padding: 14px 16px; }
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
color: #8b949e; margin-bottom: 6px; }
.card .value { font-size: 24px; font-weight: 600; color: #e6edf3; font-variant-numeric: tabular-nums; }
.card .small { font-size: 11px; color: #6e7681; margin-top: 4px; }
.pos { color: #3fb950 !important; }
.panel { background: #161b22; border: 1px solid #30363d; border-radius: 10px;
padding: 14px 16px; margin-bottom: 14px; }
.panel h2 { font-size: 13px; font-weight: 600; color: #8b949e; margin: 0 0 10px;
text-transform: uppercase; letter-spacing: 0.08em; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { text-align: left; color: #6e7681; font-weight: 500; padding: 6px 8px;
border-bottom: 1px solid #30363d; font-variant-numeric: tabular-nums; }
th.num { text-align: right; }
td { padding: 6px 8px; border-bottom: 1px solid #21262d; font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: none; }
td.num { text-align: right; }
td.good { color: #3fb950; }
td.warn { color: #d29922; }
td.bad { color: #f85149; }
img.preview { max-width: 100%; image-rendering: pixelated; border: 1px solid #30363d;
background: #fff; padding: 4px; border-radius: 4px; }
.row { display: grid; grid-template-columns: 2fr 1fr; gap: 14px; }
@media (max-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } .row { grid-template-columns: 1fr; } }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: #3fb950; margin-right: 6px; vertical-align: middle;
animation: pulse 2s infinite; }
@keyframes pulse { 50% { opacity: 0.4; } }
</style>
</head>
<body>
<h1><span class="dot"></span>claude-image-proxy</h1>
<div class="sub" id="sub">connecting...</div>
<div class="grid">
<div class="card"><div class="label">requests</div>
<div class="value" id="m_req">0</div>
<div class="small" id="m_req_sub">— compressed</div>
</div>
<div class="card"><div class="label">tokens saved</div>
<div class="value pos" id="m_saved">0</div>
<div class="small" id="m_saved_sub">effective input tokens</div>
</div>
<div class="card"><div class="label">$ saved (opus 4.7)</div>
<div class="value pos" id="m_usd">$0.00</div>
<div class="small" id="m_usd_sub">at $15/M input tokens</div>
</div>
<div class="card"><div class="label">reduction</div>
<div class="value pos" id="m_pct">0%</div>
<div class="small" id="m_pct_sub">vs uncompressed baseline</div>
</div>
</div>
<div class="row">
<div class="panel">
<h2>recent requests</h2>
<table>
<thead>
<tr>
<th>#</th><th>status</th><th>path</th><th class="num">size in</th>
<th class="num">cc</th><th class="num">img tok</th>
<th class="num">actual</th><th class="num">saved</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="panel">
<h2>latest rendered image</h2>
<div id="preview_wrap"><div class="sub">(none yet)</div></div>
<div class="small" id="preview_meta" style="margin-top:8px;color:#6e7681"></div>
</div>
</div>
<script>
async function tick() {
try {
const s = await fetch('/proxy-stats').then(r => r.json());
const r = await fetch('/proxy-recent').then(r => r.json());
document.getElementById('sub').textContent =
`port :__PORT__ · uptime ${formatDuration(s.uptime_sec)} · live`;
document.getElementById('m_req').textContent = s.requests;
document.getElementById('m_req_sub').textContent = `${s.compressed_requests} compressed`;
document.getElementById('m_saved').textContent = numFmt(s.saved_effective_tokens);
document.getElementById('m_saved_sub').textContent =
`${numFmt(s.effective_input_actual)} paid · ${numFmt(s.effective_input_baseline_est)} baseline`;
document.getElementById('m_usd').textContent = `$${s.saved_usd_opus47.toFixed(4)}`;
document.getElementById('m_pct').textContent = `${s.saved_pct.toFixed(1)}%`;
const tbody = document.getElementById('rows');
tbody.innerHTML = '';
let i = 0;
for (const e of r.recent.slice().reverse()) {
const tr = document.createElement('tr');
const statusCls = e.status >= 500 ? 'bad' : e.status >= 400 ? 'warn' : 'good';
const saved = (e.session_saved_so_far_delta || 0);
tr.innerHTML = `
<td>${++i}</td>
<td class="num ${statusCls}">${e.status}</td>
<td>${escapeHtml((e.path || '').slice(0,40))}</td>
<td class="num">${numFmt(e.size_in)}</td>
<td class="num">${e.cc_added ?? ''}</td>
<td class="num">${numFmt(e.expected_image_tokens || 0)}</td>
<td class="num">${numFmt(e.effective_actual || 0)}</td>
<td class="num pos">${saved > 0 ? '+'+numFmt(saved) : ''}</td>`;
tbody.appendChild(tr);
}
if (r.has_preview) {
const wrap = document.getElementById('preview_wrap');
// Show a native-resolution crop so the tiny font is actually readable
// (the full image is 1466×1568, gets unreadably downsampled in the panel).
// Pixelated upscaling via CSS preserves crisp edges.
wrap.innerHTML =
`<img class="preview" src="/proxy-latest-png?crop=480&t=${Date.now()}" `
+ `style="width:100%;image-rendering:pixelated">`;
document.getElementById('preview_meta').textContent =
(r.preview_meta || '') + ' — showing top-left 480×480 crop';
}
} catch (e) {
document.getElementById('sub').textContent = 'proxy unreachable';
}
}
function numFmt(n) {
n = Math.round(Number(n) || 0);
return n.toLocaleString();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
}
function formatDuration(s) {
s = Math.floor(s);
const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), sec = s%60;
return (h>0?h+'h ':'') + (m>0?m+'m ':'') + sec + 's';
}
tick(); setInterval(tick, 2000);
</script>
</body></html>
"""
class ProxyHandler(BaseHTTPRequestHandler):
server_version = "TokenProxy/0.1"
def log_message(self, fmt, *args):
pass
def _serve_stats(self):
with _session_stats["lock"]:
saved = _session_stats["effective_input_baseline_est"] - _session_stats["effective_input_actual"]
pct = (saved / _session_stats["effective_input_baseline_est"] * 100.0
if _session_stats["effective_input_baseline_est"] > 0 else 0)
uptime = time.time() - _session_stats["started_at"]
payload = {
"requests": _session_stats["requests"],
"compressed_requests": _session_stats["compressed_requests"],
"effective_input_actual": round(_session_stats["effective_input_actual"], 1),
"effective_input_baseline_est": round(_session_stats["effective_input_baseline_est"], 1),
"saved_effective_tokens": round(saved, 1),
"saved_pct": round(pct, 1),
"saved_usd_opus47": round(saved * 15.0 / 1e6, 4),
"uptime_sec": uptime,
}
body = json.dumps(payload, indent=2).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _serve_recent(self):
with _session_stats["lock"]:
recent = list(_session_stats["recent"])
has_preview = _session_stats["latest_png_bytes"] is not None
preview_meta = _session_stats.get("latest_png_meta", "")
body = json.dumps({
"recent": recent,
"has_preview": has_preview,
"preview_meta": preview_meta,
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _serve_latest_png(self):
"""Serve the current rendered image. Supports `?crop=N` to return only
the top-left N×N region at native resolution (so humans can actually
SEE the tiny font — at 1466x1568 full-image scaled to 280px wide
the browser turns the antialiased glyphs into unreadable noise)."""
with _session_stats["lock"]:
data = _session_stats["latest_png_bytes"]
if not data:
self.send_response(404)
self.end_headers()
return
# Parse ?crop=N from query string
crop_n = 0
if "?" in self.path:
qs = self.path.split("?", 1)[1]
for kv in qs.split("&"):
if kv.startswith("crop="):
try: crop_n = int(kv[5:])
except: pass
if crop_n > 0:
try:
img = Image.open(io.BytesIO(data))
cw = min(crop_n, img.width)
ch = min(crop_n, img.height)
cropped = img.crop((0, 0, cw, ch))
buf = io.BytesIO()
cropped.save(buf, "PNG", optimize=True)
data = buf.getvalue()
except Exception:
pass # fall back to full image
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _do_proxy(self, method: str):
# Local endpoints — never forwarded to Anthropic.
if method == "GET" and self.path in ("/", "/dashboard"):
html = DASHBOARD_HTML.replace("__PORT__", str(PORT)).encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html)))
self.end_headers()
self.wfile.write(html)
return
if method == "GET" and self.path == "/proxy-stats":
self._serve_stats()
return
if method == "GET" and self.path == "/proxy-recent":
self._serve_recent()
return
if method == "GET" and self.path.split("?", 1)[0] == "/proxy-latest-png":
self._serve_latest_png()
return
try:
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) if length > 0 else b""
# Strip hop-by-hop headers
HOP = {"connection", "keep-alive", "proxy-connection",
"transfer-encoding", "upgrade", "te", "host",
"content-length", "expect", "accept-encoding"}
fwd_headers = {k: v for k, v in self.headers.items()
if k.lower() not in HOP}
fwd_headers.setdefault("anthropic-version", "2023-06-01")
log = {"method": method, "path": self.path, "size_in": len(body)}
if COMPRESS and method == "POST" and self.path.startswith("/v1/messages"):
new_body, info = transform_request(body)
log.update(info)
body = new_body
log["size_after"] = len(body)
with httpx.Client(http2=False, timeout=120.0) as c:
resp = c.request(method, UPSTREAM + self.path,
content=body if body else None,
headers=fwd_headers)
log["status"] = resp.status_code
log["size_out"] = len(resp.content)
log["upstream_ms"] = log.get("upstream_ms") # placeholder if added
# Try plain JSON first; if that fails, parse SSE event stream for usage.
usage = None
try:
usage = resp.json().get("usage")
except Exception:
pass
if not usage and b"event:" in resp.content[:200]:
# SSE: aggregate usage from message_start (input) + message_delta (output).
txt = resp.content.decode("utf-8", errors="replace")
inp = cr = cc = out = 0
for raw in txt.split("\n"):
if not raw.startswith("data: "):
continue
try:
ev = json.loads(raw[6:])
except Exception:
continue
if ev.get("type") == "message_start":
u = ev.get("message", {}).get("usage", {})
inp = u.get("input_tokens", inp)
cr = u.get("cache_read_input_tokens", cr)
cc = u.get("cache_creation_input_tokens", cc)
elif ev.get("type") == "message_delta":
u = ev.get("usage", {})
if "output_tokens" in u:
out = u["output_tokens"]
if inp or out or cr or cc:
usage = {"input_tokens": inp, "output_tokens": out,
"cache_read_input_tokens": cr,
"cache_creation_input_tokens": cc}
if usage:
inp = usage.get("input_tokens", 0) or 0
out = usage.get("output_tokens", 0) or 0
cr = usage.get("cache_read_input_tokens", 0) or 0
cc = usage.get("cache_creation_input_tokens", 0) or 0
eff = inp + cc * 1.25 + cr * 0.10
log["tokens"] = {
"in": inp, "out": out,
"cache_read": cr, "cache_create": cc,
"effective_cost": round(eff, 1),
}
# Update session totals + estimate the baseline (uncompressed) cost.
# Baseline estimate: add back the text-token equivalent of whatever
# we replaced with images. Conservative — assumes baseline would
# also have cached, so we apply the 10% cache_read rate to the
# uncompressed delta.
if log.get("compressed"):
txt_replaced = (log.get("system_text_chars", 0)
+ log.get("tool_text_added", 0)) // 4
img_tokens_est = log.get("expected_image_tokens", 0)
extra_text_input_baseline = max(0, txt_replaced - img_tokens_est)
# CRITICAL: the extra text would have been billed at the
# SAME mix of cache_create/cache_read as the actual call,
# NOT all at cache_read (10%). On cold-start turns where
# cache_create dominates, baseline should be billed at 1.25
# not 0.10 — otherwise we drastically under-estimate the
# savings and the dashboard shows tiny numbers.
cached_total = (cr or 0) + (cc or 0)
if cached_total > 0:
cc_share = (cc or 0) / cached_total
# Effective rate the extra text would have paid:
baseline_rate = cc_share * 1.25 + (1 - cc_share) * 0.10
else:
baseline_rate = 0.10 # fully warm-cache assumption
baseline_eff = eff + extra_text_input_baseline * baseline_rate
else:
baseline_eff = eff
with _session_stats["lock"]:
prev_saved = (_session_stats["effective_input_baseline_est"]
- _session_stats["effective_input_actual"])
_session_stats["requests"] += 1
if log.get("compressed"):
_session_stats["compressed_requests"] += 1
_session_stats["effective_input_actual"] += eff
_session_stats["effective_input_baseline_est"] += baseline_eff
saved_so_far = (_session_stats["effective_input_baseline_est"]
- _session_stats["effective_input_actual"])
# Push compact row for dashboard
row = {
"ts": time.time(),
"method": method,
"path": log.get("path", ""),
"status": log.get("status", 0),
"size_in": log.get("size_in", 0),
"size_out": log.get("size_out", 0),
"compressed": bool(log.get("compressed")),
"cc_added": log.get("cc_breakpoints_added"),
"expected_image_tokens": log.get("expected_image_tokens"),
"input_tokens": inp,
"cache_create": cc,
"cache_read": cr,
"effective_actual": round(eff, 1),
"effective_baseline": round(baseline_eff, 1),
"session_saved_so_far_delta": round(saved_so_far - prev_saved, 1),
}
_session_stats["recent"].append(row)
# Persist cumulative totals (atomic JSON rewrite) + append
# per-request line to the JSONL log so dashboards survive restart.
_persist_stats()
_append_request_log(row)
log["session_saved_so_far"] = round(saved_so_far, 1)
log["session_saved_usd"] = round(saved_so_far * 15.0 / 1e6, 4)
print(f"[PROXY] {json.dumps(log)}", flush=True)
try:
self.send_response(resp.status_code)
for k, v in resp.headers.items():
if k.lower() in HOP or k.lower() == "content-encoding":
continue
self.send_header(k, v)
self.send_header("Content-Length", str(len(resp.content)))
self.end_headers()
self.wfile.write(resp.content)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
# Client (Claude Code) closed before we finished writing. Common
# on stream timeouts / Ctrl-C / fast aborts. Not fatal — just log.
print(f"[PROXY] client disconnect during response: {type(e).__name__}", flush=True)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError) as e:
print(f"[PROXY] transport error: {type(e).__name__}", flush=True)
except Exception as e:
traceback.print_exc()
try:
self.send_response(502)
self.send_header("Content-Type", "application/json")
msg = json.dumps({"error": "proxy_error", "detail": str(e)}).encode()
self.send_header("Content-Length", str(len(msg)))
self.end_headers()
self.wfile.write(msg)
except Exception:
pass
def do_GET(self): self._do_proxy("GET")
def do_POST(self): self._do_proxy("POST")
def do_PUT(self): self._do_proxy("PUT")
def do_DELETE(self): self._do_proxy("DELETE")
def do_HEAD(self): self._do_proxy("HEAD")
def main():
print(f"Python token proxy listening on http://127.0.0.1:{PORT}", flush=True)
print(f" COMPRESS_SYSTEM={COMPRESS} FONT={FONT_PATH}@{FONT_SIZE}pt "
f"PLACEMENT={PLACEMENT} MIN_CHARS={MIN_COMPRESS_CHARS}", flush=True)
srv = ThreadingHTTPServer(("127.0.0.1", PORT), ProxyHandler)
try:
srv.serve_forever()
except KeyboardInterrupt:
srv.shutdown()
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
//! Zig 0.16 build for claude-image-proxy.
//!
//! Targets:
//! zig build # build all
//! zig build render-cli # standalone CLI that renders text → PNG (verifies pipeline)
//! zig build test # run renderer tests
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Find libdeflate. Allow override via LIBDEFLATE_DIR env var; otherwise
// expect the user to either vendor it or have it on the system path.
const libdeflate_dir = b.graph.environ_map.get("LIBDEFLATE_DIR");
const render_exe = b.addExecutable(.{
.name = "render_cli",
.root_module = b.createModule(.{
.root_source_file = b.path("render_cli.zig"),
.target = target,
.optimize = optimize,
}),
});
render_exe.root_module.link_libc = true;
if (libdeflate_dir) |dir| {
render_exe.root_module.addIncludePath(.{ .cwd_relative = b.fmt("{s}/include", .{dir}) });
render_exe.root_module.addLibraryPath(.{ .cwd_relative = b.fmt("{s}/lib", .{dir}) });
} else {
// Try Homebrew on macOS arm64 by default
render_exe.root_module.addIncludePath(.{ .cwd_relative = "/opt/homebrew/include" });
render_exe.root_module.addLibraryPath(.{ .cwd_relative = "/opt/homebrew/lib" });
}
render_exe.root_module.linkSystemLibrary("deflate", .{});
b.installArtifact(render_exe);
const run_render = b.addRunArtifact(render_exe);
if (b.args) |args| run_render.addArgs(args);
const run_step = b.step("render-cli", "Run render_cli to test the pipeline");
run_step.dependOn(&run_render.step);
// Unit tests
const tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("menlo5.zig"),
.target = target,
.optimize = optimize,
}),
});
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run renderer tests");
test_step.dependOn(&run_tests.step);
}
+7
View File
@@ -0,0 +1,7 @@
.{
.name = .claude_image_proxy_renderer,
.version = "0.1.0",
.fingerprint = 0xcbf35837f9c7dd6d,
.paths = .{""},
.dependencies = .{},
}
+222
View File
@@ -0,0 +1,222 @@
//! Menlo 5pt glyph atlas reader + text renderer.
//!
//! The atlas binary (586 bytes, embedded via @embedFile) holds 1-bit glyph
//! bitmaps for printable ASCII (32-126). This file decodes the header at
//! comptime and provides a `renderText` function that produces a grayscale
//! pixel buffer suitable for PNG encoding.
//!
//! Why pre-rendered atlas instead of TTF rasterization at runtime?
//! - Zero C library dependencies (no stb_truetype, FreeType, etc.)
//! - 586 bytes is negligible binary bloat
//! - Anthropic's vision encoder is verified to OCR this exact font/size
//! at 99.7% accuracy on Opus 4.7
const std = @import("std");
const ATLAS: []const u8 = @embedFile("menlo5_atlas.bin");
pub const Atlas = struct {
cell_w: u16,
cell_h: u16,
asc: u16,
desc: u16,
first: u8,
last: u8,
glyph_data_start: usize,
glyph_stride: usize, // bytes per glyph (advance u16 + packed bitmap)
bitmap_bytes: usize, // bytes of packed bitmap per glyph
};
pub fn loadAtlas() Atlas {
std.debug.assert(ATLAS.len >= 16);
std.debug.assert(std.mem.eql(u8, ATLAS[0..4], "MNAT"));
const version = std.mem.readInt(u16, ATLAS[4..6], .little);
std.debug.assert(version == 1);
const cell_w = std.mem.readInt(u16, ATLAS[6..8], .little);
const cell_h = std.mem.readInt(u16, ATLAS[8..10], .little);
const asc = std.mem.readInt(u16, ATLAS[10..12], .little);
const desc = std.mem.readInt(u16, ATLAS[12..14], .little);
const first = ATLAS[14];
const last = ATLAS[15];
const total_bits = @as(usize, cell_w) * @as(usize, cell_h);
const bitmap_bytes = (total_bits + 7) / 8;
return .{
.cell_w = cell_w,
.cell_h = cell_h,
.asc = asc,
.desc = desc,
.first = first,
.last = last,
.glyph_data_start = 16,
.glyph_stride = 2 + bitmap_bytes, // u16 advance + bitmap
.bitmap_bytes = bitmap_bytes,
};
}
fn glyphOffset(a: Atlas, ch: u8) ?usize {
if (ch < a.first or ch > a.last) return null;
return a.glyph_data_start + (@as(usize, ch) - a.first) * a.glyph_stride;
}
pub fn glyphAdvance(a: Atlas, ch: u8) u16 {
const off = glyphOffset(a, ch) orelse return a.cell_w; // unknown -> full cell
return std.mem.readInt(u16, ATLAS[off..][0..2], .little);
}
/// Blit one glyph into the destination grayscale buffer at (dst_x, dst_y).
/// Sets ink pixels to 0 (black). Buffer must be pre-filled with 255 (white).
fn blitGlyph(a: Atlas, ch: u8, dst: []u8, dst_w: usize, dst_h: usize, dst_x: usize, dst_y: usize) void {
const off = glyphOffset(a, ch) orelse return;
const bitmap_start = off + 2;
var bit_idx: usize = 0;
var y: usize = 0;
while (y < a.cell_h) : (y += 1) {
var x: usize = 0;
while (x < a.cell_w) : (x += 1) {
const byte_idx = bit_idx / 8;
const bit = @as(u3, @intCast(7 - (bit_idx % 8)));
const is_ink = (ATLAS[bitmap_start + byte_idx] >> bit) & 1 == 1;
bit_idx += 1;
if (!is_ink) continue;
const px = dst_x + x;
const py = dst_y + y;
if (px < dst_w and py < dst_h) {
dst[py * dst_w + px] = 0;
}
}
}
}
pub const Layout = struct {
cols_per_col: u32, // characters per column
n_cols: u32, // number of newspaper columns
width: u32,
height: u32,
};
/// Compute layout for `lines.len` wrapped lines using newspaper-column packing.
/// Caps longest edge at 1568 (Anthropic's max edge before resampling).
pub fn computeLayout(a: Atlas, lines: []const []const u8) Layout {
const edge: u32 = 1568;
const col_gap_px: u32 = 4;
// Column width: widest line up to ~80 chars (we hard-wrap before this).
var max_chars: u32 = 1;
for (lines) |ln| {
if (ln.len > max_chars) max_chars = @intCast(ln.len);
}
if (max_chars > 80) max_chars = 80;
const col_w_px: u32 = max_chars * a.cell_w + 1;
const lines_per_col: u32 = @max(8, edge / a.cell_h);
const n_cols: u32 = @intCast(@max(@as(usize, 1), (lines.len + lines_per_col - 1) / lines_per_col));
const width: u32 = n_cols * col_w_px + (n_cols -| 1) * col_gap_px;
const height: u32 = lines_per_col * a.cell_h;
return .{
.cols_per_col = lines_per_col,
.n_cols = n_cols,
.width = width,
.height = height,
};
}
/// Allocate + render text into an 8-bit grayscale buffer (255 = white, 0 = black).
/// Returns the buffer and its width/height. Caller owns memory.
pub fn renderText(allocator: std.mem.Allocator, text: []const u8) !struct { pixels: []u8, width: u32, height: u32 } {
const a = loadAtlas();
const col_gap_px: u32 = 4;
// Hard-wrap to 80 chars per line (matching Python proxy's behavior).
const WRAP: usize = 80;
var lines = std.ArrayList([]const u8).empty;
defer lines.deinit(allocator);
var owned: std.ArrayList([]u8) = .empty;
defer {
for (owned.items) |s| allocator.free(s);
owned.deinit(allocator);
}
var it = std.mem.splitScalar(u8, text, '\n');
var last_blank = false;
while (it.next()) |raw| {
// Strip trailing whitespace
var ln = raw;
while (ln.len > 0 and (ln[ln.len - 1] == ' ' or ln[ln.len - 1] == '\t' or ln[ln.len - 1] == '\r')) {
ln = ln[0 .. ln.len - 1];
}
if (ln.len == 0) {
if (last_blank) continue;
last_blank = true;
try lines.append(allocator, " ");
continue;
}
last_blank = false;
if (ln.len <= WRAP) {
try lines.append(allocator, ln);
} else {
var i: usize = 0;
while (i < ln.len) : (i += WRAP) {
const end = @min(i + WRAP, ln.len);
try lines.append(allocator, ln[i..end]);
}
}
}
if (lines.items.len == 0) {
const buf = try allocator.alloc(u8, 1);
buf[0] = 255;
return .{ .pixels = buf, .width = 1, .height = 1 };
}
const layout = computeLayout(a, lines.items);
const total = @as(usize, layout.width) * @as(usize, layout.height);
const pixels = try allocator.alloc(u8, total);
@memset(pixels, 255);
// Render columns left-to-right
const max_chars: u32 = blk: {
var m: u32 = 1;
for (lines.items) |ln| {
if (ln.len > m) m = @intCast(ln.len);
}
break :blk @min(@as(u32, 80), m);
};
const col_w_px: u32 = max_chars * a.cell_w + 1;
var col_idx: u32 = 0;
while (col_idx < layout.n_cols) : (col_idx += 1) {
const col_start = col_idx * layout.cols_per_col;
const col_end = @min(col_start + layout.cols_per_col, @as(u32, @intCast(lines.items.len)));
const x_base = col_idx * (col_w_px + col_gap_px);
var li: u32 = col_start;
while (li < col_end) : (li += 1) {
const line = lines.items[li];
const y = (li - col_start) * a.cell_h;
var px_x: u32 = x_base;
for (line) |ch| {
if (px_x + a.cell_w > layout.width) break;
blitGlyph(a, ch, pixels, layout.width, layout.height, px_x, y);
px_x += glyphAdvance(a, ch);
}
}
}
return .{ .pixels = pixels, .width = layout.width, .height = layout.height };
}
test "atlas loads" {
const a = loadAtlas();
try std.testing.expect(a.cell_w > 0);
try std.testing.expect(a.cell_h > 0);
try std.testing.expectEqual(@as(u8, 32), a.first);
try std.testing.expectEqual(@as(u8, 126), a.last);
}
test "render hello" {
const r = try renderText(std.testing.allocator, "hello world\nsecond line");
defer std.testing.allocator.free(r.pixels);
try std.testing.expect(r.width > 0);
try std.testing.expect(r.height > 0);
// Should have some ink pixels
var ink_count: usize = 0;
for (r.pixels) |p| if (p == 0) { ink_count += 1; };
try std.testing.expect(ink_count > 0);
}
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
//! Small CLI that proves the Zig renderer end-to-end:
//! render_cli <input.txt> <output.png>
//!
//! Reads text, renders via menlo5 atlas, encodes as 2-color indexed PNG with
//! libdeflate (zlib container for IDAT), writes the file. The output should
//! be visually identical to what the Python proxy produces and OCR-able by
//! Opus 4.7 at the same 99.7% accuracy we already measured.
const std = @import("std");
const menlo5 = @import("menlo5.zig");
const c = @cImport({
@cInclude("libdeflate.h");
});
fn writePngChunk(buf: *std.ArrayList(u8), alloc: std.mem.Allocator, chunk_type: *const [4]u8, data: []const u8) !void {
var len_bytes: [4]u8 = undefined;
std.mem.writeInt(u32, &len_bytes, @intCast(data.len), .big);
try buf.appendSlice(alloc, &len_bytes);
try buf.appendSlice(alloc, chunk_type);
try buf.appendSlice(alloc, data);
// CRC32 over chunk_type + data
var crc_data = std.ArrayList(u8).empty;
defer crc_data.deinit(alloc);
try crc_data.appendSlice(alloc, chunk_type);
try crc_data.appendSlice(alloc, data);
const crc = std.hash.Crc32.hash(crc_data.items);
var crc_bytes: [4]u8 = undefined;
std.mem.writeInt(u32, &crc_bytes, crc, .big);
try buf.appendSlice(alloc, &crc_bytes);
}
/// Encode 8-bit grayscale (0=black, 255=white) as a 2-color indexed PNG.
pub fn encodePng(alloc: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) ![]u8 {
var out = std.ArrayList(u8).empty;
errdefer out.deinit(alloc);
// PNG signature
try out.appendSlice(alloc, &[_]u8{ 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A });
// IHDR: 8-bit indexed color
var ihdr: [13]u8 = undefined;
std.mem.writeInt(u32, ihdr[0..4], width, .big);
std.mem.writeInt(u32, ihdr[4..8], height, .big);
ihdr[8] = 8; // bit depth
ihdr[9] = 3; // color type 3 = indexed
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
try writePngChunk(&out, alloc, "IHDR", &ihdr);
// PLTE: 2 colors (0=black, 1=white)
const palette = [_]u8{ 0, 0, 0, 255, 255, 255 };
try writePngChunk(&out, alloc, "PLTE", &palette);
// IDAT: scanlines with filter byte. Map 255→1 (white), 0→0 (black).
const stride = 1 + width;
const raw = try alloc.alloc(u8, stride * height);
defer alloc.free(raw);
var y: u32 = 0;
while (y < height) : (y += 1) {
const off = y * stride;
raw[off] = 0; // filter: None
var x: u32 = 0;
while (x < width) : (x += 1) {
raw[off + 1 + x] = if (pixels[y * width + x] == 0) 0 else 1;
}
}
// Compress with libdeflate (zlib format)
const compressor = c.libdeflate_alloc_compressor(6) orelse return error.OutOfMemory;
defer c.libdeflate_free_compressor(compressor);
const bound = c.libdeflate_zlib_compress_bound(compressor, raw.len);
const compressed = try alloc.alloc(u8, bound);
defer alloc.free(compressed);
const csize = c.libdeflate_zlib_compress(compressor, raw.ptr, raw.len, compressed.ptr, bound);
if (csize == 0) return error.CompressFailed;
try writePngChunk(&out, alloc, "IDAT", compressed[0..csize]);
try writePngChunk(&out, alloc, "IEND", &[_]u8{});
return out.toOwnedSlice(alloc);
}
pub fn main(init: std.process.Init.Minimal) !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const alloc = gpa.allocator();
var it = init.args.iterate();
_ = it.next();
const in_path = it.next() orelse {
std.debug.print("usage: render_cli <input.txt> <output.png>\n", .{});
return error.MissingArg;
};
const out_path = it.next() orelse return error.MissingArg;
// Read input via libc to avoid 0.16 fs API churn
const path_z = try std.fmt.allocPrintSentinel(alloc, "{s}", .{in_path}, 0);
defer alloc.free(path_z);
const fd = std.c.open(path_z.ptr, .{ .ACCMODE = .RDONLY }, @as(std.c.mode_t, 0));
if (fd < 0) return error.OpenFailed;
defer _ = std.c.close(fd);
var buf = std.ArrayList(u8).empty;
defer buf.deinit(alloc);
var tmp: [8192]u8 = undefined;
while (true) {
const n = std.c.read(fd, &tmp, tmp.len);
if (n <= 0) break;
try buf.appendSlice(alloc, tmp[0..@intCast(n)]);
}
const text = buf.items;
// Render
const r = try menlo5.renderText(alloc, text);
defer alloc.free(r.pixels);
std.debug.print("rendered: {d}x{d} ({d} px)\n", .{ r.width, r.height, r.width * r.height });
// Encode
const png = try encodePng(alloc, r.pixels, r.width, r.height);
defer alloc.free(png);
std.debug.print("png bytes: {d}\n", .{png.len});
// Write via libc
const out_z = try std.fmt.allocPrintSentinel(alloc, "{s}", .{out_path}, 0);
defer alloc.free(out_z);
const out_fd = std.c.open(out_z.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(std.c.mode_t, 0o644));
if (out_fd < 0) return error.OpenFailed;
defer _ = std.c.close(out_fd);
_ = std.c.write(out_fd, png.ptr, png.len);
std.debug.print("wrote {s}\n", .{out_path});
}