Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
00edb1a
Add fx.loadImage: the runtime image-load effect with the audio source…
ctate Jul 17, 2026
6f8368f
Journal image results through a content-addressed session blob store
ctate Jul 17, 2026
b450b3c
Markup <image>: the runtime-image leaf with a dynamic ImageId binding
ctate Jul 17, 2026
c7525f0
Cmd.imageLoad: the TS tier's runtime image vocabulary end to end
ctate Jul 17, 2026
6d553a7
Document dynamic images: the load-show-replay story
ctate Jul 17, 2026
1b44cff
Regenerate the docs component-preview wasm module
ctate Jul 17, 2026
9a6498d
Keep the dynamic-images doc sample inside the transpiled subset
ctate Jul 17, 2026
51fe886
Export the image effect surface through both roots
ctate Jul 18, 2026
6f3b700
Serve session replay decode through the host platform's image codec
ctate Jul 18, 2026
2d3b123
Reject the seventeenth in-flight image load instead of panicking
ctate Jul 18, 2026
64be35f
Make the ImageId bound exclusive at 2^53 in both tiers
ctate Jul 18, 2026
f47e11b
Echo the requested id through the image result arm
ctate Jul 18, 2026
9023a31
Stage image-load rejections without a user-reachable cap
ctate Jul 18, 2026
0fd1a89
Cmd.imageCancel: the TS tier's numeric-id image cancel
ctate Jul 18, 2026
cc0d571
Verify blob-store dedup hits instead of trusting the name
ctate Jul 18, 2026
122f4ec
Feed worker-origin image rejections under session replay
ctate Jul 18, 2026
650d76a
Give each image cache install its own temp file
ctate Jul 18, 2026
987b232
Stage loop-side image terminals outside the lossy pending ring
ctate Jul 19, 2026
7422885
Make image cache install temps writer-unique, not generation-unique
ctate Jul 19, 2026
a98c431
Refuse fractional expectedBytes at both tiers instead of truncating
ctate Jul 19, 2026
32ac41f
Propagate a cancel that lands inside the image cache probe
ctate Jul 19, 2026
6711cdd
Cmd.imageUnregister: the TS tier's registry release
ctate Jul 19, 2026
82df39d
Survive two recorders writing the same session blob into one store
ctate Jul 19, 2026
c14277d
Back-pressure replay image feeds instead of dropping bytes and order
ctate Jul 19, 2026
049fbb6
Retire the image slot before its terminal reaches update
ctate Jul 19, 2026
dc3a0ec
Reject children on the image leaf instead of dropping them
ctate Jul 19, 2026
a0cd220
Mirror the image leaf's required binding and raw-child checks into bo…
ctate Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,12 @@ pub fn build(b: *std.Build) void {
.{ .path = "docs/src/app/media-producers/page.mdx", .pattern = "producer: media.MediaSurfaceProducer" },
.{ .path = "src/runtime/media_surface_tests.zig", .pattern = "producer: media.MediaSurfaceProducer" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-session-replay-image-codec", "Verify the replay runner installs the host image codec headlessly (the desktop arms cannot link in unit tests; the null-fallback arm is covered in session_tests.zig)", &.{
.{ .path = "src/app_runner/root.zig", .pattern = "native_sdk.platform.installHeadlessImageCodec(build_options.platform, &null_platform, &replay_platform.services);" },
.{ .path = "src/platform/macos/root.zig", .pattern = "pub fn installHeadlessImageCodec(services: *platform_mod.PlatformServices) void {\n services.decode_image_fn = decodeImage;\n}" },
.{ .path = "src/platform/linux/root.zig", .pattern = "pub fn installHeadlessImageCodec(services: *platform_mod.PlatformServices) void {\n services.decode_image_fn = decodeImage;\n}" },
.{ .path = "src/platform/windows/root.zig", .pattern = "pub fn installHeadlessImageCodec(services: *platform_mod.PlatformServices) void {\n services.decode_image_fn = decodeImage;\n}" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-js-view-helper-contracts", "Verify injected view helpers support label-first updates", &.{
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "update:function(options,patch)" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "update:function(options,patch)" },
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/dynamic-image-load.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
feature: **`Cmd.imageLoad` — dynamic images, the first full media pipeline**: apps load images at runtime from disk or the network by a model-owned ImageId, the effect executor resolves the audio cascade's source order (local path first, then a verified content-addressed cache entry under `<caches>/images/`, then the network with an atomic cache install behind it), decodes through the platform codec into the existing registered-image storage, and exactly ONE result Msg comes back — `loaded` with the decoded width/height, or one honest failure class from the same vocabulary the direct registration API raises (`decode_failed`, `too_large`, `registry_full`, `unsupported`, the fetch taxonomy, `http_status` with the status carried through).
- **TS tier first-class**: `Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event })` with a five-field result arm matched by name (`id`/`state`/`width`/`height`/`status` — `id` echoes the requested ImageId so concurrent loads sharing one arm stay distinguishable; the fourteen-member `ImageState` union checked at build time), id expressions welcome (ids are model data), `Cmd.imageCancel(id)` ending a live load loudly (the event arm's "cancelled", freeing the id for a same-id retry; an id with no live load no-ops), `Cmd.imageUnregister(id)` releasing a loaded image's registry slot (the gallery eviction move past the 16-slot registry — synchronous registry surgery like registration itself, no result Msg, misses no-op; a load in flight still registers at its terminal, so cancel first to keep the slot free), opcodes 0x10/0x11/0x12 additive within cmd_format_version 2, and `TsUiApp`'s `image_cache_dir` deriving the content-addressed cache path from the URL so update never builds filesystem paths.
- **Markup `<image>` — the runtime-image leaf (element code 67)**: `image="{binding}"` binds the model-owned u64 ImageId in avatar's grammar (binding-only, required on the leaf, negative model values fail the build with a teaching, never a trap), wired through the validator, both engines, `native check`'s model contract, LSP hover docs, and the docs vocabulary; the `image` attribute's scope broadened from avatar-only to avatar+image.
- **Recorded sessions replay byte-identical, offline**: an image load's ENCODED source bytes are the effect result, journaled at effect-result time into a content-addressed blob store beside the journal (`blobs/<sha256[..16]>` in the session directory — identical bytes twice store one blob), with the journal record carrying hash + length and the dedup probe verifying an existing blob's bytes before trusting its name (a damaged blob repairs in place from the bytes in hand — recording self-heals the store instead of sealing a journal replay must refuse); replay reads the blob, verifies it against its address, re-runs decode + registration, and delivers the recorded result with no file, network, or cache touched, refusing loudly when the blob store is missing or damaged.
- **Journal format break, stated plainly**: the session journal is now v6 (the `.image` effect-record kind plus the blob-address fields appended to effect records); v5 and older journals are refused at the preamble with the standard re-record teaching — a v5 reader would have misparsed the longer records as corruption.
- **Zig tier**: `fx.loadImage(.{ .id, .path, .url, .cache_path, .expected_bytes, .on_result })` with `Effects.imageMsg(...)` routing, a fake-executor seam (`pendingImageLoad*`, `feedImageBytes` running the REAL decode+register path, `feedImageResult`), and `imageCachePath` deriving the cache convention; the encoded source is bounded at 1.25 MiB from every source alike and over-bound sources fail whole with `too_large` — a cut image never decodes, so there is no truncated delivery.
Binary file modified docs/public/wasm/component-preview.wasm
Binary file not shown.
7 changes: 7 additions & 0 deletions docs/src/app/dynamic-images/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";

export const metadata = pageMetadata("dynamic-images");

export default function DynamicImagesLayout({ children }: { children: React.ReactNode }) {
return children;
}
121 changes: 121 additions & 0 deletions docs/src/app/dynamic-images/page.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { CodeToggle } from "@/components/code-toggle";

# Dynamic Images

Load images at **runtime** — from disk or the network — decode them through the platform codec, and show them in views by id. `Cmd.imageLoad` is effects-as-data all the way down: your core names a model-owned `ImageId` and a source, the host does the I/O and the decode, and exactly one result Msg comes back — `loaded` with the decoded dimensions, or one honest failure class. Views bind the id; nothing in `update` ever touches a file, a socket, or a codec.

Static images that ship with the app stay in [`app.zon`'s `.assets.images`](/app-zon) — read once at launch, registered before the first frame. This page is the **dynamic** half: cover art fetched from a CDN, a chart PNG a subprocess rendered, an avatar that arrives after login.

## Load, then show

The id is ordinary model data — pick it like an effect key. Issue the load, and write the id into the model **only when the result says `loaded`** (the store-on-success discipline): the view renders its fallback while the load is in flight and keeps it when the load failed, because an id of `0` — the no-image sentinel — draws nothing.

<CodeToggle>

```ts
import { Cmd, asciiBytes } from "@native-sdk/core";

// The engine's outcome vocabulary — a named alias your core declares
// (the host matches the members by name, so the order is yours).
export type ImageState =
| "loaded" | "rejected" | "not_found" | "io_failed" | "connect_failed"
| "tls_failed" | "protocol_failed" | "timed_out" | "http_status"
| "cancelled" | "too_large" | "unsupported" | "decode_failed" | "registry_full";

export interface Model {
readonly cover: number; // 0 until the load lands
readonly coverState: ImageState;
}

export type Msg =
| { readonly kind: "show" }
| { readonly kind: "cover_done"; readonly id: number; readonly state: ImageState; readonly width: number; readonly height: number; readonly status: number };

export function initialModel(): Model {
return { cover: 0, coverState: "rejected" };
}

export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "show":
return [model, Cmd.imageLoad(21, {
path: asciiBytes("art/cover.png"),
url: asciiBytes("https://cdn.example.com/art/cover.png"),
}, { event: "cover_done" })];
case "cover_done":
if (msg.state === "loaded") return { ...model, cover: msg.id, coverState: msg.state };
return { ...model, coverState: msg.state };
}
}
```

```zig
const Model = struct {
cover: canvas.ImageId = 0, // 0 until the load lands
};

// in update:
.show => fx.loadImage(.{
.id = 21,
.path = "art/cover.png",
.url = "https://cdn.example.com/art/cover.png",
.on_result = Effects.imageMsg(.cover_done),
}),
.cover_done => |result| if (result.outcome == .loaded) {
model.cover = result.id;
},
```

</CodeToggle>

The view binds the same model id. Markup's `image` element is the display-only leaf for exactly this — the binding is required (an unbound `<image>` is dead markup), the id is always `{binding}` model data (never a literal), and avatars carry the same attribute with an initials fallback:

```html
<image image="{cover}" width="120" height="80" label="Cover art" />
<avatar image="{avatar}" label="Octocat">OC</avatar>
```

The moment the result lands and `update` stores the id, every view referencing it repaints with the pixels — GPU caches re-upload off the changed content fingerprint, no invalidation calls. Zig views use `ui.image(.{ .image = model.cover, ... })`; both markup engines refuse a negative id binding at build time with a teaching message, never a trap.

## The source cascade

The source resolves the way `Cmd.audioPlay` resolves — local first, network last, with a verified cache between:

1. **The local `path` is tried first.** A missing file falls through to `url` (when one is given); every other local failure is terminal — retrying a different source would mask the real problem.
2. **A verified cache entry loads without the network.** A URL source checks its cache path before fetching; `expectedBytes` (when you know the size, e.g. from a manifest) is the integrity gate — an entry whose size disagrees is discarded and re-fetched.
3. **The network fetch installs the cache behind it.** The bytes are fetched whole (no truncated delivery — a cut image can never decode), written beside the cache path, and atomically renamed into place only after the size verifies, so a partial download never occupies the cache name.

Prefer **omitting** `cachePath` for URL sources: when the app wiring configures a caches directory (`TsUiApp`'s `image_cache_dir`; the app runner points it at the platform caches directory automatically), the host derives the conventional content-addressed path — `<caches>/images/<sha256[..16]>.<ext>` — from the URL itself. Your update never builds filesystem paths, replay re-derives the same path by construction, and clearing the cache is deleting one directory the OS already treats as reclaimable.

## One result, honest classes

Exactly one event arm dispatches per load — a five-field record matched by field name: `id`, `state`, `width`, `height`, `status`. `id` echoes the requested ImageId, so two loads in flight at once share one arm and still tell their results apart (that is what the example's `cover: msg.id` reads); `loaded` means the pixels are registered and drawable; everything else names what actually happened:

- **Source classes** — `not_found` (missing local file, no url), `io_failed` (a local read failure), `connect_failed` / `tls_failed` / `protocol_failed` / `timed_out` (the fetch taxonomy, on the fetch machinery's own timeout), `http_status` (a non-2xx answer, with the status carried through — an error page is not an image, so the body is discarded).
- **Decode and registry classes** — the same errors the direct registration API raises: `decode_failed`, `unsupported` (a host without a codec), `too_large`, `registry_full`.
- **Discipline classes** — `rejected` (an invalid id, no source at all, or a duplicate live id: one load per id at a time, the spawn rule — a load in flight is never replaced implicitly) and `cancelled` (`Cmd.imageCancel(id)` ended the load).

`Cmd.imageCancel(id)` is the load's cancel — image loads are keyed by their numeric id, so the string-keyed `Cmd.cancel` never touches them. Cancel is **loud**, the spawn discipline: the one terminal still arrives, as the load's own event arm with state `cancelled`, and the id is free for a fresh load once it lands (a slow CDN fetch no longer pins its id against a retry). Aimed at an id with no live load it no-ops — whatever it targeted already delivered its terminal.

## Releasing images: the gallery eviction

A loaded image occupies one of the registry's **16 slots** until you release it, and `Cmd.imageUnregister(id)` is the release: the pixels are freed, views still referencing the id draw their fallback (avatar initials, nothing for `<image>`) on the next frame, and the slot is open for another load. This is what keeps a gallery bigger than 16 images honest — a screen paging through covers loads the visible ids, and when the 17th image would answer `registry_full`, unregisters an off-screen evictee first (mint fresh ids for new content, effect-key style; never re-key different content onto a live id — the same rule the Zig tier's `fx.unregisterImage` documents).

Unlike a load, unregister is **synchronous registry surgery, not an effect**: no result Msg follows (registration by `imageLoad` has a terminal because I/O and decode can fail; releasing a slot cannot), and aimed at an id with no registration it no-ops — `imageCancel`'s idle rule. It frees only the **current** registration: a load in flight under the id is untouched, and its terminal still registers the pixels, re-occupying the id. To evict an id whose load is still running, `Cmd.imageCancel(id)` first, then unregister.

## Limits, honestly

Decode limits are the registered-image limits, fixed and loud: **16 slots** of **1 MiB decoded pixels** each (512×512 RGBA8 — avatar and cover-art scale, not photo scale), with `Cmd.imageUnregister` releasing a slot when the app is done with an image. The encoded source is bounded at **1.25 MiB** from every source alike, and over-bound sources fail whole with `too_large` — never a silently cropped decode. Loaded pixels live in the existing registered-image storage; there is no separate pool to size. The framework bundles no codecs: bytes decode through CGImageSource on macOS, gdk-pixbuf on GTK, WIC on Windows, and the mobile hosts' embed image service — a host without one answers `unsupported`, never silence.

For textures **produced** by your own renderer at video rates — a decoder, a camera, mpv — this is the wrong tool: that is the [media surface](/media-producers)'s dynamic texture channel. `imageLoad` is for images that exist as encoded bytes somewhere and should become long-lived registered pixels.

## Recorded sessions replay byte-identical, offline

The loaded bytes **are** the effect result, and the session journal treats them that way. When a recorded session performs an image load, the encoded source bytes are written — at effect-result time — into a content-addressed blob store beside the journal (`blobs/<sha256[..16]>` in the session directory), and the journal record carries the hash and length. Loading the same bytes twice stores one blob: content addressing is deduplication — and the deduplicating probe verifies the existing blob's bytes before trusting its name, so a damaged blob is repaired in place on the next same-bytes recording rather than sealing a journal replay would refuse.

Replay reads the blob, re-runs the same decode and registration with the recorded bytes, and delivers the recorded result — **byte-identical and fully offline**: the original file, the network, and the cache are never consulted, and the fingerprint checkpoints verify the replayed session against the recording frame by frame. A journal whose `blobs/` directory is missing or damaged refuses loudly (the bytes are verified against their address) rather than replaying a different session. The blob record kind is journal format **v6** — older journals are refused at the preamble with the standard re-record teaching, the format's usual honest break.

```sh
NATIVE_SDK_SESSION_RECORD=session/app.journal native run # records blobs/ beside the journal
NATIVE_SDK_SESSION_REPLAY=session/app.journal native run # replays offline, verifying fingerprints
```
6 changes: 4 additions & 2 deletions docs/src/app/native-ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,7 @@ macOS-first, like `resizable = false`: GTK and Win32 hosts keep standard chrome

Image pixels are runtime-registered resources: the app registers decoded straight-alpha RGBA8 under a caller-chosen `ImageId` (a `u64` kept in the model, effect-key style; 0 is the "no image" sentinel), and `image`, `icon_button`, and `avatar` widgets reference the id. Native SDK bundles no image codecs — encoded bytes (PNG, JPEG, whatever the OS decodes) go through the platform decoder: CGImageSource on macOS and the iOS toolkit host, gdk-pixbuf on GTK, WIC on Windows, BitmapFactory on the Android toolkit host (both mobile hosts register the codec over the embed image service; an embed host without one reports `error.UnsupportedService`).

The fetch-avatar path is one update arm: fetch bytes, decode + register in one call, and write the id into the model only on success — so the avatar renders its initials while the image is loading and keeps them when the fetch or decode failed:
The first-class load path is `fx.loadImage` (`Cmd.imageLoad` on the TS tier): one effect resolving a local-then-URL source cascade with a content-addressed cache, decoding, registering, and delivering exactly one result Msg — see [Dynamic Images](/dynamic-images) for the whole story, including the byte-identical offline replay guarantee. The registration seam underneath is also callable directly — the hand-rolled fetch-avatar path is one update arm: fetch bytes, decode + register in one call, and write the id into the model only on success — so the avatar renders its initials while the image is loading and keeps them when the fetch or decode failed:

```zig
.load => fx.fetch(.{ .key = avatar_key, .url = avatar_url, .on_response = Effects.responseMsg(.fetched) }),
Expand All @@ -516,7 +516,9 @@ ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics =
```

```html
<!-- in markup, avatars bind the same model id; 0 renders the initials -->
<!-- in markup, image leaves and avatars bind the same model id;
0 draws nothing / renders the initials -->
<image image="{chart_image}" width="120" height="80" label="Chart" />
<avatar image="{avatar_image}" label="Octocat">OC</avatar>
```

Expand Down
4 changes: 4 additions & 0 deletions docs/src/app/typescript/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ The runtime interprets the command after the model commits and dispatches any re
<td><code>Cmd.audioPlay(key, source, &#123; event &#125;)</code> + <code>audioPause</code>/<code>audioResume</code>/<code>audioStop</code>/<code>audioSeek</code>/<code>audioSetVolume</code></td>
<td>The audio player: one event stream (<code>loaded</code>, <code>position</code>, <code>completed</code>, <code>failed</code>, <code>spectrum</code>, ...) until <code>audioStop</code> closes it</td>
</tr>
<tr>
<td><code>Cmd.imageLoad(id, source, &#123; event &#125;)</code> + <code>imageCancel(id)</code>/<code>imageUnregister(id)</code></td>
<td>Load an image at runtime under the model-owned numeric ImageId your markup binds; one <code>event</code> result — <code>loaded</code> with the decoded width/height, or a failure class. <code>imageCancel</code> ends a live load loudly (state <code>cancelled</code>) and frees the id; <code>imageUnregister</code> releases a loaded image's registry slot (no result — synchronous, like registration) — see <a href="/dynamic-images">Dynamic Images</a></td>
</tr>
<tr>
<td><code>Cmd.host(name, ...args)</code> / <code>Cmd.request(name, payload, &#123; key?, ok, err &#125;)</code></td>
<td>App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back</td>
Expand Down
Loading
Loading