Skip to content

Memory-aware rendering, ESP32 reliability, state-field showIf, and new apps#252

Merged
mariusandra merged 51 commits into
mainfrom
june-30
Jul 2, 2026
Merged

Memory-aware rendering, ESP32 reliability, state-field showIf, and new apps#252
mariusandra merged 51 commits into
mainfrom
june-30

Conversation

@mariusandra

@mariusandra mariusandra commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Memory leak on HDMI/framebuffer frames (fixed)

  • Root cause: every Linux build shipped with -d:malloc — a no-op define (Nim's real flag is -d:useMalloc) — so binaries ran Nim's page allocator and the mallopt/malloc_trim tuning in setupRenderMemory()/reclaimRenderMemory() never governed image buffers. RSS ratcheted ~3MB/render (frame 60: ~180MB after boot → ~300MB → OOM reboot on a 451MB Pi). HDMI + local JPEG frames churned the most; e-ink rendered too rarely to notice.
  • Fixed to -d:useMalloc in the deployer (FRAMEOS_NIM_FLAGS), frameos/Makefile, and the driver/scene library build tools; deployer tests updated (assertions + a hermeticity fixture so a ../pixie checkout doesn't break them on dev machines).
  • Verified on hardware: frame 60 RSS now cycles 49–84MB and returns to baseline after renders.

Memory-aware rendering pipeline

  • Pixie fork (pinned via nimble to FrameOS/pixie): runtime per-decode budgets, streaming baseline-JPEG decode through a 32KB window, decodeJpegInfo dimension probe, aspect-correct ScaledDecodeFit (stretch/cover/contain) across all scaled decode paths.
  • PNG decodes now unfilter in place (pixie 853485d): the decode plan for a canvas-sized RGBA PNG drops from pixels + 2× scanlines (~4.5MB at 480×800) to pixels + scanlines (~3MB), fitting the budget on ESP32-class frames. PNGs can't stream like JPEGs (zippy has no streaming inflate), so multi-megapixel photos remain JPEG territory.
  • frameos: availableRenderBytes() (Linux MemAvailable / ESP32 PSRAM), per-render decode budget refresh, decode-into-canvas hints for full-frame render/image + data/localImage chains, file-read budgets, ensureRenderAllocation guardrails.
  • ESP32 OOM containment: budgets first, then a patched allocator that releases a 1MB PSRAM emergency reserve, then a fatal-OOM longjmp back to the render entry point — render fails, device survives.

showIf visibility conditions for state fields

The app-config showIf system now applies to scene state fields and custom event fields, with one set of semantics (top-level conditions OR-ed, and blocks AND-ed, values coerced by field type) evaluated consistently on every surface:

  • The condition matcher moved out of appNodeLogic into a shared frontend/src/utils/showIf.ts, used by the diagram editor, scene control forms (ExpandedScene), split-screen options, schedule state payloads and template previews. Hidden fields are excluded from submission.
  • The field definition editor gained a visual condition builder (ShowIfEditor: field/operator/value rows, match-any/match-all, raw-JSON fallback for complex conditions) for both state fields and custom event fields.
  • The on-frame /c control page evaluates conditions live in the browser and server-side for the initial render (frameos/utils/show_if.nim); compiled-scene codegen emits showIf + default values into StateField literals; interpreted scenes parse them from scenes.json.
  • Along the way, interpreted scenes now respect access: private (previously every state field was externally visible and settable — the long-standing TODO), and json state field defaults parse like compiled scenes.

New apps

  • render/chart — line/bar/area charts from JSON (accepts [1,2,3], label/value pairs, or {labels, series}), multi-series palette, auto/fixed y-ranges, grid + labels, friendly degenerate-data messages; bars bucket to pixel columns when data outnumbers the plot width.
  • data/googlePhotos — shared-album slideshows via the share link (the Library API can no longer read library content); extracts photo URLs from the share page, requests display-sized variants, caches the album list, random/sequential order.
  • data/immich — random/album/favorites/memories from a self-hosted Immich server (x-api-key), with a new immich settings namespace wired through the settings page, embedded settings pull and the on-device admin whitelist. Album mode picks server-side via POST /api/search/random so large albums never buffer on-frame; older servers fall back to /api/assets/random with client-side filtering.
  • render/zoomPan — Ken Burns-style smooth zoom/pan: each render samples a slightly different crop bilinearly straight onto the canvas (zero per-render allocations), deterministic per-cycle focal drift, persistable phase.
  • Native EXIF parser (frameos/utils/exif.nim) — dependency-free, bounds-checked, fuzz-tested; localImage and downloadImage merge camera/lens/exposure/ISO/GPS metadata plus an exifSummary one-liner into their metadata state. exiftool still wins where installed; ESP32 frames get EXIF for the first time.
  • Four sample scene templates (Chart, Google Photos, Immich, Ken Burns slideshow) with rendered previews; the Immich sample uses the new state-field showIf (albumId only shows when mode=album).

ESP32 13.3" frame (Spectra-6, 32MB)

  • Error frames now render the actual error message with the compiled-in typeface (wrapped, centered, thin border) instead of the black plus marker; the marker remains only as the fallback if typesetting itself runs out of memory.
  • Blank/new SD cards are formatted as FAT on mount failure and used as the assets folder (IDF only formats on FR_NO_FILESYSTEM, so transient errors on a good card never wipe it). Verified: blank 64GB card formatted + mounted on hardware.
  • Wi-Fi joins the strongest BSSID (WIFI_ALL_CHANNEL_SCAN + WIFI_CONNECT_AP_BY_SIGNAL): on mesh networks the default fast-scan latched onto distant nodes (-83 dBm) and died in reason=34 low-ack loops, falling back to the provisioning portal. Verified: connects at boot at -54 dBm.
  • HTTP redirects now work on-device: the manual esp_http_client open/read flow bypasses esp_http_client_perform() — the only place ESP-IDF honors max_redirection_count — so any 30x (Google Photos share short links, http→https upgrades) errored on ESP32 while working on the Pi. The glue now follows up to 5 redirects.
  • Repo-scene coverage harness (test_repo_scenes_esp32.nim): parses and builds all 21 bundled sample templates and renders the offline-safe ones through the interpreter under a 4MB render-memory override (testing-only), asserting no render-chain errors. The SD-card and Ken Burns scenes stream a bundled 10-megapixel JPEG fixture into the canvas — large JPGs from SD render within budget. FRAMEOS_TEST_NETWORK=1 also renders the network-dependent scenes. Only the Chromium/RTSP samples are excluded (child processes).
  • Scene uploads: USB payload readiness handshakes, chunked reads/writes, size-scaled timeouts; 4MB USB uploads on the 32MB flash profile; scenes re-uploaded after browser flashing (which erases cached state); HTTP fast deploy preferred when reachable.
  • Rendering fixes: Wikimedia cover aspect preserved via bounded decode, embedded scaler honoring cover/contain/stretch, embedded-safe approximate text stroke.
  • No backend media proxy: image size/format constraints are handled device-side.

Build & tooling

  • nimble.lock regenerated to pin the pixie fork revision (CI resolved pixie >= 6.1.0 to upstream and failed on pixie/decodebudget). Added a nimble relock task: nimble 0.20.1's lock-update path silently writes an empty lock for this package, so always regenerate from scratch.
  • Browser flash / deploy drawer / logging improvements (USB API bridging, trace summarization, drawer close routing).

Validation

  • backend: full pytest suite green (682 passed, 3 skipped), including new codegen tests for showIf/value emission and a fix for the pixie-path test that failed on dev machines with a ../pixie checkout
  • frameos: full Nim test suite green (35 test files incl. new showIf, EXIF, interpreter state-field and repo-scene tests); nim check src/frameos.nim against the locked pixie fork
  • ESP32: --os:freertos --cpu:esp -d:frameosEmbedded cross-compile of embedded_main.nim clean with all new apps registered
  • pixie fork: PNG suite + 10k-iteration fuzz green with the in-place unfilter; new budget regression test (480×800 RGBA decodes at a 4MB budget, rejected with a catchable error at 2MB)
  • frontend: tsc clean after kea typegen; control-page showIf JS exercised via node against the shared-evaluator semantics
  • adversarial multi-agent review of the new code confirmed and fixed 15 findings before merge (32-bit EXIF RangeDefect, ESP32 redirect gap, Immich album buffering, zoomPan per-render allocations, showIf semantic divergences, control-page placeholder XSS, and more)
  • on-hardware verification (frame 59): SD auto-format + mount, typeset error frame on panel, Wi-Fi strongest-BSSID connect at boot, 11-scene upload; (frame 60): RSS flat post-deploy

🤖 Generated with Claude Code

@mariusandra mariusandra changed the title Add PhotoPainter defaults and fix ESP32 scene uploads Fix ESP32 scene uploads and deploy drawer close Jun 30, 2026
@mariusandra mariusandra marked this pull request as ready for review June 30, 2026 22:56
mariusandra and others added 12 commits July 1, 2026 01:03
Large photos (users load 24-60MP JPEGs) and low-RAM targets (ESP32-S3
16MB PSRAM, Pi Zero 2W 451MB) previously OOM-rebooted or got killed.
Make every decode memory-aware and fail gracefully.

- utils/memory.nim: availableRenderBytes() from live headroom (Linux
  MemAvailable, ESP32 PSRAM via glue), refreshDecodeBudget() feeds the
  pixie decode budget each tick; setupRenderMemory() pins glibc mallopt
  so freed image buffers return to the OS (Pi RSS was ratcheting)
- utils/image.nim: readImageWithDisplayBounds streams JPEGs from disk;
  readImageIntoTarget decodes straight into the canvas (JPEG only, to
  preserve PNG alpha compositing); all download/dataUrl paths bounded,
  SVG kept on the generic decoder
- interpreter: decode-into-canvas hint for render/image fed by a direct
  uncached localImage producer, so a 60MP SD photo decodes into the
  canvas instead of canvas + full copy + file
- embedded: patched malloc releases a 1MB PSRAM reserve and retries;
  render loop re-arms it; C glue setjmp guards + fos_nim_fatal_oom
  longjmp so a failed allocation fails one render, never reboots the
  device; error frames render into the canvas
- esp32: SPIFFS page size 512 (24MB /state mount, fixes scene upload),
  SD mount retries with clock fallback, dynamic HTTP cap vs live PSRAM
- backend: Pi + embedded builds auto-use repo-sibling ../pixie checkout
- apps: localImage/legacy apps route through bounded decoders

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI resolved 'pixie >= 6.1.0' from the registry to upstream treeform/pixie
(nimble install -dy ignores the lock URL), so the memory-aware decode
work — including pixie/decodebudget — was absent and the build failed on
'cannot open file: pixie/decodebudget'. Pin the fork commit directly in
requires so nimble install fetches it, and update nimble.lock to the same
revision + checksum for the setup path. Verified: nimble install -dy +
setup fetches FrameOS/pixie@f4e272a and frameos compiles with no
FRAMEOS_PIXIE_PATH override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Deploy/build flags: -d:malloc was a no-op typo (Nim's flag is
  -d:useMalloc), leaving Nim's page allocator in charge so glibc
  mallopt/malloc_trim tuning was inert and Pi RSS ratcheted ~3MB/render
  to OOM. Fix in _frame_deployer FRAMEOS_NIM_FLAGS, Makefile, and the
  driver/scene library build tools.
- image.nim: readImageIntoTarget is JPEG-only (writing decoded pixels
  over the canvas is safe without alpha; PNG transparency must composite
  via the generic path); download/dataUrl paths keep SVG on the generic
  decoder; keep ImageMagick engine in charge when configured.
- fos_assets_sd.c: retry SD mount with a clock-speed fallback instead of
  giving up on the first timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The default fast-scan config connects to the first AP heard broadcasting
the SSID; on a multi-node mesh that is often a distant node, and the link
then dies in a reason=34 (low-ack) disconnect loop and falls back to the
provisioning portal even with a -53 dBm node in range. Scan all channels
and sort candidates by signal instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mariusandra mariusandra changed the title Fix ESP32 scene uploads and deploy drawer close Memory-aware rendering, Pi allocator leak fix, and ESP32 13.3" reliability Jul 2, 2026
mariusandra and others added 13 commits July 2, 2026 10:19
State fields (and custom event fields) now support the same showIf
conditions as app config fields, evaluated consistently everywhere
public fields are rendered:

- the app-config condition matcher moves out of appNodeLogic into a
  shared frontend/src/utils/showIf.ts evaluator
- scene control forms (ExpandedScene), split-screen options, schedule
  state payloads and template previews filter fields with the shared
  evaluator, coercing form values by field type
- the field definition editor gains a visual condition builder
  (ShowIfEditor) with a raw-JSON fallback for complex conditions
- the on-frame control page evaluates conditions live in the browser
  and server-side for the initial render; hidden fields are not
  submitted
- compiled scene codegen emits showIf and default values into the
  generated StateField literals; interpreted scenes parse them from
  scenes.json
- interpreted scenes now respect access=private: private fields no
  longer appear in the control UI, /state payloads or setSceneState
  (they still seed scene state and can persist to disk)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compiled scenes run json field defaults through parseJson at codegen
time; interpreted scenes stored the raw string, so a chart or code node
reading the state saw a JString instead of the parsed object. Parse
string defaults for json fields when seeding interpreted scene state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Draws charts from JSON data with pixie: liberal data parsing
([1,2,3], label/value pairs, or {labels, series}), multi-series
colors from a CVD-safe palette, auto or fixed y-ranges, optional
grid and labels, and friendly messages for degenerate data. Bars
bucket to pixel columns when the data outnumbers the plot width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shows images from a public Google Photos shared album by scraping the
share page (the Library API no longer allows reading arbitrary library
content). Extracts lh3.googleusercontent.com photo URLs, requests
display-sized variants, caches the album list on the app instance, and
supports random/sequential order, metadata state and asset saving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shows photos from a self-hosted Immich server (x-api-key auth) in
random, album, favorites or memories mode. Album mode picks the asset
server-side via POST /api/search/random so large albums never buffer
on the frame; servers without that endpoint fall back to
/api/assets/random with client-side filtering. Downloads server-side
previews by default to stay light on frame memory, and stores EXIF and
people metadata to scene state.

The immich settings namespace (url + apiKey) is wired through the
settings page, secret settings, the embedded settings pull list and
the on-device admin whitelist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Time-based smooth zoom and pan across an image: each render draws a
slightly different crop so consecutive renders form continuous motion.
Supports zoom in/out, pan and kenBurns (deterministic per-cycle focal
drift) motions with configurable easing, anchor, cycle duration and an
optional persisted phase. The crop samples straight onto the canvas
with bilinear filtering; no intermediate image allocations per render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a dependency-free, bounds-checked EXIF parser (make, model, lens,
exposure, aperture, ISO, focal length, capture date, orientation, GPS)
plus an exifSummary one-liner for text overlays. localImage and
downloadImage merge parsed EXIF into their metadata state; exiftool
values still win on hosts that have it, and ESP32 frames now get EXIF
at all. Only the first 256KB of a JPEG is read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chart (with editable demo data), Google Photos, Immich (albumId only
shows when mode=album, exercising the new state field showIf) and a
Ken Burns slideshow over SD card images.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New harness parses and builds all bundled sample templates and renders
the offline-safe ones through the interpreter with a 4MB render-memory
override (availableRenderBytesOverride, -d:testing only), asserting no
render-chain errors. The SD card and Ken Burns scenes stream a bundled
10-megapixel JPEG fixture into the canvas, proving large JPGs from SD
render within the budget. FRAMEOS_TEST_NETWORK=1 additionally renders
the network-dependent scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The manual esp_http_client open/read flow bypasses
esp_http_client_perform(), the only place ESP-IDF honors
max_redirection_count, so any 30x (Google Photos share short links,
http->https upgrades) surfaced as an error on ESP32 while working on
the Pi. Follow up to 5 redirects in the glue via
esp_http_client_set_redirection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test asserted embedded_pixie_path() is None with the env unset,
but the function deliberately auto-detects a ../pixie checkout, so the
test failed on any dev machine that has one. Pin the override to an
empty directory like the deployer tests do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Canvas-sized RGBA PNGs now plan pixels + scanlines (~3MB for 480x800)
instead of pixels + 2x scanlines (~4.5MB), fitting the decode budget on
ESP32-class frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mariusandra mariusandra changed the title Memory-aware rendering, Pi allocator leak fix, and ESP32 13.3" reliability Memory-aware rendering, ESP32 reliability, state-field showIf, and new apps Jul 2, 2026
FrameOS Bot and others added 10 commits July 2, 2026 08:33
zippy (FrameOS fork) gains a streaming inflate that emits output as it
leaves the 32KB deflate window; pixie decodes non-interlaced PNGs row
by row through it. PNG decode plans drop from pixels + whole-image
scanlines to pixels + ~64KB, and scaled decodes never allocate the
full-size pixel buffer — so canvas-sized PNGs now work on ESP32-class
budgets (incl. 1200x1600 panels) and big PNGs scale into display bounds
like streamed JPEGs.

Adds FRAMEOS_ZIPPY_PATH (mirroring FRAMEOS_PIXIE_PATH) for local
overrides, and extends the ESP32 repo-scene harness with a canvas-sized
PNG fixture decoded under the same 4MB memory override. Verified
pixel-identical to the previous decoder across the pngsuite corpus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The cache "refresh when an expression changes" toggle was silently
ignored on interpreted scenes: readCacheConfig only understood duration
and inputs. Expressions now evaluate per render as inline JS and force
a recompute when the value changes, mirroring compiled scenes. The
cache editor labels the expression field with the scene's language.

An unparseable duration string also no longer collapses to 0 seconds
("expire every render", which made slideshows advance on every frame);
it falls back to the compiled codegen's 60s default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sample's localImage cache used a Nim expression string as its
duration; the interpreter parsed it as 0 seconds, so a new photo loaded
on every render — at smooth-motion refresh rates the slideshow just
flicked through images. The image is now held by a cache expression
that flips exactly when zoomPan's epoch-based zoom cycle wraps, and the
defaults are motion-friendly (0.1s renders, 60s per image, with an
e-ink warning). The scene harness asserts the image no longer advances
between renders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cross builds failed with "cannot open file: zippy" from the pinned
pixie package: pixie.nimble's transitive URL requirement made nimble
resolve two zippy sources for one package name on fresh CI machines.
frameos.nimble now pins the FrameOS zippy fork directly (the same
pattern as the pixie pin) and pixie requires zippy by name again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's nimble produced incomplete nimble.paths with a second forked
package however it was required (transitive URL: zippy missing; root
URL: pixie missing). The streaming inflate is now vendored inside the
pixie fork, zippy pins back to stock guzba upstream, and the dependency
graph matches the shape that has been green on CI all along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The memory-aware pipeline pre-scaled every decode to the context image
size, but the context can be a small split cell and downstream apps
(resizeImage crops, blends) need native detail: e2e snapshots for
dataResize, dataDownloadImage and renderImageBlend silently degraded.
localImage and the host download paths now decode at full size, bounded
only by the live memory budget, which still scales oversized decodes on
constrained devices. Decode-into-target remains the embedded strategy.

All three snapshots return bit-identical to main and are restored. The
e2e frame config also disables the network check and timezone updates:
the fixture-driven tests should not touch the network (and both crash
LibreSSL when run on macOS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every generated scene embedded its own copy of the event payload
matcher, and the interpreter carried a private duplicate. The proc now
lives once in frameos/values and both callers use it; deployed frames
with many compiled scenes stop duplicating it per scene. The committed
sceneNodes e2e scene picks up the current codegen's node comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The default Chart scene showed a black page with "No chart data": scene
activation delivers json state values as strings, which parseChartData
rejected, and the fallback ink was black on the black background. The
chart now parses string-wrapped JSON, setSceneState on interpreted
scenes parses json-typed fields on arrival, and the default ink picks
light or dark based on the canvas underneath.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ken Burns now renders at 24 fps by default (0.0416s), shows each image
for 30s with a wider zoom (1.0 to 1.45) so the motion is clearly
visible, and gains a filename search field like the SD card scene. The
app and template descriptions state it is only for HDMI/LCD frames that
render many times per second. Google Photos documents that it scrapes
the public share page and may stop working if Google changes it. The
Chart sample uses a light ink on its dark background.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mariusandra mariusandra merged commit 243d351 into main Jul 2, 2026
35 checks passed
@mariusandra mariusandra deleted the june-30 branch July 2, 2026 10:49
mariusandra added a commit that referenced this pull request Jul 3, 2026
Rebased onto the memory-aware rendering merge (#252), which already gives
hosts native-resolution downloads with budget-scaled decoding. What was
still broken:

- Embedded decode-into-target used the pixie default fitStretch, so
  downloaded images on ESP32 arrived distorted (and the render/image
  placement config is a no-op there since the image already matches the
  region). Plumb a ScaledDecodeFit through downloadImageInto,
  downloadImageWithDataInto, downloadImageFromBuffer, decodeDataUrlInto
  and the decodeImageWithFallback target variants, defaulting to
  fitCover - the same "cover" the non-scaled fallback branches already
  used. Context downloads derive the fit from the frame's scalingMode
  (contain -> fitContain, stretch -> fitStretch, else fitCover) via
  scaledDecodeFitForFrame, so a frame configured to letterbox
  letterboxes instead of cropping. The gallery download hook carries
  the fit as well.

- On hosts, data: URLs still decoded stretched into the region-sized
  target while http downloads decoded natively. Route them through
  decodeDataUrl so both paths return native resolution.

- decodeImageWithDisplayBounds raised for formats decodeImageDimensions
  cannot probe (e.g. SVG files read via localImage); fall back to a
  plain decode instead.

The e2e dataDownloadImage render stays byte-identical to the snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mariusandra added a commit that referenced this pull request Jul 3, 2026
…253)

Rebased onto the memory-aware rendering merge (#252), which already gives
hosts native-resolution downloads with budget-scaled decoding. What was
still broken:

- Embedded decode-into-target used the pixie default fitStretch, so
  downloaded images on ESP32 arrived distorted (and the render/image
  placement config is a no-op there since the image already matches the
  region). Plumb a ScaledDecodeFit through downloadImageInto,
  downloadImageWithDataInto, downloadImageFromBuffer, decodeDataUrlInto
  and the decodeImageWithFallback target variants, defaulting to
  fitCover - the same "cover" the non-scaled fallback branches already
  used. Context downloads derive the fit from the frame's scalingMode
  (contain -> fitContain, stretch -> fitStretch, else fitCover) via
  scaledDecodeFitForFrame, so a frame configured to letterbox
  letterboxes instead of cropping. The gallery download hook carries
  the fit as well.

- On hosts, data: URLs still decoded stretched into the region-sized
  target while http downloads decoded natively. Route them through
  decodeDataUrl so both paths return native resolution.

- decodeImageWithDisplayBounds raised for formats decodeImageDimensions
  cannot probe (e.g. SVG files read via localImage); fall back to a
  plain decode instead.

The e2e dataDownloadImage render stays byte-identical to the snapshot.

Co-authored-by: Claude Fable 5 <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