Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions packages/core/scripts/dist-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ assert.equal(typeof core.AudioRecorder, "function")
assert.equal(typeof core.AudioRecorderError, "function")
assert.equal(typeof core.AudioStreamError, "function")
assert.equal(typeof core.NativeImage, "function")
assert.equal(typeof core.NativeImage.adoptRgbaFile, "function")
assert.equal(typeof core.ImageRenderable, "function")
assert.equal(typeof core.Audio.prototype.openCapture, "function")
assert.equal(typeof core.Audio.prototype.recordToFile, "function")
Expand Down Expand Up @@ -365,6 +366,7 @@ describe("${packageJson.name} dist smoke test", () => {
expect(typeof core.AudioRecorderError).toBe("function")
expect(typeof core.AudioStreamError).toBe("function")
expect(typeof core.NativeImage).toBe("function")
expect(typeof core.NativeImage.adoptRgbaFile).toBe("function")
expect(typeof core.ImageRenderable).toBe("function")
expect(typeof core.Audio.prototype.openCapture).toBe("function")
expect(typeof core.Audio.prototype.recordToFile).toBe("function")
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/image.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { open, stat } from "node:fs/promises"
import { resolve } from "node:path"

import { toArrayBuffer } from "./platform/ffi.js"
import { resolveRenderLib, type ImageHandle, type RenderLib } from "./zig.js"
Expand Down Expand Up @@ -438,6 +439,34 @@ export class NativeImage {
return NativeImage.fromHandle(lib, result.handle)
}

/**
* Adopts a tightly packed raw RGBA8 file without copying its pixels into JavaScript. OpenTUI
* owns the path after this returns and deletes it after materialization or
* disposal; a direct local-terminal Kitty render transfers deletion only for protocol-qualified paths in the OS
* temporary directory.
*/
public static adoptRgbaFile(path: string, width: number, height: number): NativeImage {
if (typeof path !== "string" || path.length === 0 || path.includes("\0")) {
throw new TypeError("path must be a non-empty path without null bytes")
}
requireU32(width, "width")
requireU32(height, "height")
const lib = resolveRenderLib()
const result = lib.imageAdoptRgbaFile(resolve(path), width, height)
checkStatus(result.status)
if (!result.handle) throw imageError(10)
return new NativeImage(lib, result.handle, {
width,
height,
sourceWidth: width,
sourceHeight: height,
format: "raw-rgba",
colorStatus: "explicit-srgb",
orientation: 1,
hasAlpha: true,
})
}

private static fromHandle(lib: RenderLib, handle: ImageHandle): NativeImage {
const result = lib.imageGetInfo(handle)
if (result.status !== 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,7 @@ describe("borrowed pointer call sites", () => {
"imageInfo",
"imageDecode",
"imageCreateFromRgba",
"imageAdoptRgbaFile",
"imageGetInfo",
"imageClone",
"imageCopyPixels",
Expand Down Expand Up @@ -692,6 +693,7 @@ describe("borrowed pointer call sites", () => {
lib.imageInfo(data)
lib.imageDecode(data)
lib.imageCreateFromRgba(pixels, 1, 1, 4)
lib.imageAdoptRgbaFile("/tmp/främé.rgba", 1, 1)
lib.imageGetInfo(handle)
lib.imageClone(handle)
lib.imageCopyPixels(handle, destination, 4, false)
Expand Down Expand Up @@ -719,6 +721,10 @@ describe("borrowed pointer call sites", () => {
expect(calls.get("imageDecode")![2]).toBeInstanceOf(Uint32Array)
expect(calls.get("imageCreateFromRgba")![0]).toBe(pixels)
expect(calls.get("imageCreateFromRgba")![5]).toBeInstanceOf(Uint32Array)
expect(calls.get("imageAdoptRgbaFile")![0]).toBeInstanceOf(Uint8Array)
expect(new TextDecoder().decode(calls.get("imageAdoptRgbaFile")![0])).toBe("/tmp/främé.rgba")
expect(calls.get("imageAdoptRgbaFile")![1]).toBe(new TextEncoder().encode("/tmp/främé.rgba").byteLength)
expect(calls.get("imageAdoptRgbaFile")![4]).toBeInstanceOf(Uint32Array)
expect(calls.get("imageGetInfo")![1]).toBeInstanceOf(ArrayBuffer)
expect(calls.get("imageClone")![1]).toBeInstanceOf(Uint32Array)
expect(calls.get("imageCopyPixels")![1]).toBe(destination)
Expand Down
82 changes: 81 additions & 1 deletion packages/core/src/tests/image.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createServer } from "node:http"
import { spawnSync } from "node:child_process"
import { chmod, mkdtemp, open, readFile, rm } from "node:fs/promises"
import { chmod, mkdtemp, open, readFile, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
Expand Down Expand Up @@ -33,6 +33,16 @@ async function readBase64Fixture(name: string): Promise<Uint8Array> {
)
}

async function expectMissingPath(path: string): Promise<void> {
try {
await stat(path)
} catch (error) {
expect(error).toMatchObject({ code: "ENOENT" })
return
}
throw new Error(`expected path to be missing: ${path}`)
}

function injectJpegExifOrientation(jpeg: Uint8Array, orientation: number): Uint8Array {
// Little-endian TIFF with a single IFD0 entry: tag 0x0112 (Orientation),
// type SHORT, count 1.
Expand Down Expand Up @@ -538,6 +548,76 @@ describe("NativeImage", () => {
}
})

test("adopts raw RGBA files and deletes them after lazy materialization", async () => {
const directory = await mkdtemp(join(process.env.OTUI_IMAGE_TEST_TMPDIR ?? tmpdir(), "opentui-raw-image-"))
const path = join(directory, "frame.rgba")
await writeFile(path, Uint8Array.of(1, 2, 3, 255, 5, 6, 7, 255))

const image = NativeImage.adoptRgbaFile(path, 1, 2)
try {
const info = image.info()
expect(info).toEqual({
width: 1,
height: 2,
sourceWidth: 1,
sourceHeight: 2,
format: "raw-rgba",
colorStatus: "explicit-srgb",
orientation: 1,
hasAlpha: true,
})
expect([...image.raw().data]).toEqual([1, 2, 3, 255, 5, 6, 7, 255])
expect(image.info()).toEqual(info)
await expectMissingPath(path)
} finally {
image.dispose()
await rm(directory, { recursive: true, force: true })
}
})

test("disposing an unmaterialized adopted RGBA file releases its path", async () => {
const directory = await mkdtemp(join(process.env.OTUI_IMAGE_TEST_TMPDIR ?? tmpdir(), "opentui-raw-image-"))
const path = join(directory, "frame.rgba")
await writeFile(path, Uint8Array.of(1, 2, 3, 255))

const image = NativeImage.adoptRgbaFile(path, 1, 1)
image.dispose()
try {
await expectMissingPath(path)
} finally {
await rm(directory, { recursive: true, force: true })
}
})

test("rejects invalid adopted RGBA file arguments without taking ownership", async () => {
const directory = await mkdtemp(join(process.env.OTUI_IMAGE_TEST_TMPDIR ?? tmpdir(), "opentui-raw-image-"))
const path = join(directory, "frame.rgba")
const pixels = Uint8Array.of(1, 2, 3, 255)
await writeFile(path, pixels)

try {
expect(() => NativeImage.adoptRgbaFile(path, 0, 1)).toThrow(RangeError)
expect(() => NativeImage.adoptRgbaFile("", 1, 1)).toThrow(TypeError)
expect([...new Uint8Array(await readFile(path))]).toEqual([...pixels])
} finally {
await rm(directory, { recursive: true, force: true })
}
})

test("requires an exact tightly packed RGBA file without taking ownership on failure", async () => {
const directory = await mkdtemp(join(process.env.OTUI_IMAGE_TEST_TMPDIR ?? tmpdir(), "opentui-raw-image-"))
const path = join(directory, "frame.rgba")
const pixels = Uint8Array.of(1, 2, 3, 255, 5)
await writeFile(path, pixels)

try {
expect(() => NativeImage.adoptRgbaFile(path, 1, 1)).toThrow()
expect([...new Uint8Array(await readFile(path))]).toEqual([...pixels])
} finally {
await rm(directory, { recursive: true, force: true })
}
})

test("transfers native RGBA pixels without copying", () => {
const pixels = Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8)
const image = NativeImage.fromRgba(pixels, 2, 1)
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/zig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,7 @@ function getOpenTUILib(libPath?: string) {
imageTestFailIccProfileCopyAllocationOnce: { args: [], returns: "void" },
imageDecode: { args: ["ptr", "u32", "ptr"], returns: "u32" },
imageCreateFromRgba: { args: ["ptr", "u64", "u32", "u32", "u32", "ptr"], returns: "u32" },
imageAdoptRgbaFile: { args: ["ptr", "u32", "u32", "u32", "ptr"], returns: "u32" },
imageDestroy: { args: ["u32"], returns: "void" },
imageGetInfo: { args: ["u32", "ptr"], returns: "u32" },
imageMaterialize: { args: ["u32"], returns: "u32" },
Expand Down Expand Up @@ -2678,6 +2679,7 @@ export interface RenderLib extends AudioEngineLib {
height: number,
stride: number,
) => { status: number; handle: ImageHandle | null }
imageAdoptRgbaFile: (path: string, width: number, height: number) => { status: number; handle: ImageHandle | null }
imageDestroy: (image: ImageHandle) => void
imageGetInfo: (image: ImageHandle) => { status: number; info: NativeImageInfo }
imageMaterialize: (image: ImageHandle) => number
Expand Down Expand Up @@ -5672,6 +5674,18 @@ class FFIRenderLib implements RenderLib {
return this.imageHandleResult(status, output)
}

public imageAdoptRgbaFile(
path: string,
width: number,
height: number,
): { status: number; handle: ImageHandle | null } {
const pathBytes = this.encoder.encode(path)
const pathLength = toSafeFFIU32Length(pathBytes.byteLength, "raw RGBA file path")
const output = new Uint32Array(1)
const status = this.opentui.symbols.imageAdoptRgbaFile(pathBytes, pathLength, width, height, output)
return this.imageHandleResult(status, output)
}

public imageDestroy(image: ImageHandle): void {
this.opentui.symbols.imageDestroy(image)
}
Expand Down
108 changes: 106 additions & 2 deletions packages/core/src/zig/bench/terminal-image_bench.zig
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const bench_utils = @import("../bench-utils.zig");
const ansi = @import("../ansi.zig");
const buffer = @import("../buffer.zig");
Expand Down Expand Up @@ -52,6 +53,26 @@ const CountingWriter = struct {
}
};

fn benchmarkTemporaryRoot(allocator: std.mem.Allocator) ![]u8 {
const environment_variables = if (builtin.os.tag == .windows)
[_][]const u8{ "TEMP", "TMP" }
else
[_][]const u8{"TMPDIR"};

for (environment_variables) |name| {
const value = std.process.getEnvVarOwned(allocator, name) catch continue;
if (value.len == 0) {
allocator.free(value);
continue;
}
if (std.fs.path.isAbsolute(value)) return value;
allocator.free(value);
}

if (builtin.os.tag == .windows) return error.TemporaryDirectoryUnavailable;
return allocator.dupe(u8, "/tmp");
}

fn fillPixels(pixels: []u8, scenario: Scenario) void {
var random = std.Random.DefaultPrng.init(0x1234_5678_9abc_def0);
for (0..@as(usize, scenario.width) * scenario.height) |index| {
Expand Down Expand Up @@ -172,6 +193,13 @@ fn appendResult(
});
}

fn outputMemStats(allocator: std.mem.Allocator, show_mem: bool, output_bytes: usize) !?[]const bench_utils.MemStat {
if (!show_mem) return null;
const values = try allocator.alloc(bench_utils.MemStat, 1);
values[0] = .{ .name = "Terminal output/frame", .bytes = output_bytes };
return values;
}

fn appendKittyBenchmarks(
allocator: std.mem.Allocator,
results: *std.ArrayListUnmanaged(bench_utils.BenchResult),
Expand Down Expand Up @@ -217,7 +245,7 @@ fn appendKittyBenchmarks(
for (0..20) |_| {
output.clearRetainingCapacity();
var timer = try std.time.Timer.start();
try terminal_image.writeKittyTransmit(output.writer(work_allocator), scenario.source, 7, false);
try terminal_image.writeKittyTransmit(output.writer(work_allocator), scenario.source, 7, false, false);
stats.record(timer.read());
}
const mem_stats: ?[]const bench_utils.MemStat = if (show_mem) blk: {
Expand All @@ -233,7 +261,7 @@ fn appendKittyBenchmarks(
for (0..20) |_| {
var counting: CountingWriter = .{};
var timer = try std.time.Timer.start();
try terminal_image.writeKittyTransmit(&counting, cover, 7, false);
try terminal_image.writeKittyTransmit(&counting, cover, 7, false, false);
stats.record(timer.read());
}
try appendResult(allocator, results, names[2], stats, null);
Expand Down Expand Up @@ -328,6 +356,81 @@ fn appendKittyBenchmarks(
}
}

// Compare OpenTUI host work for a producer that has already published an RGBA
// frame file. File creation and terminal-side file reads are outside the timed
// region; placement is also excluded because both renderer paths share it.
fn appendKittyRawRgbaFileBenchmarks(
allocator: std.mem.Allocator,
results: *std.ArrayListUnmanaged(bench_utils.BenchResult),
show_mem: bool,
bench_filter: ?[]const u8,
) !void {
const width: u32 = 1280;
const height: u32 = 720;
const inline_name = "RGBA 1280x720 copy + Kitty inline";
const file_name = "RGBA 1280x720 adopt + Kitty file";
const run_inline = bench_utils.matchesBenchFilter(inline_name, bench_filter);
const run_file = builtin.os.tag != .windows and bench_utils.matchesBenchFilter(file_name, bench_filter);
if (!run_inline and !run_file) return;

var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{};
defer _ = gpa.deinit();
const work_allocator = gpa.allocator();
const pixels = try work_allocator.alloc(u8, @as(usize, width) * height * 4);
defer work_allocator.free(pixels);
fillPixels(pixels, .{ .name = "", .width = width, .height = height, .pattern = .photo });

if (run_inline) {
var stats: bench_utils.BenchStats = .{};
var output_bytes: usize = 0;
for (0..10) |_| {
var counting: CountingWriter = .{};
var timer = try std.time.Timer.start();
{
const value = try image.createFromRgba(work_allocator, pixels, width, height, width * 4);
defer value.deinit();
try terminal_image.writeKittyTransmitFormat(&counting, value, 7, false, .rgba);
}
stats.record(timer.read());
output_bytes = counting.bytes;
}
try appendResult(allocator, results, inline_name, stats, try outputMemStats(allocator, show_mem, output_bytes));
}

if (!run_file) return;
const temporary_root = try benchmarkTemporaryRoot(work_allocator);
defer work_allocator.free(temporary_root);
const temporary_name = try std.fmt.allocPrint(
work_allocator,
"opentui-tty-graphics-protocol-benchmark-{d}.rgba",
.{std.time.nanoTimestamp()},
);
defer work_allocator.free(temporary_name);
const absolute_path = try std.fs.path.join(work_allocator, &.{ temporary_root, temporary_name });
defer work_allocator.free(absolute_path);
defer std.fs.deleteFileAbsolute(absolute_path) catch {};
{
const file = try std.fs.createFileAbsolute(absolute_path, .{});
defer file.close();
try file.writeAll(pixels);
}

var stats: bench_utils.BenchStats = .{};
var output_bytes: usize = 0;
for (0..500) |_| {
var counting: CountingWriter = .{};
var timer = try std.time.Timer.start();
{
const value = try image.adoptRgbaFile(work_allocator, absolute_path, width, height);
defer value.deinit();
try terminal_image.writeKittyTransmit(&counting, value, 7, false, true);
}
stats.record(timer.read());
output_bytes = counting.bytes;
}
try appendResult(allocator, results, file_name, stats, try outputMemStats(allocator, show_mem, output_bytes));
}

fn appendKittyPngBenchmarks(
allocator: std.mem.Allocator,
results: *std.ArrayListUnmanaged(bench_utils.BenchResult),
Expand Down Expand Up @@ -715,6 +818,7 @@ pub fn run(allocator: std.mem.Allocator, show_mem: bool, bench_filter: ?[]const
}
}
try appendKittyBenchmarks(allocator, &results, show_mem, bench_filter);
try appendKittyRawRgbaFileBenchmarks(allocator, &results, show_mem, bench_filter);
try appendKittyPngBenchmarks(allocator, &results, show_mem, bench_filter);
try appendDragonGeometryBenchmarks(allocator, &results, show_mem, bench_filter);
try appendImageSwitchBenchmarks(allocator, &results, show_mem, bench_filter);
Expand Down
Loading
Loading