also looking at this
feat(video): add bounded packet history, delayed FIFO and owned snapshots
#916
3 files
+229
-0
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,24 @@ | ||
| 1 | # Keyframe-aligned packet history | |
| 2 | ||
| 3 | `core/packet_ring.zig` (exported as `video.packet_ring`) adapts the existing video | |
| 4 | ring-buffer structure to encoded pre-roll: storage consists of one fixed byte | |
| 5 | arena and a fixed entry array. Push copies borrowed packets without allocating | |
| 6 | or blocking. The caller serializes all access and chooses timestamp units. | |
| 7 | ||
| 8 | Byte capacity, frame capacity and duration are hard upper bounds. Eviction | |
| 9 | removes dependent packets until the oldest retained packet is a keyframe. | |
| 10 | If the limits cannot hold a GOP, history can be empty until the next key. | |
| 11 | Oversized packets and non-increasing timestamps are rejected without modifying | |
| 12 | retained history. Parameter sets/codec configuration belong outside the ring. | |
| 13 | `entry` returns metadata; `copy` explicitly copies a retained payload to caller | |
| 14 | storage, including when it wraps in the byte arena. Views expire on mutation. | |
| 15 | ||
| 16 | Three standalone regression tests cover arena wrap, byte/frame/time eviction, | |
| 17 | keyframe gating, rejected-input atomicity, zero capacities and allocator failure | |
| 18 | cleanup. Tested on Zig 0.15.2 and the installed 0.17 development toolchain. This | |
| 19 | focused validation does not establish correctness of the wider video graph. | |
| 20 | ||
| 21 | zig-camera uses this module with Home JPEG/Matroska and x264/x265/fragmented MP4. | |
| 22 | It retains compressed access units, without retaining camera DMA leases or raw | |
| 23 | RGB frames. Explicit snapshots need scratch space for one largest retained | |
| 24 | packet; remuxing starts with fresh codec headers and resets file timestamps. | |
| @@ -0,0 +1,114 @@ | ||
| 1 | //! Owned encoded-packet history. One caller serializes all access. Fixed byte | |
| 2 | //! arena and entry array; push never allocates, blocks, or keeps borrowed input. | |
| 3 | //! Eviction drops dependent packets until the oldest retained packet is a key. | |
| 4 | const std = ; | |
| 5 | pub const Entry = struct { offset: usize, length: usize, timestamp: u64, key: bool }; | |
| 6 | pub const Ring = struct { | |
| 7 | allocator: std.mem.Allocator, | |
| 8 | bytes: []u8, | |
| 9 | entries: []Entry, | |
| 10 | duration: u64, | |
| 11 | head: usize = 0, | |
| 12 | count: usize = 0, | |
| 13 | used: usize = 0, | |
| 14 | write_pos: usize = 0, | |
| 15 | last: ?u64 = null, | |
| 16 | evicted: u64 = 0, | |
| 17 | skipped: u64 = 0, | |
| 18 | pub fn init(allocator: std.mem.Allocator, byte_capacity: usize, frame_capacity: usize, duration: u64) !Ring { | |
| 19 | if (byte_capacity == 0 or frame_capacity == 0 or frame_capacity > std.math.maxInt(usize) / 2 or duration == 0) return error.InvalidConfiguration; | |
| 20 | const bytes = try allocator.alloc(u8, byte_capacity); | |
| 21 | errdefer allocator.free(bytes); | |
| 22 | const entries = try allocator.alloc(Entry, frame_capacity); | |
| 23 | return .{ .allocator = allocator, .bytes = bytes, .entries = entries, .duration = duration }; | |
| 24 | } | |
| 25 | pub fn deinit(s: *Ring) void { | |
| 26 | s.allocator.free(s.bytes); | |
| 27 | s.allocator.free(s.entries); | |
| 28 | } | |
| 29 | fn drop(s: *Ring) void { | |
| 30 | s.used -= s.entries[s.head].length; | |
| 31 | s.head = (s.head + 1) % s.entries.len; | |
| 32 | s.count -= 1; | |
| 33 | s.evicted += 1; | |
| 34 | } | |
| 35 | pub fn entry(s: *const Ring, index: usize) ?Entry { | |
| 36 | if (index >= s.count) return null; | |
| 37 | return s.entries[(s.head + index) % s.entries.len]; | |
| 38 | } | |
| 39 | /// False means waiting for a key after eviction. Limits are upper bounds; | |
| 40 | /// history can be empty when a GOP is larger than the retention limits. | |
| 41 | /// Oversized/invalid input is rejected before modifying retained history. | |
| 42 | pub fn push(s: *Ring, packet: []const u8, timestamp: u64, key: bool) !bool { | |
| 43 | if (packet.len == 0 or packet.len > s.bytes.len) return error.PacketTooLarge; | |
| 44 | if (s.last) |last| if (timestamp <= last) return error.InvalidTimestamp; | |
| 45 | while (s.count > 0 and (s.count == s.entries.len or packet.len > s.bytes.len - s.used or timestamp - s.entry(0).?.timestamp > s.duration)) s.drop(); | |
| 46 | while (s.count > 0 and !s.entry(0).?.key) s.drop(); | |
| 47 | s.last = timestamp; | |
| 48 | if (s.count == 0 and !key) { | |
| 49 | s.skipped += 1; | |
| 50 | return false; | |
| 51 | } | |
| 52 | const first = @min(packet.len, s.bytes.len - s.write_pos); | |
| 53 | @memcpy(s.bytes[s.write_pos..][0..first], packet[0..first]); | |
| 54 | @memcpy(s.bytes[0 .. packet.len - first], packet[first..]); | |
| 55 | s.entries[(s.head + s.count) % s.entries.len] = .{ .offset = s.write_pos, .length = packet.len, .timestamp = timestamp, .key = key }; | |
| 56 | s.write_pos = if (packet.len - first != 0) packet.len - first else if (s.write_pos + first == s.bytes.len) 0 else s.write_pos + first; | |
| 57 | s.count += 1; | |
| 58 | s.used += packet.len; | |
| 59 | return true; | |
| 60 | } | |
| 61 | /// Explicit owned snapshot copy; data and metadata views otherwise expire | |
| 62 | /// on the next mutation. Returns the active destination slice. | |
| 63 | pub fn copy(s: *const Ring, index: usize, out: []u8) ![]const u8 { | |
| 64 | const e = s.entry(index) orelse return error.InvalidIndex; | |
| 65 | if (out.len < e.length) return error.BufferTooSmall; | |
| 66 | const first = @min(e.length, s.bytes.len - e.offset); | |
| 67 | @memcpy(out[0..first], s.bytes[e.offset..][0..first]); | |
| 68 | @memcpy(out[first..e.length], s.bytes[0 .. e.length - first]); | |
| 69 | return out[0..e.length]; | |
| 70 | } | |
| 71 | }; | |
| 72 | test "byte wrap and GOP eviction keep only independently decodable history" { | |
| 73 | var ring = try Ring.init(std.testing.allocator, 10, 4, 100); | |
| 74 | defer ring.deinit(); | |
| 75 | try std.testing.expect(try ring.push("aaaa", 0, true)); | |
| 76 | try std.testing.expect(try ring.push("bb", 1, false)); | |
| 77 | try std.testing.expect(try ring.push("ccc", 2, true)); | |
| 78 | try std.testing.expect(try ring.push("dddd", 3, false)); | |
| 79 | try std.testing.expectEqual(@as(usize, 2), ring.count); | |
| 80 | try std.testing.expectEqual(@as(usize, 7), ring.used); | |
| 81 | var out: [10]u8 = undefined; | |
| 82 | try std.testing.expectEqualStrings("ccc", try ring.copy(0, &out)); | |
| 83 | try std.testing.expectEqualStrings("dddd", try ring.copy(1, &out)); | |
| 84 | try std.testing.expect(!try ring.push("12345678", 4, false)); | |
| 85 | try std.testing.expectEqual(@as(usize, 0), ring.count); | |
| 86 | try std.testing.expect(try ring.push("key", 5, true)); | |
| 87 | try std.testing.expectEqualStrings("key", try ring.copy(0, &out)); | |
| 88 | } | |
| 89 | test "frame and time limits; rejected inputs preserve existing payload" { | |
| 90 | var ring = try Ring.init(std.testing.allocator, 10, 2, 3); | |
| 91 | defer ring.deinit(); | |
| 92 | try std.testing.expect(!try ring.push("p", 0, false)); | |
| 93 | try std.testing.expect(try ring.push("a", 1, true)); | |
| 94 | try std.testing.expect(try ring.push("b", 2, false)); | |
| 95 | try std.testing.expect(!try ring.push("c", 3, false)); | |
| 96 | try std.testing.expect(try ring.push("d", 4, true)); | |
| 97 | try std.testing.expectError(error.PacketTooLarge, ring.push("01234567890", 5, true)); | |
| 98 | try std.testing.expectError(error.InvalidTimestamp, ring.push("x", 4, true)); | |
| 99 | var out: [1]u8 = undefined; | |
| 100 | try std.testing.expectEqualStrings("d", try ring.copy(0, &out)); | |
| 101 | try std.testing.expect(!try ring.push("e", 8, false)); | |
| 102 | try std.testing.expectEqual(@as(usize, 0), ring.count); | |
| 103 | try std.testing.expectError(error.InvalidIndex, ring.copy(0, &out)); | |
| 104 | } | |
| 105 | test "initialization rejects zero limits and frees partial allocations" { | |
| 106 | try std.testing.expectError(error.InvalidConfiguration, Ring.init(std.testing.allocator, 0, 1, 1)); | |
| 107 | const Check = struct { | |
| 108 | fn run(a: std.mem.Allocator) !void { | |
| 109 | var r = try Ring.init(a, 10, 2, 3); | |
| 110 | defer r.deinit(); | |
| 111 | } | |
| 112 | }; | |
| 113 | try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); | |
| 114 | } | |
| @@ -842,6 +842,7 @@ pub const applyAudioGain = sample_convert.applyGain; | ||
| 842 | 842 | pub const normalizeAudio = sample_convert.normalize; |
| 843 | 843 | pub const AudioChannelLayout = sample_convert.ChannelLayout; |
| 844 | 844 | |
| 845 | pub const packet_ring = ; | |
| 845 | 846 | pub const pixel_convert = ; |
| 846 | 847 | pub const PixelConvertFormat = pixel_convert.PixelFormat; |
| 847 | 848 | pub const ConvertColorSpace = pixel_convert.ColorSpace; |
| @@ -1218,6 +1219,7 @@ test "Video library imports" { | ||
| 1218 | 1219 | _ = dash; |
| 1219 | 1220 | _ = subtitle_convert; |
| 1220 | 1221 | _ = sample_convert; |
| 1222 | _ = packet_ring; | |
| 1221 | 1223 | _ = pixel_convert; |
| 1222 | 1224 | _ = nal_convert; |
| 1223 | 1225 | _ = remux; |