Memory-aware rendering, ESP32 reliability, state-field showIf, and new apps#252
Merged
Conversation
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>
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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Memory leak on HDMI/framebuffer frames (fixed)
-d:malloc— a no-op define (Nim's real flag is-d:useMalloc) — so binaries ran Nim's page allocator and themallopt/malloc_trimtuning insetupRenderMemory()/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.-d:useMallocin the deployer (FRAMEOS_NIM_FLAGS),frameos/Makefile, and the driver/scene library build tools; deployer tests updated (assertions + a hermeticity fixture so a../pixiecheckout doesn't break them on dev machines).Memory-aware rendering pipeline
FrameOS/pixie): runtime per-decode budgets, streaming baseline-JPEG decode through a 32KB window,decodeJpegInfodimension probe, aspect-correctScaledDecodeFit(stretch/cover/contain) across all scaled decode paths.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.availableRenderBytes()(Linux MemAvailable / ESP32 PSRAM), per-render decode budget refresh, decode-into-canvas hints for full-framerender/image+data/localImagechains, file-read budgets,ensureRenderAllocationguardrails.showIf visibility conditions for state fields
The app-config
showIfsystem now applies to scene state fields and custom event fields, with one set of semantics (top-level conditions OR-ed,andblocks AND-ed, values coerced by field type) evaluated consistently on every surface:appNodeLogicinto a sharedfrontend/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.ShowIfEditor: field/operator/value rows, match-any/match-all, raw-JSON fallback for complex conditions) for both state fields and custom event fields./ccontrol page evaluates conditions live in the browser and server-side for the initial render (frameos/utils/show_if.nim); compiled-scene codegen emitsshowIf+ default values intoStateFieldliterals; interpreted scenes parse them from scenes.json.access: private(previously every state field was externally visible and settable — the long-standingTODO), and json state field defaults parse like compiled scenes.New apps
[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.x-api-key), with a newimmichsettings namespace wired through the settings page, embedded settings pull and the on-device admin whitelist. Album mode picks server-side viaPOST /api/search/randomso large albums never buffer on-frame; older servers fall back to/api/assets/randomwith client-side filtering.frameos/utils/exif.nim) — dependency-free, bounds-checked, fuzz-tested; localImage and downloadImage merge camera/lens/exposure/ISO/GPS metadata plus anexifSummaryone-liner into their metadata state. exiftool still wins where installed; ESP32 frames get EXIF for the first time.albumIdonly shows when mode=album).ESP32 13.3" frame (Spectra-6, 32MB)
FR_NO_FILESYSTEM, so transient errors on a good card never wipe it). Verified: blank 64GB card formatted + mounted on hardware.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.esp_http_clientopen/read flow bypassesesp_http_client_perform()— the only place ESP-IDF honorsmax_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.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=1also renders the network-dependent scenes. Only the Chromium/RTSP samples are excluded (child processes).coveraspect preserved via bounded decode, embedded scaler honoringcover/contain/stretch, embedded-safe approximate text stroke.Build & tooling
nimble.lockregenerated to pin the pixie fork revision (CI resolvedpixie >= 6.1.0to upstream and failed onpixie/decodebudget). Added animble relocktask: nimble 0.20.1's lock-update path silently writes an empty lock for this package, so always regenerate from scratch.Validation
pytestsuite 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../pixiecheckoutnim check src/frameos.nimagainst the locked pixie fork--os:freertos --cpu:esp -d:frameosEmbeddedcross-compile ofembedded_main.nimclean with all new apps registeredtscclean after kea typegen; control-page showIf JS exercised via node against the shared-evaluator semantics🤖 Generated with Claude Code