a5726e8a
Add --capture mode: render a VT script to PNG
a73x 2026-04-17 15:37
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -301,4 +301,21 @@ pub fn build(b: *std.Build) void { | |||
| 301 | }); | 301 | }); |
| 302 | const png_tests = b.addTest(.{ .root_module = png_test_mod }); | 302 | const png_tests = b.addTest(.{ .root_module = png_test_mod }); |
| 303 | test_step.dependOn(&b.addRunArtifact(png_tests).step); | 303 | test_step.dependOn(&b.addRunArtifact(png_tests).step); |
| 304 | |||
| 305 | // capture module — --capture mode (render a VT script to PNG) | ||
| 306 | const capture_mod = b.createModule(.{ | ||
| 307 | .root_source_file = b.path("src/capture.zig"), | ||
| 308 | .target = target, | ||
| 309 | .optimize = optimize, | ||
| 310 | .link_libc = true, | ||
| 311 | }); | ||
| 312 | capture_mod.addImport("vt", vt_mod); | ||
| 313 | capture_mod.addImport("pty", pty_mod); | ||
| 314 | capture_mod.addImport("wayland-client", wayland_mod); | ||
| 315 | capture_mod.addImport("renderer", renderer_mod); | ||
| 316 | capture_mod.addImport("font", font_mod); | ||
| 317 | capture_mod.addImport("config", config_mod); | ||
| 318 | capture_mod.addImport("png", png_mod); | ||
| 319 | capture_mod.addImport("vulkan", vulkan_module); | ||
| 320 | exe_mod.addImport("capture", capture_mod); | ||
| 304 | } | 321 | } |
src/capture.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,402 @@ | |||
| 1 | //! `--capture <script> <output.png>` mode. | ||
| 2 | //! | ||
| 3 | //! Renders a VT script to a single PNG frame for golden-image testing. | ||
| 4 | //! | ||
| 5 | //! 1. Stand up a Wayland window + Vulkan context at a forced 80x24 grid, | ||
| 6 | //! buffer scale = 1 (so renders are deterministic across multi-monitor | ||
| 7 | //! setups). | ||
| 8 | //! 2. Wait up to 3s for the window to become visible. | ||
| 9 | //! 3. Pipe the script through a PTY via `/bin/cat`, then drain remaining | ||
| 10 | //! output after cat exits. | ||
| 11 | //! 4. Snapshot the terminal, build a flat Instance list for every cell, | ||
| 12 | //! render a single frame to an offscreen VkImage, read the BGRA bytes | ||
| 13 | //! back, convert to RGBA and write a PNG. | ||
| 14 | //! | ||
| 15 | //! The window itself is never committed/presented — the offscreen target | ||
| 16 | //! is its own framebuffer. We still need the Wayland surface so Vulkan | ||
| 17 | //! can allocate a swapchain (required by the current Context.init path) | ||
| 18 | //! and so the compositor hands us a real configure event. | ||
| 19 | |||
| 20 | const std = @import("std"); | ||
| 21 | const vt = @import("vt"); | ||
| 22 | const pty = @import("pty"); | ||
| 23 | const wayland_client = @import("wayland-client"); | ||
| 24 | const renderer = @import("renderer"); | ||
| 25 | const font = @import("font"); | ||
| 26 | const config = @import("config"); | ||
| 27 | const png = @import("png"); | ||
| 28 | const vk = @import("vulkan"); | ||
| 29 | |||
| 30 | pub const CaptureError = error{ | ||
| 31 | MissingArgs, | ||
| 32 | ScriptNotFound, | ||
| 33 | OutputPathUnwritable, | ||
| 34 | WindowNotVisible, | ||
| 35 | WindowSizeMismatch, | ||
| 36 | PngEncodeFailed, | ||
| 37 | }; | ||
| 38 | |||
| 39 | const CAPTURE_COLS: u16 = 80; | ||
| 40 | const CAPTURE_ROWS: u16 = 24; | ||
| 41 | const VISIBILITY_DEADLINE_NS: i128 = 3 * std.time.ns_per_s; | ||
| 42 | |||
| 43 | /// Entry point. `argv[0]` is `--capture`; argv[1] = script path, argv[2] = out path. | ||
| 44 | pub fn run(alloc: std.mem.Allocator, argv: []const [:0]const u8) !void { | ||
| 45 | if (argv.len < 3) { | ||
| 46 | std.debug.print("usage: waystty --capture <script.vt> <output.png>\n", .{}); | ||
| 47 | return CaptureError.MissingArgs; | ||
| 48 | } | ||
| 49 | const script_path = argv[1]; | ||
| 50 | const out_path = argv[2]; | ||
| 51 | |||
| 52 | // Probe script path up-front so we fail fast with a clean error rather | ||
| 53 | // than having cat silently print a "No such file" diagnostic onto the | ||
| 54 | // captured image. | ||
| 55 | std.fs.cwd().access(script_path, .{}) catch |err| { | ||
| 56 | std.debug.print("capture: cannot read script {s}: {t}\n", .{ script_path, err }); | ||
| 57 | return CaptureError.ScriptNotFound; | ||
| 58 | }; | ||
| 59 | |||
| 60 | // === font + cell metrics (scale=1, same lookup as runTerminal) === | ||
| 61 | var font_lookup = try font.lookupConfiguredFont(alloc); | ||
| 62 | defer font_lookup.deinit(alloc); | ||
| 63 | |||
| 64 | const font_size: u32 = config.font_size_px; | ||
| 65 | var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, font_size); | ||
| 66 | defer face.deinit(); | ||
| 67 | |||
| 68 | const cell_w: u32 = face.cellWidth(); | ||
| 69 | const cell_h: u32 = face.cellHeight(); | ||
| 70 | const baseline: u32 = face.baseline(); | ||
| 71 | |||
| 72 | const px_w: u32 = @as(u32, CAPTURE_COLS) * cell_w; | ||
| 73 | const px_h: u32 = @as(u32, CAPTURE_ROWS) * cell_h; | ||
| 74 | |||
| 75 | // === wayland === | ||
| 76 | const conn = try wayland_client.Connection.init(alloc); | ||
| 77 | defer conn.deinit(); | ||
| 78 | |||
| 79 | const window = try conn.createWindow(alloc, "waystty-capture"); | ||
| 80 | defer window.deinit(); | ||
| 81 | |||
| 82 | window.width = px_w; | ||
| 83 | window.height = px_h; | ||
| 84 | _ = conn.display.roundtrip(); | ||
| 85 | |||
| 86 | // === vulkan context (swapchain matches requested px size) === | ||
| 87 | var ctx = try renderer.Context.init( | ||
| 88 | alloc, | ||
| 89 | @ptrCast(conn.display), | ||
| 90 | @ptrCast(window.surface), | ||
| 91 | px_w, | ||
| 92 | px_h, | ||
| 93 | ); | ||
| 94 | defer ctx.deinit(); | ||
| 95 | |||
| 96 | // === offscreen render target (separate framebuffer; renders don't present) === | ||
| 97 | var offscreen = try renderer.createOffscreen( | ||
| 98 | ctx.vki, | ||
| 99 | ctx.vkd, | ||
| 100 | ctx.physical_device, | ||
| 101 | ctx.device, | ||
| 102 | ctx.render_pass, | ||
| 103 | ctx.swapchain_format, | ||
| 104 | px_w, | ||
| 105 | px_h, | ||
| 106 | ); | ||
| 107 | defer renderer.destroyOffscreen(ctx.vkd, ctx.device, offscreen); | ||
| 108 | |||
| 109 | // === glyph atlas + printable ASCII warm-up (matches runTerminal) === | ||
| 110 | var atlas = try font.Atlas.init(alloc, 1024, 1024); | ||
| 111 | defer atlas.deinit(); | ||
| 112 | |||
| 113 | for (32..127) |cp| { | ||
| 114 | _ = atlas.getOrInsert(&face, @intCast(cp)) catch |err| switch (err) { | ||
| 115 | error.AtlasFull => break, | ||
| 116 | else => return err, | ||
| 117 | }; | ||
| 118 | } | ||
| 119 | try ctx.uploadAtlas(atlas.pixels); | ||
| 120 | atlas.last_uploaded_y = atlas.cursor_y; | ||
| 121 | atlas.needs_full_upload = false; | ||
| 122 | atlas.dirty = false; | ||
| 123 | |||
| 124 | // === terminal === | ||
| 125 | var term = try vt.Terminal.init(alloc, .{ | ||
| 126 | .cols = CAPTURE_COLS, | ||
| 127 | .rows = CAPTURE_ROWS, | ||
| 128 | .max_scrollback = 1000, | ||
| 129 | }); | ||
| 130 | defer term.deinit(); | ||
| 131 | term.setReportedSize(.{ | ||
| 132 | .rows = CAPTURE_ROWS, | ||
| 133 | .columns = CAPTURE_COLS, | ||
| 134 | .cell_width = cell_w, | ||
| 135 | .cell_height = cell_h, | ||
| 136 | }); | ||
| 137 | |||
| 138 | // === visibility wait + size check === | ||
| 139 | try waitUntilVisible(conn, window); | ||
| 140 | |||
| 141 | if (window.width != px_w or window.height != px_h) { | ||
| 142 | std.debug.print( | ||
| 143 | "capture: window size mismatch (got {d}x{d}, expected {d}x{d})\n", | ||
| 144 | .{ window.width, window.height, px_w, px_h }, | ||
| 145 | ); | ||
| 146 | return CaptureError.WindowSizeMismatch; | ||
| 147 | } | ||
| 148 | |||
| 149 | // === play script through /bin/cat === | ||
| 150 | try playScript(alloc, term, script_path); | ||
| 151 | |||
| 152 | // === snapshot + build instances === | ||
| 153 | try term.snapshot(); | ||
| 154 | |||
| 155 | var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty; | ||
| 156 | defer instances.deinit(alloc); | ||
| 157 | |||
| 158 | try buildInstancesForSnapshot( | ||
| 159 | alloc, | ||
| 160 | &instances, | ||
| 161 | term, | ||
| 162 | &face, | ||
| 163 | &atlas, | ||
| 164 | cell_w, | ||
| 165 | cell_h, | ||
| 166 | baseline, | ||
| 167 | ); | ||
| 168 | |||
| 169 | // If the script needed glyphs that weren't in the ASCII warm-up set, | ||
| 170 | // the atlas pixels are newer than the GPU copy. Re-upload the full | ||
| 171 | // atlas so the render samples valid texels. | ||
| 172 | if (atlas.dirty) { | ||
| 173 | try ctx.uploadAtlas(atlas.pixels); | ||
| 174 | atlas.dirty = false; | ||
| 175 | atlas.last_uploaded_y = atlas.cursor_y; | ||
| 176 | } | ||
| 177 | |||
| 178 | // === render one frame to offscreen === | ||
| 179 | const push = renderer.PushConstants{ | ||
| 180 | .viewport_size = .{ @floatFromInt(px_w), @floatFromInt(px_h) }, | ||
| 181 | .cell_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, | ||
| 182 | .coverage_params = renderer.coverageVariantParams(.baseline), | ||
| 183 | }; | ||
| 184 | |||
| 185 | try ctx.renderToOffscreen(&offscreen, instances.items, push); | ||
| 186 | |||
| 187 | // === readback BGRA->RGBA === | ||
| 188 | const rgba = try alloc.alloc(u8, @as(usize, px_w) * px_h * 4); | ||
| 189 | defer alloc.free(rgba); | ||
| 190 | try ctx.readbackOffscreen(&offscreen, rgba); | ||
| 191 | |||
| 192 | // === encode PNG === | ||
| 193 | try writePng(alloc, out_path, px_w, px_h, rgba); | ||
| 194 | |||
| 195 | std.debug.print("capture: wrote {s} ({d}x{d})\n", .{ out_path, px_w, px_h }); | ||
| 196 | } | ||
| 197 | |||
| 198 | /// Wait up to VISIBILITY_DEADLINE_NS for the Wayland compositor to `configure` | ||
| 199 | /// the surface. For `--capture` we don't need the surface to actually be | ||
| 200 | /// mapped onto an output (which would require committing a presentable | ||
| 201 | /// buffer via the swapchain — we deliberately skip that since rendering is | ||
| 202 | /// offscreen). A configured surface is enough to know our fixed 80x24 | ||
| 203 | /// geometry was accepted. | ||
| 204 | fn waitUntilVisible(conn: *wayland_client.Connection, window: *wayland_client.Window) !void { | ||
| 205 | const deadline = @as(i128, std.time.nanoTimestamp()) + VISIBILITY_DEADLINE_NS; | ||
| 206 | while (std.time.nanoTimestamp() < deadline) { | ||
| 207 | _ = conn.display.roundtrip(); | ||
| 208 | if (window.state.configured) return; | ||
| 209 | std.Thread.sleep(10 * std.time.ns_per_ms); | ||
| 210 | } | ||
| 211 | std.debug.print( | ||
| 212 | "capture: window never configured within 3s\n", | ||
| 213 | .{}, | ||
| 214 | ); | ||
| 215 | return CaptureError.WindowNotVisible; | ||
| 216 | } | ||
| 217 | |||
| 218 | /// Spawn `/bin/cat <script>` on a PTY; feed all its output into `term`. | ||
| 219 | /// Returns once the child has exited AND two consecutive 20 ms polls | ||
| 220 | /// produce no new bytes (drain). | ||
| 221 | fn playScript( | ||
| 222 | alloc: std.mem.Allocator, | ||
| 223 | term: *vt.Terminal, | ||
| 224 | script_path: [:0]const u8, | ||
| 225 | ) !void { | ||
| 226 | _ = alloc; | ||
| 227 | |||
| 228 | var p = try pty.Pty.spawn(.{ | ||
| 229 | .cols = CAPTURE_COLS, | ||
| 230 | .rows = CAPTURE_ROWS, | ||
| 231 | .shell = "/bin/cat", | ||
| 232 | .shell_args = &.{script_path}, | ||
| 233 | }); | ||
| 234 | defer p.deinit(); | ||
| 235 | |||
| 236 | var buf: [4096]u8 = undefined; | ||
| 237 | var consecutive_empty: u32 = 0; | ||
| 238 | |||
| 239 | // Loop until child exited AND we saw two empty polls in a row (to make | ||
| 240 | // sure any straggler bytes in the master buffer have been drained). | ||
| 241 | while (true) { | ||
| 242 | var pfd = [_]std.posix.pollfd{ | ||
| 243 | .{ .fd = p.master_fd, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 244 | }; | ||
| 245 | _ = std.posix.poll(&pfd, 20) catch 0; | ||
| 246 | |||
| 247 | var saw_bytes = false; | ||
| 248 | while (true) { | ||
| 249 | const n = p.read(&buf) catch |err| switch (err) { | ||
| 250 | error.WouldBlock => break, | ||
| 251 | // EIO on Linux after slave fd closes is the normal signal | ||
| 252 | // that cat exited. Break out — the child reaper below will | ||
| 253 | // notice. | ||
| 254 | error.InputOutput => break, | ||
| 255 | else => return err, | ||
| 256 | }; | ||
| 257 | if (n == 0) break; | ||
| 258 | term.write(buf[0..n]); | ||
| 259 | saw_bytes = true; | ||
| 260 | } | ||
| 261 | |||
| 262 | const alive = p.isChildAlive(); | ||
| 263 | if (saw_bytes) { | ||
| 264 | consecutive_empty = 0; | ||
| 265 | } else if (!alive) { | ||
| 266 | consecutive_empty += 1; | ||
| 267 | if (consecutive_empty >= 2) break; | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 271 | // VT parser settle — give any delayed effects (timers, etc) a beat. | ||
| 272 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 273 | } | ||
| 274 | |||
| 275 | /// Build a flat Instance list covering every cell in the current snapshot. | ||
| 276 | /// Does not do dirty tracking — this is a one-shot full rebuild. Mirrors | ||
| 277 | /// the per-cell logic in `main.zig:rebuildRowInstances`, minus the | ||
| 278 | /// selection/cursor overlay. | ||
| 279 | fn buildInstancesForSnapshot( | ||
| 280 | alloc: std.mem.Allocator, | ||
| 281 | instances: *std.ArrayListUnmanaged(renderer.Instance), | ||
| 282 | term: *vt.Terminal, | ||
| 283 | face: *font.Face, | ||
| 284 | atlas: *font.Atlas, | ||
| 285 | cell_w: u32, | ||
| 286 | cell_h: u32, | ||
| 287 | baseline: u32, | ||
| 288 | ) !void { | ||
| 289 | const default_bg = term.backgroundColor(); | ||
| 290 | const bg_uv = atlas.cursorUV(); | ||
| 291 | |||
| 292 | const term_rows = term.render_state.row_data.items(.cells); | ||
| 293 | var row_idx: u32 = 0; | ||
| 294 | while (row_idx < term_rows.len) : (row_idx += 1) { | ||
| 295 | const row_cells = term_rows[row_idx]; | ||
| 296 | const raw_cells = row_cells.items(.raw); | ||
| 297 | var col_idx: u32 = 0; | ||
| 298 | while (col_idx < raw_cells.len) : (col_idx += 1) { | ||
| 299 | const cp = raw_cells[col_idx].codepoint(); | ||
| 300 | const colors = term.cellColors(row_cells.get(col_idx)); | ||
| 301 | const glyph_uv = if (cp == 0 or cp == ' ') | ||
| 302 | null | ||
| 303 | else | ||
| 304 | atlas.getOrInsert(face, @intCast(cp)) catch null; | ||
| 305 | |||
| 306 | try appendCellInstances( | ||
| 307 | alloc, | ||
| 308 | instances, | ||
| 309 | row_idx, | ||
| 310 | col_idx, | ||
| 311 | cell_w, | ||
| 312 | cell_h, | ||
| 313 | baseline, | ||
| 314 | glyph_uv, | ||
| 315 | bg_uv, | ||
| 316 | colors, | ||
| 317 | default_bg, | ||
| 318 | ); | ||
| 319 | } | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 323 | /// Mirror of main.zig's `appendCellInstances` (kept local to avoid making | ||
| 324 | /// that function public just for this module). Appends 0-2 instances per | ||
| 325 | /// cell: a filled-background quad if the bg differs from the terminal | ||
| 326 | /// default, plus the glyph quad if the cell has a printable codepoint. | ||
| 327 | fn appendCellInstances( | ||
| 328 | alloc: std.mem.Allocator, | ||
| 329 | instances: *std.ArrayListUnmanaged(renderer.Instance), | ||
| 330 | row_idx: u32, | ||
| 331 | col_idx: u32, | ||
| 332 | cell_w: u32, | ||
| 333 | cell_h: u32, | ||
| 334 | baseline: u32, | ||
| 335 | glyph_uv: ?font.GlyphUV, | ||
| 336 | bg_uv: font.GlyphUV, | ||
| 337 | colors: vt.CellColors, | ||
| 338 | default_bg: [4]f32, | ||
| 339 | ) !void { | ||
| 340 | if (!std.meta.eql(colors.bg, default_bg)) { | ||
| 341 | try instances.append(alloc, .{ | ||
| 342 | .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) }, | ||
| 343 | .glyph_size = .{ @floatFromInt(cell_w), @floatFromInt(cell_h) }, | ||
| 344 | .glyph_bearing = .{ 0, 0 }, | ||
| 345 | .uv_rect = .{ bg_uv.u0, bg_uv.v0, bg_uv.u1, bg_uv.v1 }, | ||
| 346 | .fg = colors.bg, | ||
| 347 | .bg = colors.bg, | ||
| 348 | }); | ||
| 349 | } | ||
| 350 | |||
| 351 | const uv = glyph_uv orelse return; | ||
| 352 | try instances.append(alloc, .{ | ||
| 353 | .cell_pos = .{ @floatFromInt(col_idx), @floatFromInt(row_idx) }, | ||
| 354 | .glyph_size = .{ @floatFromInt(uv.width), @floatFromInt(uv.height) }, | ||
| 355 | .glyph_bearing = .{ | ||
| 356 | @floatFromInt(uv.bearing_x), | ||
| 357 | glyphTopOffset(baseline, uv.bearing_y), | ||
| 358 | }, | ||
| 359 | .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 }, | ||
| 360 | .fg = colors.fg, | ||
| 361 | .bg = colors.bg, | ||
| 362 | }); | ||
| 363 | } | ||
| 364 | |||
| 365 | fn glyphTopOffset(baseline: u32, bearing_y: i32) f32 { | ||
| 366 | return @as(f32, @floatFromInt(baseline)) - @as(f32, @floatFromInt(bearing_y)); | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Encode `rgba` as a PNG to a brand-new file at `path`. Buffers the full | ||
| 370 | /// encoded byte stream in memory (fine for 80x24@16px: under 200 KB) and | ||
| 371 | /// writes it in one shot. | ||
| 372 | fn writePng( | ||
| 373 | alloc: std.mem.Allocator, | ||
| 374 | path: [:0]const u8, | ||
| 375 | width: u32, | ||
| 376 | height: u32, | ||
| 377 | rgba: []u8, | ||
| 378 | ) !void { | ||
| 379 | var buf: std.ArrayList(u8) = .empty; | ||
| 380 | defer buf.deinit(alloc); | ||
| 381 | |||
| 382 | const img: png.Image = .{ | ||
| 383 | .width = width, | ||
| 384 | .height = height, | ||
| 385 | .pixels = rgba, | ||
| 386 | }; | ||
| 387 | png.encode(alloc, img, buf.writer(alloc)) catch |err| { | ||
| 388 | std.debug.print("capture: PNG encode failed: {t}\n", .{err}); | ||
| 389 | return CaptureError.PngEncodeFailed; | ||
| 390 | }; | ||
| 391 | |||
| 392 | const file = std.fs.cwd().createFile(path, .{ .truncate = true }) catch |err| { | ||
| 393 | std.debug.print("capture: cannot open output {s}: {t}\n", .{ path, err }); | ||
| 394 | return CaptureError.OutputPathUnwritable; | ||
| 395 | }; | ||
| 396 | defer file.close(); | ||
| 397 | |||
| 398 | file.writeAll(buf.items) catch |err| { | ||
| 399 | std.debug.print("capture: write failed for {s}: {t}\n", .{ path, err }); | ||
| 400 | return CaptureError.OutputPathUnwritable; | ||
| 401 | }; | ||
| 402 | } | ||
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -103,6 +103,11 @@ pub fn main() !void { | |||
| 103 | return runHiddenFreezeRegression(alloc); | 103 | return runHiddenFreezeRegression(alloc); |
| 104 | } | 104 | } |
| 105 | 105 | ||
| 106 | if (args.len >= 2 and std.mem.eql(u8, args[1], "--capture")) { | ||
| 107 | const capture = @import("capture"); | ||
| 108 | return capture.run(alloc, args[1..]); | ||
| 109 | } | ||
| 110 | |||
| 106 | return runTerminal(alloc); | 111 | return runTerminal(alloc); |
| 107 | } | 112 | } |
| 108 | 113 | ||