a73x

a5726e8a

Add --capture mode: render a VT script to PNG

a73x   2026-04-17 15:37

Commit message
Add --capture mode: render a VT script to PNG

Forces 80x24 grid at scale=1, waits for window configure, plays
script through /bin/cat on PTY, drains + settles, renders one frame
to offscreen VkImage, reads back BGRA->RGBA, writes PNG.

Capture keeps rendering offscreen on a dedicated OffscreenTarget so
the swapchain is never presented and the Wayland surface never needs
to be mapped onto an output — configure alone is sufficient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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