ReviewOS

also looking at this

home-lang/home

feat(video): add checked elementary packet and PTS outputs

#919
Merged chris wants to merge feat/packet-output-streams into main
4 files +148 -0
.github/workflows/media-packet-output.ymladded+22-0
Changes to .github/workflows/media-packet-output.yml
@@ -0,0 +1,22 @@
1name: Packet and PTS output helpers
2on:
3 push:
4 branches: [feat/packet-output-streams]
5 pull_request:
6 paths: ['packages/video/src/core/packet_output.zig', 'packages/video/docs/packet-output.md', '.github/workflows/media-packet-output.yml']
7permissions:
8 contents: read
9jobs:
10 helpers:
11 strategy:
12 fail-fast: false
13 matrix:
14 os: [ubuntu-24.04, ubuntu-24.04-arm]
15 zig: ['0.15.2', 'master']
16 runs-on: ${{ matrix.os }}
17 steps:
18 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
19 - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29
20 with:
21 version: ${{ matrix.zig }}
22 - run: zig test packages/video/src/core/packet_output.zig
packages/video/docs/packet-output.mdadded+34-0
Changes to packages/video/docs/packet-output.md
@@ -0,0 +1,34 @@
1# Checked elementary packet and PTS output
2
3`src/core/packet_output.zig` is a standalone, allocation-free boundary for
4reusable streaming sinks. The broader sink graph includes decoded-frame and
5accumulating packet collections; this module neither retains borrowed packets
6nor imports that graph.
7
8`annexB(input, output)` converts four-byte big-endian length-prefixed AVC/HEVC
9NAL units to four-byte Annex B start codes. It checks every length before writing
10any destination bytes, rejects empty/truncated/zero-length input, and checks
11output capacity. Input/output must not overlap. Parameter sets can be converted
12separately and prepended at the first key of a newly attached output.
13
14`formatPts(timestamp_us, output)` formats integer microsecond PTS as milliseconds
15with exactly three decimal digits and LF. Zero and all u64 timestamps are exact;
16no floating-point rounding or locale formatting is used. Too-small buffers remain
17unchanged. A 32-byte destination covers the complete u64 range.
18
19`PtsWriter` wraps an explicit synchronous `Sink{context,write}`. Sink callbacks
20must completely accept their borrowed data or return an error. Rejected
21non-increasing timestamps do not touch the sink. A sink failure poisons the
22writer; create a fresh writer for a new clip/epoch. The module does not own files,
23threads or packet storage and does not depend on FFmpeg/Libav.
24
25Focused tests on Zig 0.15.2 and the Home development toolchain:
26
27```sh
28zig test packages/video/src/core/packet_output.zig
29```
30
31Tests cover multi-NAL conversion, malformed/truncated input, exact maximum PTS,
32small-buffer state preservation, monotonicity and sink-failure poisoning.
33zig-camera uses the helpers at its native callback output boundary, with
34independent video decoding and media/PTS timeline checks in its integration suite.
packages/video/src/core/packet_output.zigadded+89-0
Changes to packages/video/src/core/packet_output.zig
@@ -0,0 +1,89 @@
1//! Allocation-free elementary-packet and PTS output helpers. Callers own sinks.
2const std = ;
3/// Validate every four-byte big-endian NAL length before any destination write.
4/// Annex B uses four-byte start codes, so conversion preserves byte length.
5pub fn annexB(input: []const u8, output: []u8) ![]const u8 {
6 if (input.len == 0) return error.InvalidPacket;
7 var at: usize = 0;
8 while (at < input.len) {
9 if (input.len - at < 4) return error.InvalidPacket;
10 const n = std.mem.readInt(u32, input[at..][0..4], .big);
11 at += 4;
12 if (n == 0 or n > input.len - at) return error.InvalidPacket;
13 at += n;
14 }
15 if (output.len < input.len) return error.BufferTooSmall;
16 at = 0;
17 while (at < input.len) {
18 const n = std.mem.readInt(u32, input[at..][0..4], .big);
19 @memcpy(output[at..][0..4], &[_]u8{ 0, 0, 0, 1 });
20 @memcpy(output[at + 4 ..][0..n], input[at + 4 ..][0..n]);
21 at += 4 + n;
22 }
23 return output[0..input.len];
24}
25/// Microsecond PTS as milliseconds with exactly three decimals and LF. Integer
26/// arithmetic preserves zero and all u64 values without floating-point loss.
27pub fn formatPts(timestamp_us: u64, output: []u8) ![]const u8 {
28 var scratch: [32]u8 = undefined;
29 const line = try std.fmt.bufPrint(&scratch, "{d}.{d:0>3}\n", .{ timestamp_us / 1000, timestamp_us % 1000 });
30 if (output.len < line.len) return error.BufferTooSmall;
31 @memcpy(output[0..line.len], line);
32 return output[0..line.len];
33}
34pub const Sink = struct { context: *anyopaque, write: *const fn (*anyopaque, []const u8) anyerror!void };
35pub const PtsWriter = struct {
36 sink: Sink,
37 last: ?u64 = null,
38 failed: bool = false,
39 pub fn frame(self: *PtsWriter, timestamp_us: u64) !void {
40 if (self.failed) return error.SinkFailed;
41 if (self.last) |last| if (timestamp_us <= last) return error.InvalidTimestamp;
42 var buffer: [32]u8 = undefined;
43 const line = try formatPts(timestamp_us, &buffer);
44 self.sink.write(self.sink.context, line) catch |err| {
45 self.failed = true;
46 return err;
47 };
48 self.last = timestamp_us;
49 }
50};
51test "Annex B converts multiple NAL units and rejects malformed input atomically" {
52 var out: [16]u8 = undefined;
53 @memset(&out, 0x55);
54 for ([_][]const u8{ &.{}, &.{0}, &.{ 0, 0, 0, 0 }, &.{ 0, 0, 0, 2, 1 }, &.{ 0, 0, 0, 1, 5, 0 } }) |bad| {
55 try std.testing.expectError(error.InvalidPacket, annexB(bad, &out));
56 for (out) |byte| try std.testing.expectEqual(@as(u8, 0x55), byte);
57 }
58 const packet = [_]u8{ 0, 0, 0, 2, 0x67, 0x11, 0, 0, 0, 1, 0x68 };
59 try std.testing.expectError(error.BufferTooSmall, annexB(&packet, out[0..10]));
60 try std.testing.expectEqualSlices(u8, &.{ 0, 0, 0, 1, 0x67, 0x11, 0, 0, 0, 1, 0x68 }, try annexB(&packet, &out));
61}
62test "PTS formatting is exact and too-small destinations are unchanged" {
63 var out: [32]u8 = undefined;
64 try std.testing.expectEqualStrings("0.000\n", try formatPts(0, &out));
65 try std.testing.expectEqualStrings("41.666\n", try formatPts(41666, &out));
66 try std.testing.expectEqualStrings("18446744073709551.615\n", try formatPts(std.math.maxInt(u64), &out));
67 @memset(&out, 0x55);
68 try std.testing.expectError(error.BufferTooSmall, formatPts(1234, out[0..2]));
69 for (out) |byte| try std.testing.expectEqual(@as(u8, 0x55), byte);
70}
71test "PTS sink failure poisons the writer; rejected timestamps do not call the sink" {
72 const Test = struct {
73 calls: usize = 0,
74 fn write(ctx: *anyopaque, data: []const u8) !void {
75 const s: *@This() = @ptrCast(@alignCast(ctx));
76 s.calls += 1;
77 if (s.calls > 1) return error.Injected;
78 try std.testing.expectEqualStrings("0.000\n", data);
79 }
80 };
81 var state: Test = .{};
82 var writer = PtsWriter{ .sink = .{ .context = &state, .write = Test.write } };
83 try writer.frame(0);
84 try std.testing.expectError(error.InvalidTimestamp, writer.frame(0));
85 try std.testing.expectEqual(@as(usize, 1), state.calls);
86 try std.testing.expectError(error.Injected, writer.frame(1));
87 try std.testing.expectError(error.SinkFailed, writer.frame(2));
88 try std.testing.expectEqual(@as(usize, 2), state.calls);
89}
packages/video/src/video.zigmodified+3-0
Changes to packages/video/src/video.zig
@@ -815,6 +815,9 @@ pub const RawAudioSource = sources.RawAudioSource;
815815// Media Sinks
816816// ============================================================================
817817
818/// Standalone checked elementary packet/PTS streaming helpers.
819pub const packet_output = ;
820
818821pub const sinks = ;
819822pub const VideoSink = sinks.VideoSink;
820823pub const AudioSink = sinks.AudioSink;