Skip to content

feat(core): support file-backed RGBA images - #1328

Open
neriousy wants to merge 9 commits into
anomalyco:mainfrom
neriousy:feat/raw-rgba-file-images
Open

feat(core): support file-backed RGBA images#1328
neriousy wants to merge 9 commits into
anomalyco:mainfrom
neriousy:feat/raw-rgba-file-images

Conversation

@neriousy

@neriousy neriousy commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR introduces NativeImage.adoptRgbaFile(path, width, height) for lazy, file-backed RGBA8 images. After validating the file, OpenTUI owns it and materializes its pixels only when an operation or terminal protocol requires them.

For eligible local Kitty terminals, OpenTUI emits an f=32,t=t transmit command so the terminal can read the unchanged frame file directly. All other cases use the existing inline-pixel path.

This is a low-level NativeImage API. ImageSource, ImageRenderable, and the renderer output lifecycle are unchanged.

Motivation

Some image producers already publish complete frames as raw RGBA files. Loading those files through NativeImage.fromRgba() requires reading every frame into a JavaScript Uint8Array, copying it into the native image, and then base64-encoding the pixels through stdout for Kitty.

File adoption lets those frames participate in OpenTUI's normal image lifecycle—including placement, clipping, overlays, movement, resizing, and protocol fallback—without that extra work when the terminal can consume the file directly.

One intended consumer is opentui-browser, whose browser sidecar produces bounded, top-down RGBA8 frame files.

API contract

NativeImage.adoptRgbaFile(path, width, height) accepts a regular file whose size is exactly width * height * 4. The data must be tightly packed, top-down RGBA8.

  • The source is opened non-blocking before its type is checked, so a FIFO cannot block the synchronous API.
  • The caller retains ownership of the source file on every construction failure.
  • OpenTUI owns the path after successful construction.
  • Width, height, and channel count come from the validated arguments without decoding the file.
  • info() is cached and stable. File-backed images conservatively report hasAlpha: true because answering it does not materialize the file.
  • Native materialization reads the complete file once and determines the actual transparency for encoding decisions.
  • Materialization or disposal deletes the owned path unless ownership has been transferred to Kitty.

Kitty file transport

File transport is available only when:

  • the renderer is writing to its own TTY stdout
  • the terminal is local
  • the image is still backed by the original adopted file
  • the canonical file path remains inside a known temporary root and contains tty-graphics-protocol

Windows, remote terminals, custom or memory output, image feeds, transformed images, unqualified paths, and symlink escapes fall back to inline pixels.

Kitty takes responsibility for deleting a qualified t=t temporary file. OpenTUI marks the path transferred only after the complete transmit command has been serialized. A serialization error leaves the file owned and retryable. After a successful file transmission, subsequent transmissions use the retained descriptor and fall back to inline pixels.

This ownership boundary is best-effort: a later frame write may still fail or be dropped after the command has been serialized. OpenTUI does not wait for terminal acknowledgement because Kitty responses are suppressed for these commands. Path qualification follows the Kitty graphics protocol.

Benchmark

A focused native benchmark compares the two 1280×720 paths:

Transport Average OpenTUI output
Copy + Kitty inline encoding 2.67 ms 4.70 MiB
Adopted file + Kitty t=t 46.85 µs ~191 B

The measurement includes image creation or adoption, Kitty serialization, and disposal. It excludes producer file creation, terminal-side file reads, and shared placement work.

Validation

  • native suite with Zig 0.15.2: 1,880 passed, 3 skipped
  • Bun core package suite: 5,344 passed, 23 skipped
  • Node 26.4 suite: 4,614 passed, 6 skipped
  • packed distribution and CommonJS export coverage
  • focused coverage for validation, ownership, lazy materialization, FIFO handling, descriptor cleanup, path qualification, symlink escape, serialization retry, fallback behavior, UTF-8 paths, and renderer output
  • root build
  • bun run fmt:check
  • bun run lint

@neriousy
neriousy marked this pull request as ready for review August 4, 2026 08:17
@simonklee

Copy link
Copy Markdown
Member

The ownership model needs a look:

  • t=t does not transfer deletion for arbitrary paths. Kitty only deletes protocol-qualified temporary paths containing
    tty-graphics-protocol; ordinary paths such as the tests’ frame.rgba remain, while OpenTUI marks them transferred and
    skips cleanup.
  • File transport is gated only by !terminal.remote, so memory output or an explicitly local custom sink transfers
    ownership even though no terminal consumes the path.
  • transferred is set while staging frame bytes, before endFrame() succeeds. A later frame failure drops the command
    without rolling back ownership.
  • Adoption opens the path before validating it is a regular file, so a FIFO can block this synchronous API
    indefinitely on POSIX.
  • Opaque adopted images permanently report stale hasAlpha: true through the cached TS metadata.

@neriousy
neriousy marked this pull request as draft August 6, 2026 07:56
@neriousy

neriousy commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Marking as draft for now, I asked sol to update based on comments left by Simon buut the code looks really sussy to me

@neriousy
neriousy marked this pull request as ready for review August 6, 2026 18:16
@neriousy

neriousy commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Okay, adjuted the PR a bit - most of the bullet points are done but

  • 3 about the ownership idk if its necessary to be done so thorough?
  • 5 the rgba has true point - i mean those file always have the alpha channel as their name says rgba yeah? if to be marked yeah / no i'd need to go through each chunk and check if the alpha is not 255

smth like this:

fn fileHasTransparency(file: std.fs.File, byte_len: usize) !bool {
    var buffer: [64 * 1024]u8 = undefined;
    var offset: usize = 0;

    while (offset < byte_len) {
        const chunk = buffer[0..@min(buffer.len, byte_len - offset)];
        const read = try file.preadAll(chunk, offset);
        if (read != chunk.len) return error.MalformedInput;

        var alpha: usize = 3;
        while (alpha < chunk.len) : (alpha += 4) {
            if (chunk[alpha] != 255) return true;
        }
        offset += chunk.len;
    }

    return false;
}

idk if its worth it

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.

2 participants