ReviewOS

also looking at this

home-lang/home

feat(video): add native AVC/HEVC and synchronized AAC MP4 streaming

#913
Merged chris wants to merge feat/avc-hevc-mp4 into main
14 files +1119 -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.

packages/video/NATIVE_MEDIA.mdmodified+30-0
Changes to packages/video/NATIVE_MEDIA.md
@@ -57,3 +57,33 @@ tests, not Pi hardware FPS, long-run reliability or compression-quality benchmar
5757References: [x265 API](https://x265.readthedocs.io/en/master/api.html),
5858[x264 public header](https://code.videolan.org/videolan/x264/-/blob/master/x264.h),
5959[ISO BMFF streaming requirements](https://www.w3.org/TR/mse-byte-stream-format-isobmff/).
60
61## AAC-LC with synchronized fragmented MP4
62
63`mp4_stream.Writer.initAv` extends the same bounded video muxer with an optional
64AAC track. Its `AudioConfig` consumes the encoder's real AudioSpecificConfig,
65rate/channels/bitrate and codec delay. Use the native encoder in
66[Home #918](https://github.com/home-lang/home/pull/918); the muxer itself has no
67codec dependency and remains independently testable.
68
69Audio raw access units use the sample-rate timebase and `mp4a`/`esds`; video
70retains microsecond capture timestamps. Pass a sink with both write and patch
71callbacks. One AAC packet is staged so the final duration can omit padding.
72After draining AAC, `finish(start_us, input_frames)` emits the final packet and
73patches only fixed header fields: 64-bit movie/track/media durations and edit
74lengths. Memory does not grow with recording duration. A seekable sink and clean
75finalization are required; pipe output and crash recovery are not provided.
76
77The audio edit list represents the initial offset and actual encoder delay.
78Global `sgpd` and per-fragment `sbgp` roll associations mark explicit decoder
79pre-roll. Without those associations Apple readers can apply an extra implicit
80priming trim. The final short fragment duration prevents padded tail playback.
81Both are checked by FAAD2/AVFoundation camera integration, including sub-frame
82input, 44.1/48kHz, stereo, video gaps and independently decodable split files.
83Do not infer AAC time from callback scheduling or assume a fixed codec delay.
84The caller must provide continuous PCM to the encoder and preserve sample counts.
85Microsecond movie/edit times can round by less than one PCM sample.
86
87Four focused muxer tests pass on Zig 0.15.2 and the Home development toolchain,
88including sink failure poisoning and fixed edit/duration patch values. No
89FFmpeg/Libav or complete Home media-graph validation is represented here.
packages/video/src/containers/mp4_stream.zigmodified+270-19
Changes to packages/video/src/containers/mp4_stream.zig
@@ -1,9 +1,16 @@
1//! Home single-track streaming fragmented MP4, adapted from mp4_muxer.zig.
1//! Home streaming fragmented MP4 with optional AAC-LC audio, adapted from mp4_muxer.zig.
22//! Complete sample description + mvex; exact sizes/offsets; no file-sized buffer.
33//! AVC/HEVC are length-prefixed, with out-of-band parameter sets (avc1/hvc1).
44const std = ;
55pub const Codec = enum { h264, hevc };
6pub const Sink = struct { context: *anyopaque, write: *const fn (*anyopaque, []const u8) anyerror!void };
6pub const Sink = struct {
7 context: *anyopaque,
8 write: *const fn (*anyopaque, []const u8) anyerror!void,
9 patch: ?*const fn (*anyopaque, u64, []const u8) anyerror!void = null,
10};
11pub const AudioConfig = struct { sample_rate: u32, channels: u32, bitrate: u32, delay: u32, config: [2]u8 };
12const AudioOffsets = struct { track: usize = 0, media: usize = 0, empty: usize = 0, edit: usize = 0 };
13
714const Buffer = struct {
815 data: [16384]u8 = undefined,
916 len: usize = 0,
@@ -119,13 +126,144 @@ fn config(b: *Buffer, codec: Codec, headers: []const u8) !void {
119126 }
120127 b.end(at);
121128}
129fn audioTrack(b: *Buffer, cfg: AudioConfig) !AudioOffsets {
130 const rates = [_]u32{ 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000 };
131 var index: ?u16 = null;
132 for (rates, 0..) |rate, i| if (rate == cfg.sample_rate) {
133 index = @intCast(i);
134 };
135 if (index == null or cfg.sample_rate > 48000 or cfg.channels < 1 or cfg.channels > 2 or cfg.bitrate < 16000 or cfg.bitrate > 320000 or cfg.delay > 16384) return error.InvalidConfiguration;
136 const expected = (@as(u16, 2) << 11) | (index.? << 7) | (@as(u16, @intCast(cfg.channels)) << 3);
137 if (std.mem.readInt(u16, &cfg.config, .big) != expected) return error.InvalidConfiguration;
138 var offsets: AudioOffsets = .{};
139 const trak = try b.box("trak");
140 const tkhd = try b.full("tkhd", 0x1000007);
141 try b.zero(16);
142 try b.int32(2);
143 try b.zero(4);
144 offsets.track = b.len;
145 try b.int64(0);
146 try b.zero(8);
147 try b.zero(4);
148 try b.int16(0x100);
149 try b.int16(0);
150 try b.matrix();
151 try b.zero(8);
152 b.end(tkhd);
153 const edts = try b.box("edts");
154 const elst = try b.full("elst", 0x1000000);
155 try b.int32(2);
156 offsets.empty = b.len;
157 try b.int64(0);
158 try b.int64(std.math.maxInt(u64));
159 try b.int16(1);
160 try b.int16(0);
161 offsets.edit = b.len;
162 try b.int64(0);
163 try b.int64(cfg.delay);
164 try b.int16(1);
165 try b.int16(0);
166 b.end(elst);
167 b.end(edts);
168 const mdia = try b.box("mdia");
169 const mdhd = try b.full("mdhd", 0x1000000);
170 try b.zero(16);
171 try b.int32(cfg.sample_rate);
172 offsets.media = b.len;
173 try b.int64(0);
174 try b.int16(0x55c4);
175 try b.int16(0);
176 b.end(mdhd);
177 const hdlr = try b.full("hdlr", 0);
178 try b.int32(0);
179 try b.bytes("soun");
180 try b.zero(12);
181 try b.bytes("Home AAC\x00");
182 b.end(hdlr);
183 const minf = try b.box("minf");
184 const smhd = try b.full("smhd", 0);
185 try b.zero(4);
186 b.end(smhd);
187 const dinf = try b.box("dinf");
188 const dref = try b.full("dref", 0);
189 try b.int32(1);
190 const url = try b.full("url ", 1);
191 b.end(url);
192 b.end(dref);
193 b.end(dinf);
194 const stbl = try b.box("stbl");
195 const stsd = try b.full("stsd", 0);
196 try b.int32(1);
197 const entry = try b.box("mp4a");
198 try b.zero(6);
199 try b.int16(1);
200 try b.zero(8);
201 try b.int16(@intCast(cfg.channels));
202 try b.int16(16);
203 try b.zero(4);
204 try b.int32(cfg.sample_rate << 16);
205 const esds = try b.full("esds", 0);
206 try b.bytes(&.{ 3, 25 });
207 try b.int16(2);
208 try b.bytes(&.{ 0, 4, 17, 0x40, 0x15, 0, 0x20, 0 });
209 try b.int32(cfg.bitrate);
210 try b.int32(cfg.bitrate);
211 try b.bytes(&.{ 5, 2 });
212 try b.bytes(&cfg.config);
213 try b.bytes(&.{ 6, 1, 2 });
214 b.end(esds);
215 b.end(entry);
216 b.end(stsd);
217 for ([_]*const [4]u8{ "stts", "stsc", "stco" }) |name| {
218 const at = try b.full(name, 0);
219 try b.int32(0);
220 b.end(at);
221 }
222 const stsz = try b.full("stsz", 0);
223 try b.zero(8);
224 b.end(stsz);
225 // Explicit decoder pre-roll makes the edit list authoritative. Without
226 // roll sample groups Apple assumes an additional legacy 2112-sample delay.
227 const sgpd = try b.full("sgpd", 0x1000000);
228 try b.bytes("roll");
229 try b.int32(2);
230 try b.int32(1);
231 try b.int16(65535);
232 b.end(sgpd);
233 const sbgp = try b.full("sbgp", 0);
234 try b.bytes("roll");
235 try b.int32(0);
236 b.end(sbgp);
237 b.end(stbl);
238 b.end(minf);
239 b.end(mdia);
240 b.end(trak);
241 return offsets;
242}
122243pub const Writer = struct {
123244 sink: Sink,
124245 sequence: u32 = 1,
125246 last_dts: u64 = 0,
126247 frames: u64 = 0,
127248 failed: bool = false,
249 finished: bool = false,
250 audio_config: ?AudioConfig = null,
251 audio_packets: u64 = 0,
252 audio_pending: [8192]u8 = undefined,
253 audio_pending_len: usize = 0,
254 video_end_us: u64 = 0,
255 movie_duration: usize = 0,
256 video_duration: usize = 0,
257 video_media_duration: usize = 0,
258 audio_offsets: AudioOffsets = .{},
259 pub fn initAv(sink: Sink, width: u32, height: u32, codec: Codec, headers: []const u8, audio_cfg: AudioConfig) !Writer {
260 if (sink.patch == null) return error.InvalidConfiguration;
261 return initInternal(sink, width, height, codec, headers, audio_cfg);
262 }
128263 pub fn init(sink: Sink, width: u32, height: u32, codec: Codec, headers: []const u8) !Writer {
264 return initInternal(sink, width, height, codec, headers, null);
265 }
266 fn initInternal(sink: Sink, width: u32, height: u32, codec: Codec, headers: []const u8, audio_cfg: ?AudioConfig) !Writer {
129267 if (width == 0 or height == 0 or width > 8192 or height > 8192) return error.InvalidConfiguration;
130268 var b: Buffer = .{};
131269 const ftyp = try b.box("ftyp");
@@ -134,32 +272,37 @@ pub const Writer = struct {
134272 try b.bytes("iso6isommp41");
135273 b.end(ftyp);
136274 const moov = try b.box("moov");
137 const mvhd = try b.full("mvhd", 0);
138 try b.zero(8);
275 const mvhd = try b.full("mvhd", 0x1000000);
276 try b.zero(16);
139277 try b.int32(1000000);
140 try b.int32(0);
278 const movie_duration = b.len;
279 try b.int64(0);
141280 try b.int32(0x10000);
142281 try b.int16(0x100);
143282 try b.zero(10);
144283 try b.matrix();
145284 try b.zero(24);
146 try b.int32(2);
285 try b.int32(if (audio_cfg != null) 3 else 2);
147286 b.end(mvhd);
148287 const trak = try b.box("trak");
149 const tkhd = try b.full("tkhd", 7);
150 try b.zero(8);
151 try b.int32(1);
288 const tkhd = try b.full("tkhd", 0x1000007);
152289 try b.zero(16);
290 try b.int32(1);
291 try b.zero(4);
292 const video_duration = b.len;
293 try b.int64(0);
294 try b.zero(8);
153295 try b.zero(8);
154296 try b.matrix();
155297 try b.int32(width << 16);
156298 try b.int32(height << 16);
157299 b.end(tkhd);
158300 const mdia = try b.box("mdia");
159 const mdhd = try b.full("mdhd", 0);
160 try b.zero(8);
301 const mdhd = try b.full("mdhd", 0x1000000);
302 try b.zero(16);
161303 try b.int32(1000000);
162 try b.int32(0);
304 const video_media_duration = b.len;
305 try b.int64(0);
163306 try b.int16(0x55c4);
164307 try b.int16(0);
165308 b.end(mdhd);
@@ -218,24 +361,88 @@ pub const Writer = struct {
218361 b.end(minf);
219362 b.end(mdia);
220363 b.end(trak);
364 const offsets = if (audio_cfg) |cfg| try audioTrack(&b, cfg) else AudioOffsets{};
221365 const mvex = try b.box("mvex");
222366 const trex = try b.full("trex", 0);
223367 try b.int32(1);
224368 try b.int32(1);
225369 try b.zero(12);
226370 b.end(trex);
371 if (audio_cfg != null) {
372 const audio_trex = try b.full("trex", 0);
373 try b.int32(2);
374 try b.int32(1);
375 try b.int32(1024);
376 try b.int32(0);
377 try b.int32(0x02000000);
378 b.end(audio_trex);
379 }
227380 b.end(mvex);
228381 b.end(moov);
229382 try sink.write(sink.context, b.slice());
230 return .{ .sink = sink };
383 return .{ .sink = sink, .audio_config = audio_cfg, .audio_offsets = offsets, .movie_duration = movie_duration, .video_duration = video_duration, .video_media_duration = video_media_duration };
231384 }
232385 /// One complete sample. Caller supplies its actual duration, using the next
233386 /// capture time; the last sample uses nominal frame duration. PTS == DTS.
234387 pub fn frame(s: *Writer, sample: []const u8, dts_us: u64, duration_us: u32, key: bool) !void {
235 if (s.failed) return error.WriterFailed;
388 if (s.failed or s.finished) return error.WriterFailed;
236389 if (sample.len == 0 or sample.len > std.math.maxInt(u32) - 8 or duration_us == 0 or (s.frames != 0 and dts_us <= s.last_dts) or s.sequence == std.math.maxInt(u32)) return error.InvalidSample;
237390 var nals: Nals = .{ .data = sample };
238391 while (try nals.next()) |_| {}
392 const end = try std.math.add(u64, dts_us, duration_us);
393 try s.fragment(1, sample, dts_us, duration_us, key);
394 s.frames += 1;
395 s.last_dts = dts_us;
396 s.video_end_us = end;
397 }
398 pub fn audio(s: *Writer, sample: []const u8) !void {
399 if (s.audio_config == null or s.failed or s.finished) return error.WriterFailed;
400 if (sample.len == 0 or sample.len > 8192 or s.sequence == std.math.maxInt(u32)) return error.InvalidSample;
401 if (s.audio_pending_len != 0) try s.flushAudio(1024);
402 @memcpy(s.audio_pending[0..sample.len], sample);
403 s.audio_pending_len = sample.len;
404 }
405 fn flushAudio(s: *Writer, duration: u32) !void {
406 if (s.audio_pending_len == 0) return;
407 const time = try std.math.mul(u64, s.audio_packets, 1024);
408 try s.fragment(2, s.audio_pending[0..s.audio_pending_len], time, duration, true);
409 s.audio_pending_len = 0;
410 s.audio_packets += 1;
411 }
412 /// Patch fixed metadata only after draining AAC. Decoder delay and tail
413 /// padding stay in media samples; edit lists expose exactly the input PCM.
414 pub fn finish(s: *Writer, audio_start_us: u64, presented_frames: u64) !void {
415 if (s.failed or s.finished) return error.WriterFailed;
416 const cfg = s.audio_config orelse return error.InvalidConfiguration;
417 const encoded = try std.math.mul(u64, s.audio_packets + @intFromBool(s.audio_pending_len != 0), 1024);
418 const valid_media = try std.math.add(u64, presented_frames, cfg.delay);
419 if (encoded < valid_media or encoded - valid_media >= 1024) return error.InvalidSample;
420 const padding = encoded - valid_media;
421 const scaled = @as(u128, presented_frames) * 1000000 / cfg.sample_rate;
422 if (scaled > std.math.maxInt(u64)) return error.InvalidSample;
423 const duration: u64 = @intCast(scaled);
424 const audio_end = try std.math.add(u64, audio_start_us, duration);
425 errdefer s.failed = true;
426 // A short final duration also signals tail trim to fragmented readers
427 // that do not apply the finite edit duration to the last fragment.
428 try s.flushAudio(@intCast(1024 - padding));
429 try s.patch64(s.movie_duration, @max(s.video_end_us, audio_end));
430 try s.patch64(s.video_duration, s.video_end_us);
431 try s.patch64(s.video_media_duration, s.video_end_us);
432 try s.patch64(s.audio_offsets.track, audio_end);
433 try s.patch64(s.audio_offsets.media, valid_media);
434 try s.patch64(s.audio_offsets.empty, audio_start_us);
435 try s.patch64(s.audio_offsets.edit, duration);
436 s.finished = true;
437 }
438 fn patch64(s: *Writer, offset: usize, value: u64) !void {
439 var bytes: [8]u8 = undefined;
440 std.mem.writeInt(u64, &bytes, value, .big);
441 try s.sink.patch.?(s.sink.context, offset, &bytes);
442 }
443 fn fragment(s: *Writer, track: u32, sample: []const u8, time: u64, duration: u32, key: bool) !void {
444 if (s.failed or s.finished) return error.WriterFailed;
445 if (s.sequence == std.math.maxInt(u32)) return error.InvalidSample;
239446 var b: Buffer = .{};
240447 const moof = try b.box("moof");
241448 const mfhd = try b.full("mfhd", 0);
@@ -243,19 +450,27 @@ pub const Writer = struct {
243450 b.end(mfhd);
244451 const traf = try b.box("traf");
245452 const tfhd = try b.full("tfhd", 0x20000);
246 try b.int32(1);
453 try b.int32(track);
247454 b.end(tfhd);
248455 const tfdt = try b.full("tfdt", 0x1000000);
249 try b.int64(dts_us);
456 try b.int64(time);
250457 b.end(tfdt);
251458 const trun = try b.full("trun", 0x701);
252459 try b.int32(1);
253460 const offset = b.len;
254461 try b.int32(0);
255 try b.int32(duration_us);
462 try b.int32(duration);
256463 try b.int32(@intCast(sample.len));
257464 try b.int32(if (key) 0x02000000 else 0x01010000);
258465 b.end(trun);
466 if (track == 2) {
467 const sbgp = try b.full("sbgp", 0);
468 try b.bytes("roll");
469 try b.int32(1);
470 try b.int32(1);
471 try b.int32(1);
472 b.end(sbgp);
473 }
259474 b.end(traf);
260475 b.end(moof);
261476 std.mem.writeInt(u32, b.data[offset..][0..4], @intCast(b.len + 8), .big);
@@ -265,8 +480,6 @@ pub const Writer = struct {
265480 try s.sink.write(s.sink.context, b.slice());
266481 try s.sink.write(s.sink.context, sample);
267482 s.sequence += 1;
268 s.frames += 1;
269 s.last_dts = dts_us;
270483 }
271484};
272485test "NAL lengths reject truncation and empty units" {
@@ -318,3 +531,41 @@ test "Matroska CodecPrivate reuses checked config and respects destination bound
318531 try std.testing.expectError(error.HeaderTooLarge, codecPrivate(.h264, &headers, destination[0..1]));
319532 try std.testing.expectError(error.MissingParameterSets, codecPrivate(.h264, &.{}, &destination));
320533}
534test "AAC track sample clock, edit delay/tail, fixed patches and sink failure" {
535 const Out = struct {
536 bytes: std.ArrayList(u8) = .empty,
537 fail: bool = false,
538 fn write(ctx: *anyopaque, data: []const u8) anyerror!void {
539 const s: *@This() = @ptrCast(@alignCast(ctx));
540 try s.bytes.appendSlice(std.testing.allocator, data);
541 }
542 fn patch(ctx: *anyopaque, offset: u64, data: []const u8) anyerror!void {
543 const s: *@This() = @ptrCast(@alignCast(ctx));
544 if (s.fail) return error.DiskFull;
545 if (offset + data.len > s.bytes.items.len) return error.InvalidOffset;
546 @memcpy(s.bytes.items[@intCast(offset)..][0..data.len], data);
547 }
548 };
549 var out: Out = .{};
550 defer out.bytes.deinit(std.testing.allocator);
551 const headers = [_]u8{ 0, 0, 0, 4, 0x67, 100, 0, 40, 0, 0, 0, 2, 0x68, 0x80 };
552 const cfg: AudioConfig = .{ .sample_rate = 48000, .channels = 2, .bitrate = 128000, .delay = 2048, .config = .{ 0x11, 0x90 } };
553 const sink: Sink = .{ .context = &out, .write = Out.write, .patch = Out.patch };
554 var writer = try Writer.initAv(sink, 128, 96, .h264, &headers, cfg);
555 try std.testing.expect(std.mem.indexOf(u8, out.bytes.items, "mp4a") != null);
556 try writer.frame(&.{ 0, 0, 0, 2, 0x65, 0x80 }, 0, 41667, true);
557 try std.testing.expectError(error.InvalidSample, writer.finish(12345, 1000));
558 for (0..3) |_| try writer.audio(&.{ 0x21, 0x10 });
559 try writer.finish(12345, 1000);
560 try std.testing.expectEqual(@as(u64, 12345), std.mem.readInt(u64, out.bytes.items[writer.audio_offsets.empty..][0..8], .big));
561 try std.testing.expectEqual(@as(u64, 20833), std.mem.readInt(u64, out.bytes.items[writer.audio_offsets.edit..][0..8], .big));
562 try std.testing.expectEqual(@as(u64, 2048), std.mem.readInt(u64, out.bytes.items[writer.audio_offsets.edit + 8 ..][0..8], .big));
563 try std.testing.expectEqual(@as(u64, 3048), std.mem.readInt(u64, out.bytes.items[writer.audio_offsets.media..][0..8], .big));
564 try std.testing.expectError(error.WriterFailed, writer.audio(&.{1}));
565 var failure = try Writer.initAv(sink, 128, 96, .h264, &headers, cfg);
566 try failure.audio(&.{ 0x21, 0x10 });
567 try failure.audio(&.{ 0x21, 0x10 });
568 out.fail = true;
569 try std.testing.expectError(error.DiskFull, failure.finish(0, 0));
570 try std.testing.expectError(error.WriterFailed, failure.audio(&.{1}));
571}