Skip to content

[Feat] Rust+Skia rendering for all endpoints (63/63 parity, use_skia_plot on by default)#33

Open
MejiroRina wants to merge 123 commits into
mainfrom
feat/rust-skia-render-ir
Open

[Feat] Rust+Skia rendering for all endpoints (63/63 parity, use_skia_plot on by default)#33
MejiroRina wants to merge 123 commits into
mainfrom
feat/rust-skia-render-ir

Conversation

@MejiroRina

@MejiroRina MejiroRina commented Jul 12, 2026

Copy link
Copy Markdown
Member

Summary

Rust + Skia rendering for every drawing endpoint, with Pillow kept as a fail-open fallback.

This is no longer the "all gates off, zero behavior change" PR the original description promised. use_skia_plot now defaults to on and is the only Skia gate — use_skia_card_list, skia_card_list_fallback_to_pillow and use_skia_card_box have all been deleted. Merging this changes what production renders with, so it wants the canary rollout below rather than a quiet merge.

The fallback contract is unchanged and load-bearing: a missing/old native extension, an unsupported primitive, or any Skia error returns None and the endpoint composes with Pillow. The image can also ship with the extension and the gate off (HARUKI_DRAWING__USE_SKIA_PLOT=false) — that is the recommended first step.

What's inside

Renderer. rust/haruki_skia_renderer is a pure IR interpreter (PyO3, gil_used = false): Group / Rect / RoundRect / PieSlice / Image / Text / Shadow / Gradient / BlurGlass / TriangleBg / ImageBg / SelfImage. Version-handshaked — the extension exports IR_CAPABILITY (now 5) and Python refuses anything older, failing open rather than silently dropping features.

One layout, two backends. Every drawing endpoint builds a plot.py widget tree; Pillow draws it with canvas.get_img() and Skia draws the same tree through IRPainter. card/box and then card/list both returned to the shared tree and skia_renderer/card_render.py is deleted — a dedicated builder means two layouts kept in step by hand, and those two had already drifted.

Two hand-built IR paths remain, and are worth knowing about: chart wraps the score raster the pjsekai-scores-rs crate produces (both backends use the same raster; only the watermark footer differs), so it is a thin shell rather than a second layout. honor is the real one — honor/skia.py hand-builds its IR while honor/drawer.py keeps a separate PIL compositor, i.e. exactly the two-layouts-by-hand risk that card_render.py was retired for. It is not a regression in this PR, but it is the next thing to collapse onto the shared tree.

Pillow pre-composition is likewise gone: card thumbnails, avatar frames and the costume preview are now widgets drawn natively by whichever backend runs (CardFullThumbnailBox, PlayerFrameBox, _CostumePreviewBox), so the Skia path emits asset paths into the IR and Rust raster-caches the layers across requests.

Lazy image sources. ImageBox/ImageBg/Painter.paste* accept AssetImageRef | EncodedImageRef | PIL.Image. Assets reach Skia as paths (no Python decode at all) and the Pillow fallback decodes on demand through the global resize cache.

Observability. GET /render-stats counts skia | cache_hit | fallback | disabled | error per endpoint (plus font_fallbacks), and image.response logs backend=. The two heavy-worker endpoints render in a spawned process, so the backend rides home on the payload and the parent replays it — without that they would be invisible.

Two things worth a reviewer's attention

A cross-service cache bug. Haruki-Cloud deliberately bypasses its own render cache for misc/alias-list"Alias-list watermarks include request DT, so we intentionally bypass the render cache here to avoid serving stale timestamps" (client.go:361). But this service kept three result caches for that endpoint (memory, disk, Skia payload), all keyed without dt, silently undoing that. Two requests an hour apart returned byte-identical images; they now differ. Page-level result caches are removed from alias-list, profile, vlive/list and chart — cloud already caches by payload, so a cache here can never hit anyway.

A process-level PIL font cache is a 4-5x throughput LOSS. Pillow guards a FreeTypeFont with a per-object critical section, so on the free-threaded build one shared font object serializes every text measurement in the process (measured: 8 threads 897ms shared vs 207ms per-thread). The font cache is deliberately thread-local. Please don't "optimize" it.

Verification

  • Parity sweep: 63 real payloads, 63 ok, 0 failures (scripts/skia_parity_sweep.py). No pillow-only cases remain — honor was the last one.
  • 221 pytest, ruff clean, 15 Rust tests, clippy/fmt clean.
  • Legacy-baseline diff (scripts/skia_legacy_baseline.py, new): the parity sweep compares Pillow against Skia on the same tree, so it is blind to drift both backends share — which is exactly what a bad widget-tree port produces. Diffing the current Pillow output against the pre-port commit: 45 of 53 comparable endpoints are pixel-identical, none changed size, and the only differences are localized to the card thumbnails on the 8 endpoints that draw them (now rendered at final size instead of composed at art scale and downscaled). That harness caught two real regressions that the 63/63 sweep had been passing over — a level label 4px too high, and translucent thumbnail edges bleeding the background through.
  • CI builds the wheel (linux-x86_64 + macos-arm64), runs the native tests, and builds the Docker image with the extension.

Rollout

  1. Ship the image with the extension and HARUKI_DRAWING__USE_SKIA_PLOT=false. 48h proves the image is harmless.
  2. Turn the gate on. Watch /render-stats: fallback/error should be ~0 and font_fallbacks must be 0 — anything above 0 means a broken font config and wrong images, not just slow ones.
  3. Rollback is an env flip, not a redeploy.

Known blind spot: src/sekai/mysekai/drawer.real.py is gitignored, so the MySekai endpoints are not covered by CI at all. Verify they return 200 against the built image before turning the gate on.

🤖 Generated with Claude Code

MejiroRina and others added 30 commits June 23, 2026 18:12
Render /api/pjsk/card/list and /card/box via a PyO3 haruki_skia_renderer
extension (Render IR v1) with a Pillow fallback. Adds the Python IR
builders, settings/config gates (use_skia_card_list/box, default off),
payload-build and asset-sync scripts, and their tests.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a declarative Render IR v2 (ir.rs) rendered by a general Skia
interpreter (interp.rs) exposed via the render_scene PyO3 entrypoint,
alongside the untouched card list/box path. Coordinates resolve to
absolute canvas space (Group offset/clip -> save/clip/restore) so
BlurGlass backdrop snapshots align with drawing. Node renderers: Group,
Rect, RoundRect (per-corner radii), PieSlice, Image (cubic sampling,
fit/cover/contain/width), Text (baseline policy + align), linear
Gradient fill, BlurGlass, TriangleBg, ImageBg, Watermark; reuses the
existing image cache, font loading, encode, blur-glass and triangle-bg
infrastructure. Documents the v2 architecture and locked decisions.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Build a Render IR v2 scene from CardListIr (card_scene.rs) and render it
via the general interpreter by default; the bespoke draw path stays
behind HARUKI_SKIA_CARD_LEGACY for A/B parity. The thumbnail composite
becomes a sub-Group of layered Image/Rect/Text nodes (approach 1), so no
in-memory image handles are needed. Adds a Shadow node and a Baseline::
Alphabetic policy for the card's raw-baseline text. Output matches the
legacy path at identical size (1036x922 on the 12-card reference).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Build a Render IR v2 scene from CardBoxIr (character groups, headers,
color bars, scaled thumbnails, limited badges, ids) and render it via the
general interpreter by default; the bespoke path stays behind
HARUKI_SKIA_CARD_LEGACY. Variable thumbnail sizes are handled by scaling
each layer offset/size at build time (no matrix scale, stays in absolute
coords). Output matches the legacy path at identical size (1316x368 on
the 12-card reference).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
tests/test_skia_parity.py renders a single Render IR v2 node through both the
Pillow Painter (reference) and the Rust render_scene, asserting exact size
parity (hard) plus a per-node shape check: alpha IoU for fills, ink
bounding-box tolerance for text (IoU is too sensitive to sub-pixel stroke
shifts). On failure it writes an expected|actual|diff triptych to
out/skia-parity/. Covers rect, roundrect, pieslice, linear-gradient rect and
CJK text; skips if the extension/fonts are absent.

Also align Text CjkTop baseline with Painter._text: place the baseline at
pos.y + ink height of the '哇' reference glyph (uniform CJK top-anchor)
instead of the text's own ink bounds.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add IRBuilder (src/sekai/skia_renderer/ir_builder.py) whose method names
mirror Painter but emit declarative Render IR v2 nodes. Port the Card List
layout from Rust to Python (build_card_list_scene) and switch
render_card_list_payload to construct the v2 scene and call render_scene,
so the layout lives in Python and Rust stays a pure interpreter. The
Python-built scene renders byte-identically to the Rust-built scene
(621539 bytes, 1036x922 on the 12-card reference). Adds IRBuilder unit
tests.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Port the Card Box layout (build_box_groups / compute_box_layout packing,
character headers, scaled thumbnails, badges, ids) from Rust to Python
(build_card_box_scene) and switch render_card_box_payload to construct the
v2 scene and call render_scene. Extract the shared list/box helpers
(thumbnail, center-text, notice-title, limited-icon, parse-color) into
card_common.py. Both card endpoints now build their layout in Python with
Rust as a pure interpreter.

A/B on the 12-card reference: size identical (1316x368); pixels differ
only at 32/484288 by a single channel LSB (f64-via-JSON vs direct-f32
representation noise), visually identical. Enable ruff isort
combine-as-imports for the shared-helper import blocks.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Both card endpoints now build their Render IR v2 scene in Python and call
render_scene, so the entire bespoke Rust card path is dead. Delete
card_scene.rs, the render_card_list/render_card_box pyfunctions and their
*_inner draw paths, the card draw helpers, the CardListIr/CardBoxIr IR
structs, build_box_groups/compute_box_layout, the font/profile/legacy
plumbing, and the HARUKI_SKIA_CARD_LEGACY toggle. lib.rs drops from ~1970
to 739 lines and the module exposes only render_scene; the shared render
helpers used by the interpreter (image decode, encode_surface,
load_typeface, blur glass, triangle background, cover image, SimpleRng)
are kept. Rust is now a pure IR interpreter.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add Image (stretch + width fit) and BlurGlass (panel over an opaque
backdrop) parity cases to tests/test_skia_parity.py. Generalize the rig to
multi-node scenes (backdrop + glass) and add a mean-abs-error metric for
the blur-glass case alongside alpha-IoU (fills/images) and ink-bbox (text).
Thresholds calibrated from measured values (Image IoU>=0.97, glass MAE<=8)
and the fragile rect/gradient IoU loosened to 0.95. Image cases skip when
the game asset isn't synced locally.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
scripts/compare_card_render.py renders a card list/box request through both
the Pillow composer and the Skia render_*_payload path, writing pillow.png,
skia.png, a labelled side-by-side.png and metrics.json (size match, MAE,
alpha-IoU). The Pillow box composer can reject some payloads; the script
still emits the Skia render and records the error. Consolidates the
throwaway comparison scripts used during the migration.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The frosted panel edge read flat because draw_glass_overlay used uniform
white strokes. Port Pillow's directional bevel instead: an edge_width-wide
rounded-rect highlight shaded by a diagonal gradient (bright white on the
top-left rim, fading through transparent to a fainter highlight on the
bottom-right), plus a faint inner contact line. This restores the raised-
glass depth and also nudges the blurglass parity MAE from 1.21 to 0.99.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Skia panel only had a downward-offset (and purple-tinted) drop shadow,
so the top/left/right edges looked like they were missing a shadow layer
versus Pillow, whose blurglass shadow is a soft black ring on all four
sides. Replace draw_glass_shadow with blurred black rrects clipped to the
area outside the panel (ClipOp::Difference) so the halo wraps every edge
symmetrically and never darkens the translucent interior. Nudges the
blurglass parity MAE from 0.99 to 0.57.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
draw_blur_glass_rect downsampled, blurred and upscaled the backdrop with
SamplingOptions::default() (NEAREST), so the 2x down/upscale left the frosted
glass visibly blocky versus Pillow's smooth BILINEAR blur. Add a
linear_sampling() helper and use it for all three passes, and drop the blur
sigma from 5 to 4 to match Pillow's effective GaussianBlur radius. The frosted
backdrop is now smooth and matches the Pillow look.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The directional bevel was a thin hard-edged stroke, so it read as a crisp
line rather than Pillow's broad pearly rim. Stroke a wider band centered on
the rim, clip it to the panel and blur it so the highlight fades inward into
the interior (and never bleeds outside), matching Pillow's super-sampled
outline gloss.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Skia composed thumbnails with Catmull-Rom cubic, which came out noticeably
crisper than Pillow (sharpness ~36 vs ~27). Pillow resizes with BILINEAR, so
switch the image-node sampling to bilinear to match its softer character
(brings sharpness to ~31). The remaining gap is Pillow's compose-at-native-
then-downscale pipeline (a larger change), not the filter.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The dead-code cleanup removed the font cache, so every render re-read and
re-parsed the font files (~30ms). Cache typefaces process-wide by (dir, name)
in load_typeface (Typeface is ref-counted and cheap to clone). Card-list
render drops from ~132ms to ~99ms, restoring the ~1.5x speedup over Pillow.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… light

Rework the glass panel edge transition: the light comes from the top-left and
bottom-right toward the center, so both corners now carry a bright sheen that
fades along the rim to the dim diagonal (top-right/bottom-left). Use a 5-stop
gradient (bright corner -> faint shoulder -> transparent middle -> faint
shoulder -> bright corner) that goes straight to transparent in the middle,
and drop the dark inner contact line so there is no shadow-colored layer in
the edge transition.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The wide blurred highlight faded inward over ~8px, which read as a second
inner border ("two layers"). Pillow's edge is a crisp ~edge_w band that drops
straight to the interior. Render the gloss as a tight band hugging the rim
(stroke width edge_w, AA-only blur) and raise the gradient shoulders to half
strength so the highlight reaches along the edges from each corner. Edge
luminance profile now tracks Pillow (band ~198 vs 204, sharp drop to the
fill) instead of an 8px ramp.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
On the cream limited-card panel the gloss faded to the interior over a 2-3px
ramp (an intermediate ~F2EBDF band) that looked like a sandwiched layer. The
ramp came from the AA mask blur on the highlight stroke. Remove it: the
stroke's own anti-aliasing gives a ~1px inner edge, so the gloss now snaps
straight from the rim band (F2F0E3) to the interior fill (E8E4CD), matching
Pillow's sharp edge with no middle layer.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Skill icons (e.g. skill_life_recovery 81x96) were force-stretched to 32x32,
squashing them. Pillow draws them aspect-preserving (width 32) bottom-right
aligned with an 8px margin. Add an `anchor` field to the Image node ([0,0]
top-left default .. [1,1] bottom-right) so `pos` can align the rect's corner
even when `width` fit computes the height, and draw the skill icon with
fit=width + anchor=(1,1) at the bottom-right inset.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The skill icon sat ~4px too high. Pillow's content frame ends a few px above
the card's visual bottom, so its skill icon has asymmetric margins (right ~8,
bottom ~4). Anchor the icon at (CARD_W-8, CARD_H-4) instead of (-8,-8). Icon
bbox now matches Pillow within 1px (rmargin 9, bmargin 5 on both sample cards).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Plain bilinear (MipmapMode::None) aliased on big downscales — the skill icon
(95x96 -> 32px, ~3x) looked rough versus Pillow's area-aware resize. Switch
image sampling to bilinear + MipmapMode::Linear: mild downscales (thumbnails
~1.3x) stay at the base level and keep Pillow's soft character, while large
downscales area-average instead of aliasing. The skill icon is now smooth and
the thumbnail sharpness even edges closer to Pillow (28.6 vs 27).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Skia path re-rendered on every request, so repeated identical requests
paid the full render cost while Pillow served them from its composed-image
cache (~0.5ms vs ~80-365ms). Add a process-wide TTL+LRU payload cache
(card_common._SkiaPayloadCache, sized from the composed-image-cache config)
keyed by Pillow's stable request key plus the output format/quality. Wire it
into render_card_list_payload / render_card_box_payload. Hot-path render now
drops to ~0.2ms (12 cards) / ~1.1ms (60 cards), matching the Pillow cache,
while keeping the ~2-4x cold-render advantage.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The interpreter only had a per-render image cache, so every render re-read and
re-decoded each asset from disk even when the asset was unchanged across
requests. Add a process-wide decoded-image cache (lib.rs load_image_cached),
keyed by (full_path, mtime, size) so entries self-invalidate, with LRU eviction
bounded at 4096 entries. skia Image is ref-counted, so cached clones are cheap
and read-only (output stays byte-identical). The per-render path-keyed cache
stays as an L1 to avoid re-stat within a single render. This mirrors the Pillow
global image pool (CLAUDE.md). Cold render with warm assets drops ~9ms (12
cards) / ~28ms (60 cards), keeping the Skia path ~2x faster than Pillow.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Profiling the cold card_list render (caches warm) showed PNG encode at 54-64%
of native time and drawing second. Two fidelity-neutral optimizations
(verified pixel-identical, max|delta|=0 over 12- and 60-card renders):

1. PNG encode: stop using Image::encode (skia default z_lib_level=6 +
   FilterFlag::ALL, the slowest deflate setting and the renderer's single
   biggest cost). Route through png_encoder::encode_image with z_lib_level=3
   and SUB|UP filters. Encode -57% (45.3->19.4ms 12 cards, 177.7->76.8ms 60
   cards). PNG is lossless so pixels are unchanged; encoded size grows ~9-11%.

2. BlurGlass: snapshot only the panel's region via image_snapshot_with_bounds
   instead of the whole canvas per panel. The full-canvas snapshot was forced
   to a full copy (the shadow write right after breaks copy-on-write) once per
   card. Draw -26% at 60 cards (148.3->109.7ms). Sample coords are rebased by
   the snapshot origin, so sampled pixels are identical.

Net native render: -38% (12 cards) / -43% (60 cards); cold-vs-Pillow goes from
~2.0x to ~3.3-3.4x faster. 4 Rust + 24 Python tests pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/skia-pillow-coverage-gaps.md: a feature-by-feature audit of the
Pillow component library (painter.py + plot.py + draw.py + img_utils.py)
against the Skia Render IR, with full/partial/missing status, per-subsystem
gaps, and a prioritized list of IR primitives to add before migrating the
remaining endpoints. Also track the previously-untracked migration record
doc and cross-link the two.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…orner radii

Extend the gradient/fill layer toward Painter parity (cards stay pixel-identical,
max|delta|=0):
- GradientSpec gains an N-stop `stops` list (color+pos) for linear and radial;
  c1/c2 remain the 2-stop shorthand.
- Radial gradient is now a real Skia radial shader (was a flat center-color stub),
  honoring Painter's center=c2 / edge=c1 convention.
- Shape `stroke` accepts a Fill, so strokes can be gradients (a JSON array still
  parses as a solid color — backward compatible).
- RoundRect gains optional `corner_radii` [UL,UR,LR,LL] for distinct per-corner
  radii, overriding the uniform radius + toggle.
- IRBuilder: linear_gradient gains `stops`; new radial_gradient(); rect/roundrect/
  pieslice strokes accept gradients; roundrect gains corner_radii.
`method` (combine vs separate) is accepted; separate is approximated by combine.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Extend ImageNode toward Painter parity (cards stay pixel-identical, max|delta|=0):
- `tint`: multiply (Modulate, = Painter multiply_image_by_color) or mix
  (alpha-weighted lerp toward color, = mix_image_by_color), via Skia color filters.
- `Fit::Crop`: center-crop to size WITHOUT scaling (1:1), mirroring Painter's
  center_crop_by_aspect_ratio; smaller sources are centered.
- `shadow`: a drop shadow from the image's own alpha silhouette (SrcIn recolor +
  Gaussian blur + offset), mirroring Painter paste(use_shadow=True) — not just the
  rounded-rect Shadow node.
- IRBuilder: image() gains tint/shadow; new image_tint()/image_shadow() helpers.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
MejiroRina and others added 13 commits July 13, 2026 15:42
Co-authored-by: Codex <noreply@openai.com>
Font resolution failures were silent: a misconfigured deployment rendered every
string in sans-serif forever with nothing in the logs. They now log once per
distinct face at ERROR (with the requested name and every path tried) and bump a
counter exposed through renderer_cache_stats() and per-render native_metrics.
The sans-serif fallback itself is unchanged, so the fail-open contract holds.

Also:
- Move the font file read outside the typeface cache lock; a cold miss no longer
  serializes every other render thread.
- Parse the incoming IR inside py.detach so other Python threads run during it.
- Borrow mem-image bytes instead of copying them. PyBuffer::readonly() describes
  the VIEW, not the exporter -- memoryview(bytearray).toreadonly() passes it while
  the bytearray stays writable, and Skia reads those bytes with the interpreter
  detached, so another thread mutating them is a data race on memory Rust holds as
  an immutable slice. Mutable exporters are therefore copied; only genuinely
  immutable ones (bytes, and the chart crate's Rust-owned RasterImage) are borrowed.
- A degenerate raw mem image (e.g. a layout crop clamped to zero width) now skips
  just its own Image node, as it did before validation existed, instead of raising
  and throwing away an otherwise good native render.
- Add the SelfImage canvas-snapshot node (IR_CAPABILITY 4 -> 5) so honor renders in
  one pass; bump the two CI capability assertions to match.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ImageBox, ImageBg and Painter.paste* now accept AssetImageRef | EncodedImageRef |
PIL.Image. The Skia path emits the asset path straight into the IR (Rust decodes
and raster-caches it, cross-request); the Pillow path decodes on demand through the
global resize cache. Canvas.get_img prefetches the refs in a tree concurrently, at
each ImageBox's display size rather than full size -- 696 full-size jackets are
~1.5GiB and would thrash the byte budget, while the 64x64 entries the draw actually
reads total ~11MiB.

New Painter primitives, implemented on both backends: push_clip_roundrect/pop_clip,
shadow_roundrect, and a src_rect argument on paste*/paste_with_alpha_blend that
crops BEFORE the fit (the IR already had Image.source_rect). IRBuilder gains
self_image (canvas snapshot) and splice_root_children.

Two Pillow-fallback pixel bugs this shook out:
- A ref-backed paste resampled BILINEAR while a decoded PIL image resampled BICUBIC
  (Image.resize's default), so the same tree rendered softer purely because its
  source stayed lazy -- 6297 differing pixels on a real stamp asset. Pastes now pin
  BICUBIC, and the resize cache keys on the filter, which it did not: get_img_resized
  already passes LANCZOS, so BICUBIC pastes would otherwise have been served its pixels.
- The paste resized before normalizing the mode, so a palette ("P") source was
  resampled NEAREST in palette space and only then expanded to RGBA.

IRBuilder's PIL font cache stays THREAD-LOCAL by design. Pillow guards a FreeTypeFont
with a per-object critical section, so on the free-threaded build one shared font
object serializes every measurement in the process: 8 threads 897ms shared vs 207ms
per-thread, 16 threads 1785ms vs 381ms. It also no longer caches the load_default
fallback, which would freeze a 10px bitmap face in for the life of the process.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Production could not tell whether a request was served by Skia, by a cached Skia
payload, or had silently fallen back to Pillow. render_stats.py now counts
skia/cache_hit/fallback/disabled/error per endpoint behind GET /render-stats, and
image.response carries a backend= field.

The recording lives inside render_canvas_payload, so every drawer is counted whether
or not it passes an endpoint name -- an un-named caller shows up as "unknown" rather
than as nothing. card_list and chart hand-build their IR and never reach that
function, so they record through the same public primitives; the payload-cache hit
paths record too, or they would systematically under-count themselves.

deck and chara-birthday render inside a spawned heavy worker, whose counters and
contextvars the parent cannot read, so the backend rides home on
EncodedImagePayload.backend and the parent replays it. The Rust font-fallback count
crosses the same boundary via native_metrics.

The Skia payload cache moves to its own module with hit/miss/eviction stats, is
reported by /cache-stats and dropped by the global cache clear.

Skia also gains the canvas-size guard the Pillow path has had. It is deliberately
NOT a mirror of Pillow's 4096x4096 budget: the largest real payload (chart) is
already 14.2 Mpx, i.e. 85% of it, and bouncing such a canvas to Pillow only trades a
working native render for the Pillow assertion -- a 500. The bound catches the absurd
(64 Mpx) and is evaluated inside the pool task, because measuring the tree on the
event loop would serialize every request.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…y pixels

CardFullThumbnailBox and PlayerFrameBox draw their layers through Painter primitives,
so both backends compose them natively: the Skia path emits asset paths into the IR
(the layers land in Rust's raster cache, shared across users) and the Pillow path
draws the same layers on demand. The 700x700 avatar-frame pre-composition and the
whole get_card_full_thumbnail composed/disk cache are gone, and each part is now
resampled once at its final size instead of composed at art scale and downscaled.

The port had drifted from the legacy composer in two ways that the Pillow-vs-Skia
parity sweep is structurally blind to -- both backends drew the same wrong tree, so
it stayed 63/63 green. Caught by diffing against main:

- The level label sat 4px too high. ImageDraw.text anchors the ascender top;
  Painter.text anchors the baseline at y + ink-height("哇"). The y constant was lifted
  straight from the old ImageDraw code, so the label spilled 2 rows above the level bar
  onto the card art. The offset is font- and size-dependent, so it is now derived from
  the metrics rather than folded into the constant.
- The thumbnail lost its opacity. The legacy composer ended with img.putalpha(mask),
  hard-resetting alpha inside the rounded rect; pop_clip only MULTIPLIES alpha, and
  Pillow's paste(im, pos, im) lerps the destination alpha toward the layer's, so
  anti-aliased frame/star edges left the result translucent (361 pixels at alpha ~191)
  and the page background and drop shadow bled through as a halo. The overlays now go
  through paste_with_alpha_blend (true alpha_composite), which is also what Skia already
  did for both paste variants.

Both are pinned by tests that were mutation-checked -- each fails when its bug is
reintroduced. profile also moves to lazy asset refs and passes an endpoint name; it
keeps get_img_from_path for the ImageBg background, which decodes eagerly on purpose.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…points

Every try_render_*_payload now passes a stable endpoint name, so /render-stats and
the backend= log field can attribute a render. The drawers move from
get_img_from_path (full decode, eagerly) to get_asset_image_ref (header probe), which
keeps the pixels out of Python on the Skia path entirely.

Some call sites deliberately stay eager, and changing them is a bug, not a cleanup:

- Anything feeding ImageBg with its default fade=0.1. Fade and blur rewrite pixels in
  the CONSTRUCTOR, so a ref is force-decoded there -- on the event loop, off the thread
  pool -- and never reaches the IR. Only ImageBg(..., blur=False, fade=0) may take a
  ref. This is also fixed for the event background, which had been migrated to a ref
  while still going through the fading ImageBg.
- Anything that touches a real PIL pixel API: _circular_progress_avatar
  (convert/resize/mask), concat_images, and MySekai's site_image, harvest points and
  spawn recolouring.

One semantic narrowing, documented at the gacha fallback chain: on_missing="raise" now
fires on "missing / not an image", not on "cannot decode the pixels", because a header
probe never reads them. A corrupt-but-parseable file renders as the placeholder instead
of falling through to the banner. Missing assets -- the case that chain exists for --
behave exactly as before.

Also converges the last Pillow/Skia mixing: MySekai's tile clip uses the shared
push_clip_roundrect primitive, housing base64 payloads ship as EncodedImageRef, the
costume preview's foreground crop rides through src_rect, and honor renders in a single
pass via the SelfImage node instead of encoding an intermediate PNG and rendering twice.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
add_request_watermark bakes a second-resolution "DT: %Y-%m-%d %H:%M:%S" stamp into the
canvas, taken from the per-request dt. Any result cache whose key drops dt therefore
serves a visibly stale timestamp, and any key that keeps it can never hit -- while its
misses still insert and evict entries of the endpoints whose caches do.

The caller already caches for us. Haruki-Cloud checks its own render cache before ever
calling this service (internal/pjsk/drawing/), and its key strips dt at any depth
(cache_rules.go defaultRenderCacheRule.IgnoreFieldNames). So a page-level cache here is
redundant at best: cloud hits and we are never called, or cloud misses and our equivalent
key misses too.

misc/alias-list was worse than redundant. Cloud deliberately bypasses its own cache for
that endpoint -- "Alias-list watermarks include request DT, so we intentionally bypass the
render cache here to avoid serving stale timestamps" (client.go:361) -- while this service
kept THREE result caches for it (memory, disk, Skia payload), all keyed without dt, quietly
undoing that. The disk layer survived restarts. Two requests an hour apart returned
byte-identical images; they now differ.

Removes those three caches, and the never-hitting page caches on chart and vlive/list along
with the bg_hour quantisation that only existed to make their keys hit (the background hue
is back to the live clock). Endpoint names and counters are kept, so /render-stats can now
measure what the remaining caches (card_box, card_list, honor) actually do in production.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…pers

skia_parity_sweep compares Pillow against Skia on the CURRENT tree, so it cannot see
drift that both backends share -- which is exactly what happens when a Pillow composer is
ported to Painter primitives slightly wrong. CardFullThumbnailBox shipped with a 4px text
offset and translucent overlay edges while the sweep stayed 63/63 green, because both
backends drew the same wrong tree.

skia_legacy_baseline.py renders the same payloads with the Pillow path on a baseline ref
(default main) in a throwaway git worktree and diffs the two images pixel-wise. It uses
only APIs both trees have, so no baseline-side code is needed.

Note it does not use ImageChops.difference(...).getbbox(): getbbox keys off ALPHA, and the
difference of two opaque renders has alpha 0 everywhere, so it reports "identical" no
matter how far the RGB channels drift. That trap cost real time.

Also deletes the GIF/APNG helpers from img_utils (285 lines, zero callers repo-wide).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Skia chapter to the agent guides (mirrored across CLAUDE.md, AGENTS.md and
copilot-instructions.md): the IR-first rule, the fail-open contract, env-only switches
and rollback, the capability handshake, the wheel/CI chain, and the cargo test linker
recipe.

The traps section is the point. Each of these has already cost debugging time:
- The parity sweep only compares Pillow against Skia on the current tree, so drift both
  backends share renders 63/63 green.
- ImageBg defaults to fade=0.1 and rewrites pixels in the constructor, so a lazy ref
  handed to it is force-decoded on the event loop and never reaches the IR.
- Painter.text anchors the baseline; ImageDraw.text anchors the ascender top.
- Pillow's paste lerps the destination alpha, so overlays need paste_with_alpha_blend.
- The resize cache keys on the resample filter; pastes use BICUBIC, get_img_resized
  BILINEAR.

Updates the migration docs with what actually landed, and records the two conclusions we
had to establish rather than assume: a process-level PIL font cache is a 4-5x throughput
LOSS on the free-threaded build, and page-level result caching is redundant with the
caller's cache and poisoned by the DT watermark.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
card/list was the last endpoint with a hand-written IR scene builder. It now draws the
same plot.py widget tree the Pillow path already builds, exactly as card/box did before
it: _build_card_list_canvas is the single layout, compose_card_list_image draws it with
Pillow and try_render_card_list_payload draws it through IRPainter.

A dedicated builder means two layouts that have to be kept in step by hand, and they had
already drifted -- which is precisely the failure mode this retires.

Deletes skia_renderer/card_render.py entirely (the box builder was already gone), shrinks
card_common to the one helper that is actually shared (rare_count), and drops
scripts/compare_card_render.py, which only existed to diff the two layouts.

Retires the card/list-only gates too: use_skia_card_list and
skia_card_list_fallback_to_pillow are gone, and card/list rides use_skia_plot with the
same fail-open contract as every other endpoint.

No regression: full parity sweep is 63/63, and card/list is if anything faster on the
shared tree (skia 0.059s vs the dedicated builder's recorded 0.076s on the same payload).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The harness reported drift on 48 of 52 endpoints — including HEAD against itself. It was
measuring its own setup:

- configs.yaml points assets at a RELATIVE ./data, and data/ is gitignored (859MB of
  untracked art), so the throwaway worktree had none of it and every image in the baseline
  resolved to the missing-image placeholder. The asset tree is now symlinked in.
- The triangle background draws from the UNSEEDED global random module, so two renders of
  the same tree differ by ~12% of pixels on their own. Both sides are now seeded. (This is
  also why the Pillow-vs-Skia parity sweep can only assert a loose mean diff on these
  endpoints: their backgrounds never match at all.)
- The process pool was disabled via a HARUKI_ env var, but the "env beats yaml" precedence
  fix only exists on this branch — on an older baseline the yaml won and the pool stayed on,
  and its spawned workers cannot import a throwaway worktree (BrokenProcessPool). The
  baseline's configs.yaml is now patched directly.
- List-shaped payloads, mysekai (whose drawer is gitignored) and endpoints that simply do
  not exist on the baseline ref were all reported as errors rather than as "no baseline".

A `--ref HEAD` self-check must come back max_delta=0. It now does; before, it did not, which
is the tell that a differential harness is measuring itself.

With a trustworthy baseline, the actual result against the pre-port commit is clean: 45 of 53
comparable endpoints are pixel-identical, no endpoint changed size, and the only differences
are localized to the card thumbnails on the 8 endpoints that draw them (now rendered at final
size rather than composed at art scale and downscaled) plus the seeded triangle background on
alias-list, whose content is untouched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The triage found no regressions. The first run's "48 of 52 endpoints are drifting" was the
harness measuring its own setup (see the harness fix); with a trustworthy baseline against the
pre-port commit, 45 of 53 comparable endpoints are pixel-identical, nothing changed size, and
the only differences are the card thumbnails on the 8 endpoints that draw them — the intended
consequence of drawing each layer at its final size instead of composing at art scale and
downscaling.

Also records the rule the harness earned: a differential harness must self-check against HEAD
(max_delta 0) before you believe a single number it prints.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Brings in the two costume commits that landed on main while this branch was open
(normalized costume lookup IDs and 3D hairstyle IDs).

Merged rather than rebased: the branch is 106 commits ahead of main, and a rebase
conflicted on configs.yaml/pyproject.toml/uv.lock on the very first replayed commit --
files that would keep conflicting across dozens of them -- while also rewriting the
history of an open PR. A merge reaches the same place (the branch now contains main) with
one resolution and no rewrite.

costume/drawer.py took changes from both sides: main's ID display helpers and this
branch's asset-ref loading + _CostumePreviewBox. Verified both survive, and the costume
endpoints still pass the parity sweep.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@MejiroRina MejiroRina changed the title [Feat] Rust+Skia Render IR migration (50/53 endpoints, all gates off) [Feat] Rust+Skia rendering for all endpoints (63/63 parity, use_skia_plot on by default) Jul 13, 2026
MejiroRina and others added 16 commits July 14, 2026 03:55
The docs still described a migration that was gated off, 50/53 wired, and had a dedicated
card scene builder. Audited all six against the code and corrected anything a reader would
take as true today; dated execution-log entries were left as records, not rewritten.

Two things this turned up that were not just wording:

- **Dockerfile asserted IR_CAPABILITY >= 3 while the code requires 5.** A cap-3/4 wheel would
  pass the image self-check, print "self-check passed", and then be rejected at runtime by
  load_native_renderer() — every endpoint silently failing open to Pillow, which is exactly the
  stale-wheel failure the self-check exists to catch. The floor is now parsed out of
  REQUIRED_NATIVE_IR_CAPABILITY in canvas.py, so it cannot drift again.

- **"No endpoint hand-builds IR" was false**, and I had repeated it in the PR description. honor
  and chart still assemble their own IR with IRBuilder and never touch the widget tree. chart is a
  thin shell (both backends use the crate's raster; only the watermark differs), but honor keeps a
  full second layout in honor/drawer.py — the same two-layouts-by-hand hazard that retired
  card_render.py. Now documented as an open item rather than claimed as done.

Also deletes render_cached_canvas_payload: it had zero callers once page-level caching was
removed, and its docstring advertised itself as the drawers' shared entry point. The behaviours it
was tested for (fail-open, disabled short-circuit, the canvas-size guard) live in
render_canvas_payload, and the tests now pin them there. Fixes two docstrings that were lying:
honor/skia.py still described the two-pass render that SelfImage replaced, and
record_skia_cache_hit named callers that do not call it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
CI runs ruff over `src tests scripts`, but my local gate only covered `src/ tests/`, so the
edit that dropped the retired card_render mapping from the parity sweep left the file
unformatted and lint-test failed in 9s.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
**Font self-check.** A missing font is not a slow render, it is a WRONG one: Pillow degrades to a
10px bitmap face and Rust to sans-serif, silently, on every string of every image, until someone
notices by eye. The two layers need different answers, and conflating them is why "just disable
Skia on a font error" would not have worked:

- Pillow cannot resolve it -> BOTH backends are broken, so turning Skia off fixes nothing. Refuse
  to start. A deploy that fails fast beats one that serves thousands of wrong images.
- Only the native renderer cannot resolve it (different font dir in the image, a wheel built
  against another layout) -> Pillow still renders correctly, so disable Skia and keep serving.
  This is the case the rule is actually for.

The Pillow probe goes through get_font() itself rather than re-implementing its path search, so it
cannot drift from the real lookup. The native probe renders a tiny scene per font and reads
native_metrics["font_fallbacks"] — Rust does not fail on a miss, it counts one.

**Structural guards.** Both architectural failures this repo actually suffered were structural, not
visual, and neither would have been caught by a pixel test:

- test_every_drawing_route_has_a_skia_path: an endpoint added during the migration could silently
  ship Pillow-only forever — never a failure, just a permanent absence from /render-stats. It walks
  the real route tree (FastAPI wraps included routers rather than flattening them). The two
  heavy-worker routes are exempt because their Skia call lives in the worker, and a second test
  proves that exemption is still true rather than a hiding place.
- test_no_new_hand_written_scene_builders: a dedicated IR scene builder means the layout exists
  twice in Python and has to be kept in step by hand — exactly how card/list drifted, and why
  card_render.py was deleted. chart is exempt (both backends use the crate's raster; the IR is only
  the watermark shell); honor is exempt for now and the entry says it should disappear once honor
  moves onto the shared tree.

Mutation-checked: adding an IRBuilder import to a drawer trips the guard.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
honor was the last endpoint whose layout existed twice in Python: a pure-Pillow composer in
honor/drawer.py and a hand-built IR scene in honor/skia.py. They had already drifted — the IR
cropped the bonds chara icons crop-then-scale while Pillow did resize-then-crop, a real RGB
maxΔ 52 across 5513 px that the parity sweep never saw because its diff drops alpha.

Now there is one layout: honor/widget.py's HonorBadgeBox, a Widget emitting absolute-coordinate
Painter ops (the CardFullThumbnailBox shape). Pillow executes them, IRPainter lowers the same ops
to IR. honor/skia.py keeps only the raster watermark footer — a SelfImage snapshot of the canvas,
which no widget can express — and splices the shared tree's scene into it. The drift is gone
(maxΔ ≤ 1) and all 11 pixel baselines, including three real honors embedded in profile, reproduce
byte-identically.

New shared primitives, both backends:
- Painter.push_mask/pop_mask — an arbitrary-image alpha mask (the bonds putalpha). Multiply on
  both sides: DstIn == putalpha exactly whenever the masked layer is opaque, which is honor's case.
- Painter.paste_src — Porter-Duff Src (the mask-less Image.paste). The base art IS the canvas in
  the legacy composer, and Pillow's paste-lerp reads the destination's RGB even under zero alpha,
  so neither paste nor paste_with_alpha_blend reproduces it.
- Canvas.get_img_sync — custom_profile calls the honor composer from sync code.
- canvas.build_canvas_ir — public canvas->IR lowering, so a caller can splice a widget tree into a
  larger scene instead of hand-building one.

Four things the adversarial review caught, all fixed:

- **paste_src diverged silently across backends.** Pillow replaced the destination; Skia composited
  over it. They only agreed on an empty destination, which was a prose caveat on a PUBLIC primitive
  — a footgun, not a contract. Skia now does true Src via a new ImageNode blend field
  (IR_CAPABILITY 5 -> 6). Verified: pasting a half-transparent image over an opaque one now gives
  identical pixels on both backends; before, Skia bled the background through.
- **The anti-drift guard was weakened in the same change that had to satisfy it** — narrowed from
  "IRBuilder" to "IRBuilder(" because build_canvas_ir's return annotation tripped it. But
  build_canvas_ir hands back a live mutable builder, so a caller could hand-build a second layout
  onto it without ever naming IRBuilder. The guard now watches both doors, by scanning imports via
  ast (a text scan tripped over its own explanatory comment). Mutation-checked on both.
- **Fail-open was broken**: asset loading moved into the async body under a 3-exception allowlist,
  so a DecompressionBombError from a corrupt PNG would escape, skip the outcome record, and 500
  instead of falling back to Pillow.
- **A Canvas built inside an open widget context was silently adopted as a child of it**, corrupting
  the outer layout. No live caller hit it, but `with Canvas(): with VSplit():` is the idiom of every
  drawer, so the first one to compose a badge inline would have. A Canvas is a page root and now
  detaches itself.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The check I shipped one commit ago could never fire. It tested
`isinstance(get_font(name, 20), ImageFont.FreeTypeFont)` — but modern Pillow's `load_default()`
fallback is ALSO a FreeTypeFont (its bundled Aileron face), so every name looked resolved even
against an empty font dir. The startup guard protected nothing.

What actually distinguishes them is the file the face came from: a resolved font's `.path` is the
font file, the fallback's is an in-memory buffer. Verified against an empty font dir: the check now
lists all four fonts as missing and refuses to start; before, it reported success.

This surfaced because CI's lint-test job runs pytest with NO fonts at all (they are OFL/CC licensed
and too large to vendor, so data/ is gitignored and only the native-tests job downloads them). Tests
whose subject is real font metrics — text placement, the FreeType font cache, the startup check —
are meaningless there: Pillow silently swaps in a 10px bitmap face, so they either fail or, worse,
pass while measuring the wrong thing. They now take a `real_fonts` fixture (tests/conftest.py) that
skips them when the configured fonts do not resolve, rather than leaving it to luck which ones
happen to survive.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ts fonts

Two problems with the font self-check as shipped, both surfaced by CI.

**Severity was flat.** A missing TEXT face means every string on every image renders wrong —
refusing to start is right. A missing EMOJI face only means emoji do; the text is still correct and
the service is still useful. Refusing to boot over a decorative face is a self-inflicted outage.
The check now separates the two: text -> refuse to start, emoji -> loud ERROR and keep serving.
The native probe skips emoji entirely for the same reason (and where Pillow cannot resolve it
either, both backends degrade identically, so disabling Skia would fix nothing and cost the
speedup).

**The smoke job had no fonts.** free-threaded-smoke actually boots the server, so the new guard
stopped it dead. The fix is not to weaken the guard: that job has been rendering images with
Pillow's fallback face and asserting they were fine — it was validating a broken configuration. It
now downloads and caches the same fonts the native-tests job does, and passes the emoji name CI
actually ships.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The perf backlog aimed at the Rust renderer, but profiling put the hotspot
somewhere else entirely. On inventory_list (1.673s) native was only 0.165s
(~10%) while draw->IR was 1.202s, and cProfile pinned it on Font.getsize:
6816 calls / 0.966s, 84% of the whole draw pass. Layout is what measures --
every widget sizes itself from its text, and the tree re-measures the same
strings while sizing.

- painter: process-level bbox cache keyed on (font file, size, text), with a
  separate pool for emoji strings (which measure through getsize_emoji).
- utils: memoize the realpath walk. Path.resolve() lstats every path COMPONENT,
  once for the base and once for the asset, so music_list's 696 jackets cost
  33134 lstat calls per render. Collapse is_file()+stat() into one syscall too.
- ir_painter: _image_ref resolved every image node; route it through the same
  memo. lstat is now 5 calls per render, down from 33134.
- interp.rs: stop building an emoji Font for text with no emoji codepoint, and
  measure the advance only for Center/Right (Left never used it).

The stat itself stays live -- its mtime/size key every image cache, so caching
it would make an asset sync silently not happen. Mutation-tested.

Sweep skia 9.79s -> 5.41s (1.81x), pillow 20.86s -> 16.67s; inventory_list 7.3x,
gacha_list 4.2x. 63/63 parity, and the legacy Pillow baseline is 55/55
max_delta=0 (pixel-identical). The Rust half is honestly worth only ~0.4% --
verified pixel-neutral by byte-comparing a background-free text scene across
alignments, emoji, letter-spacing and baselines -- and the doc now says so
rather than claiming the win.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Four docs still said IR_CAPABILITY was 5; the code (Rust, canvas.py) and both
CI smoke assertions have been 6 since the ImageNode.blend Src channel landed.
The todo also still listed the structural anti-drift CI test as open when
tests/test_route_render_contract.py has been guarding it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
_collect_asset_refs recorded an ImageBox's display-size hint, then the
prefetch_image_sources loop overwrote it with None whenever a widget named
its own image among the extras. CardFullThumbnailBox does exactly that:
layers.base goes to super().__init__() and is also first in the extras list,
so both entries key on the same id(ref).

Every card thumbnail was therefore prefetched at full source size instead of
its 48x48 display size. On card/box that is 1404 thumbnails: 88.4 MB decoded
instead of 13.0 MB, a thumbnail cache running at a 0% hit rate while evicting
761 of its own entries mid-render, and 2087 MB of peak RSS at 8 concurrent
renders instead of 1384 MB.

setdefault, so an unhinted extra can no longer displace a hint. Pixel-neutral:
parity 63/63, legacy Pillow baseline 55/55 max_delta=0.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
It exists to dodge the GIL. On free-threaded 3.14 there is no GIL to dodge,
but the cost of pickling every decoded image across the process boundary is
still there in full. Measured on card/box at concurrency 8: 1.35 r/s with the
pool against 2.00 r/s without it (+48%), and every other heavy endpoint was
8-10% slower with it too. Not one endpoint gained.

The memory-isolation argument does not hold either: total RSS across all
python processes is the same with the pool on (2384 MB) or off (2325 MB). It
relocates memory into six spawned children rather than saving any -- children
that each re-import the app and build their own copy of every cache, inside
whatever cgroup the container is given.

Gone with it: image_to_id / id_to_image / image_dict, the marshalling that
existed only to survive the pickle. It ran on the thread path as well, so
every card/box render was swapping 9426 PIL images out to "%%image%%<id>"
strings and back for a boundary it never crossed. Deleting it is not a
measurable speedup (0.987s vs 0.964s, noise), only less machinery.

The isolated heavy_render_pool (deck_recommend / chara_birthday) is a
different thing and is untouched.

Parity 63/63. Legacy Pillow baseline: pixel-identical except event_planner,
which drifts by ~66px against itself run to run -- the unseeded global RNG in
the triangle background, tracked separately.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Painter scattered the triangles from Python's unseeded global `random`; the
Rust renderer rolled its own from a (width, height, hour)-seeded xorshift.
Different PRNG, different normal-variate algorithm, different rounding, even a
different number of preset colours. So:

  - the two backends' backgrounds could never match, and the parity sweep had
    to keep a loose threshold on every canvas -- a blind spot exactly where
    drift hides;
  - Pillow did not reproduce itself (~12% pixel churn between two renders of
    the same tree), which once had the legacy-baseline harness reporting that
    everything had drifted when nothing had;
  - Rust did not either: its seed took `hour` at millisecond precision, so
    "deterministic" rolled over every 3.6 seconds.

Sharing a seed would not have been enough -- the PRNGs, the normal variates
and the rounding would all have had to be mirrored across the FFI, which is
the same logic written twice and exactly what the IR-first rule forbids.

So the scatter stops being rendering and becomes data. base/triangle_bg.py
generates the triangle list from a seed quantized to the whole hour; Painter
draws that list and IRPainter ships the same list on TriangleBg.tris
(IR_CAPABILITY 6 -> 7). The divergence is gone by construction and Rust loses
276 lines: SimpleRng, weighted_edge, time_lightness and the preset-colour
derivation all retire. Pillow's triangles are now placed at subpixel
coordinates too, since Skia draws the path at float ones and no amount of
seed-sharing fixes a half-pixel offset.

The palette still follows the fractional hour, on purpose -- the colours are
meant to drift with the clock. Only the layout is pinned, so a harness that
wants byte-stable output pins HARUKI_BG_TEST_HOUR; skia_parity_sweep.py now
does, as skia_legacy_baseline.py already did.

Both backends now reproduce themselves, and on a bare background canvas they
agree to mean=0.55 / max=41 where they used to draw entirely different
triangles. Across two full sweeps the non-deterministic endpoint count falls
51 -> 8; the remaining 8 render a live countdown ("距离活动结束还有…"), which
is content changing with the clock, not the renderer.

Parity 63/63. Legacy baseline: the 7 endpoints without a triangle background
are pixel-identical; the 48 with one differ only in the background
(mean_delta 0.1-1.2 of 255). No content-drawing code was touched.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Every memory number this project had was taken on macOS/libmalloc, which is
not what it ships on -- and which turned out to be unusable for the purpose:
under pressure the kernel reclaims pages out from under the measurement and
every process in the tree "shrinks" fourfold at once. Measured inside the
image instead (OrbStack, linux, glibc).

The heavy-render worker pool is the dominant memory term, and nobody had ever
weighed it. The workers are spawned at boot and are only ever recycled on
crash or timeout, and each builds its own asset/font/raster caches: one grows
from 47 MB idle to ~500 MB after serving, and eight of them plateau at
~2.15 GB. Container steady state reached 2.54 GB -- so `memory: 2G` was not
survivable. The first burst of 8 concurrent deck/recommend would OOM-kill it.
The growth does converge (flat after 30 tasks), so it is a cache working set,
not a leak, and config is enough to fix it.

Eight workers were also just oversubscription. deck_recommend is a CPU-bound
search (~12s, not the 0.2s the parity sweep shows -- that path does not run
the recommendation), so on a 4-CPU container the extra workers only contend:
24 requests at concurrency 8 gave p50 13.39s / 1075 MB with 2 workers against
12.53s / 2580 MB with 8. Six percent of latency for 1.5 GB.

  isolated_worker_pool_size  8 -> 4   (size it to the CPUs, not above them)
  readiness_unhealthy_rss_mb 4096 -> 1536
  docker-compose memory      2G -> 4G,  cpus 2 -> 4

Verified in a 4G container: a mixed load of 12 card/box + 8 deck/recommend at
concurrency 10 peaks at 1790 MB (44% of the limit), all 20 responses OK,
oom_kill 0.

Also recorded, not fixed: readiness_unhealthy_rss_mb reads /proc/self/status
VmRSS, i.e. the PARENT only (debug.py:189). It cannot see the heavy workers,
which is where most of the container's memory lives. At 4096 it could never
fire before the kernel killed the cgroup; 1536 at least makes it reachable
against the parent's measured 765 MB ceiling, but the real fix is to read the
cgroup.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Every drawing endpoint in the service returns through encoded_image_payload_to_response
/ image_to_response, and both built a StreamingResponse over an io.BytesIO.

There was nothing to stream -- the encoded bytes are already whole in memory, so
wrapping them saved no memory at all. What it did do was hand Starlette a *sync*
iterable, which Starlette drives through iterate_in_threadpool()... and BytesIO
implements iteration as readline(). A PNG is binary, so the body was split on every
0x0A byte: ~384 bytes per chunk, i.e. ~2,300 thread-pool round-trips and ~2,300 ASGI
body messages for one 870 KB image, ~19,000 for a 7.3 MB card/box. Content-Length was
lost with it, so every response also degraded to chunked transfer-encoding.

deck/recommend, 24 requests at concurrency 8:

               before     after
  solo (warm)   0.43s     0.07s
  p50          10.24s     0.22s
  wall         30.8s      0.97s
  throughput    0.78 rps  24.8 rps    (32x)

It hid because it is functionally correct -- every byte arrives, the image is fine --
and because the server could not see it: request.end stamps its elapsed when the
endpoint returns the Response object, while the body is sent afterwards. The log said
0.12s, the client waited 10s, and the CPU sat 95% idle the whole time.

The regression guard asserts the ASGI body-message count is 1. Byte content cannot
catch this; the broken version's bytes are correct too. Only the message count is.

This also retracts the previous commit's story that deck_recommend is "a CPU-bound
search taking 12-13s": src/sekai/deck/ contains no search at all (the deck is chosen
by the caller and arrives in DeckRequest.deck_data; this service only draws it). The
12-13s was this bug. The 8-vs-4 worker sizing still stands, but on re-measured
numbers: 4 workers now beat 8 on both p50 and throughput.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Every capacity number in the doc was measured while each image response was being
line-chunked into thousands of ASGI messages, i.e. while most requests were stuck in
the send path instead of rendering. Re-ran the mixed load in the 4G container against
the fixed build: idle 588 MB, peak 2039 MB (50% of the limit), 20/20 OK, oom_kill 0.

The peak is 250 MB HIGHER than before the fix, which is the fix working: responses are
32x faster, so more renders are genuinely in flight at once. Any capacity figure taken
on the old code reads low for that reason.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The card/box claim survives: cgroup peak still barely grows with concurrency
(1081/1155/1182 MB at 4/8/12), matching the pre-fix shape, so that conclusion was
not contaminated by the chunking bug.

The parent's own RSS was. It scales with concurrency -- 483/757/838/958 MB at
1/4/8/12 -- where the pre-fix run reported a 765 MB ceiling, because back then the
requests were serialized in the send path and far fewer renders were ever in flight
at once. That number matters because the parent's VmRSS is the only thing /ready can
see, which makes readiness_unhealthy_rss_mb an in-flight gate (~20 concurrent) rather
than the memory-pressure gate it is named after.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
/ready's memory gate read /proc/self/status VmRSS, which is the parent process only.
The heavy-render workers are separate processes and are where most of the container's
memory lives: measured at idle, the parent sits at 267 MB while the cgroup is already
at 585 MB, and each warm worker holds ~500 MB. The gate was structurally blind to more
than half the memory it was supposed to protect, so it could not fire before the kernel
OOM-killed the cgroup.

read_cgroup_memory() reads memory.current against memory.max (cgroup v2, falling back
to v1's usage_in_bytes/limit_in_bytes) and readiness_unhealthy_cgroup_percent (default
90) gates on the ratio. A percentage, not an absolute MB, precisely because the old
absolute threshold had been set to 4096 -- above the container's own 4G hard limit,
i.e. unfirable by construction. Outside a memory-limited cgroup (bare metal, macOS,
unconstrained container) it returns None and the gate does not apply rather than
guessing.

readiness_unhealthy_rss_mb goes to 0. The parent's RSS scales with concurrency
(483/757/838/958 MB at 1/4/8/12 concurrent card/box), so it was really a miscalibrated
concurrency gate -- it would trip around 26 in flight, ahead of the explicit inflight
gate at 48 -- while still not seeing the workers. Concurrency has its own gate; memory
now has a correct one.

Verified against a real container cgroup, not just fixtures: /ready reports 584.8/4096
MB and agrees with what the kernel reports, and lowering the threshold under live usage
flips it to 503 with reason "cgroup_percent 9.4 >= 5 (385/4096 MB)".

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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