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
.github/workflows/native-media.ymladded+34-0
Changes to .github/workflows/native-media.yml
@@ -0,0 +1,34 @@
1name: Native AVC HEVC and MP4
2on:
3 push:
4 paths:
5 - 'packages/video/src/codecs/video/native_encoder.*'
6 - 'packages/video/src/codecs/video/rgb_i420.zig'
7 - 'packages/video/src/containers/mp4_stream.zig'
8 - 'packages/video/src/native_media_tests.zig'
9 - 'packages/video/test-native-media.sh'
10 - '.github/workflows/native-media.yml'
11 pull_request:
12 paths:
13 - 'packages/video/src/codecs/video/native_encoder.*'
14 - 'packages/video/src/codecs/video/rgb_i420.zig'
15 - 'packages/video/src/containers/mp4_stream.zig'
16 - 'packages/video/src/native_media_tests.zig'
17 - 'packages/video/test-native-media.sh'
18 - '.github/workflows/native-media.yml'
19 workflow_dispatch:
20permissions:
21 contents: read
22jobs:
23 native-media:
24 strategy:
25 matrix:
26 os: [ubuntu-24.04, ubuntu-24.04-arm]
27 runs-on: ${{ matrix.os }}
28 steps:
29 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
30 - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29
31 with:
32 version: 0.15.2
33 - run: sudo apt-get update && sudo apt-get install -y libx264-dev libx265-dev pkg-config
34 - run: packages/video/test-native-media.sh
packages/video/NATIVE_MEDIA.mdadded+89-0
Changes to packages/video/NATIVE_MEDIA.md
@@ -0,0 +1,89 @@
1# Native AVC/HEVC and streamed MP4
2
3`src/codecs/video/native_encoder.zig` is the supported standalone Home integration
4for libx264/libx265. Compile `native_encoder.c` against the installed vendor
5headers, then link x264/x265 directly. Only Home-owned fixed scalar structs cross
6the C/Zig boundary; the old handwritten vendor structures in x264.zig/x265.zig
7are not reused. There is no FFmpeg/Libav or encoder subprocess dependency.
8The Zig wrapper works with Zig 0.15.2 and the Home 0.17 development toolchain.
9Vendor libraries keep their GPL/commercial licenses; Home modules remain MIT.
10
11The wrapper accepts checked I420 planes, returns borrowed length-prefixed NAL
12packets, and preserves microsecond PTS. Serialize operations per encoder; packets
13expire on the next call. Encode may return null while AVC VFR buffers one input.
14Always drain `flush()` until null before deinit. Encoding after drain or error is
15rejected. The integration fixes presets to veryfast, one frame thread, no B frames
16or lookahead, closed GOPs, AVC High / HEVC Main, 8-bit 4:2:0. Quality 1..100 maps
17linearly to CRF 51..0 (AVC minimum 1 to retain High-profile compatibility); bitrate >0 selects ABR in bits/sec, not strict CBR. Zero GOP
18means 60. HEVC uses CTU16 across instances to avoid global x265 CTU mismatches.
19
20`rgb_i420.zig` is a bounded, stride-checked conversion from RGB24. It improves
21Home's existing RGB conversion approach with actual 2x2 chroma averaging. Output
22is limited-range BT.601 with sRGB transfer / BT.709 primaries. This is SDR.
23
24`src/containers/mp4_stream.zig` provides a sink-based single-video-track fragmented
25MP4 writer, adapted from Home's existing MP4 box code. It writes complete AVC/HEVC
26sample descriptions, avcC/hvcC from encoder headers, mvex/trex, accurate moof/traf/
27tfhd/trun offsets, 64-bit tfdt, and one mdat per sample. Payload writes go straight
28to the sink; there is no whole-recording buffer or growing sample table.
29
30The writer accepts length-prefixed samples with PTS=DTS and explicit microsecond
31durations. A recorder should retain one encoded packet to compute duration from
32the next timestamp, preserving capture gaps; use nominal duration for the last.
33The writer rejects malformed NAL lengths and non-increasing decode times. Partial
34sink failure poisons the writer. It has a fixed 16KiB header bound, no seek index,
35no audio, and supports the fixed 8-bit profiles emitted by this adapter.
36
37Home's older pure-Zig H.264/HEVC slice encoders and fragmented-muxer implementation
38remain legacy code; their feature lists are not conformance evidence for these
39new modules. The new modules are available through video.native_encoder,
40video.rgb_i420 and video.mp4_stream without pulling in the old codec graph.
41
42From the repository root on Linux:
43
44```sh
45sudo apt install libx264-dev libx265-dev pkg-config
46packages/video/test-native-media.sh
47```
48
49Focused tests validate geometry, original timestamps, all frames through drain,
50invalid lifecycle, averaged chroma, fragment sizes/data offsets/64-bit time and
51sink failure. They pass in Debug/ReleaseSafe with Zig 0.15.2 and Home's installed
520.17 development toolchain. zig-utils/zig-camera integration additionally checks
53MP4 regular/gapped capture timelines and GOPs, and decodes AVC/HEVC using independent
54Apple AVFoundation and Linux OpenH264/libde265. These are synthetic correctness
55tests, not Pi hardware FPS, long-run reliability or compression-quality benchmarks.
56
57References: [x265 API](https://x265.readthedocs.io/en/master/api.html),
58[x264 public header](https://code.videolan.org/videolan/x264/-/blob/master/x264.h),
59[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/docs/native-video.mdadded+25-0
Changes to packages/video/docs/native-video.md
@@ -0,0 +1,25 @@
1# Header-checked native video adapter
2
3`codecs/video/native_encoder.{c,h,zig}` compiles the x264/x265 boundary against
4installed vendor headers. Only Home-owned scalar configuration and borrowed
5length-prefixed packet data cross the interface. The Zig wrapper validates input
6geometry, timestamps and encode/drain state. Link the C source and libx264/libx265.
7
8`Encoder.requestKeyframe()` / `home_encoder_request_keyframe` requests an IDR for
9the next submitted input. Requests coalesce. This supports independently decodable
10recording splits without waiting for the regular GOP interval. Inspect emitted
11packet timestamps and key flags before switching outputs: buffered AVC output
12can precede the newly requested IDR. Keep headers outside packet history and
13remux each segment with a fresh sample description and a rebased timeline.
14
15Use requests only on an active encoder before draining, serialize every operation,
16and consume borrowed output before another adapter call. The Zig wrapper rejects
17requests after drain/failure. A request neither drains nor resets the encoder.
18
19The standalone `tests/native_encoder_idr.zig` imports this module as `encoder` and
20links its compiled C boundary. Its regression encodes eight frames under GOP100,
21forces IDRs at inputs 3 and 6, checks actual AVC/HEVC IDR NAL types and packet keys,
22counts every delayed/flushed frame, and rejects requests after drain. It passes
23on Zig 0.15.2 and Home's installed 0.17 development toolchain with x264/x265.
24zig-camera additionally remuxes split files and pre-roll snapshots and validates
25them with independent decoders. No FFmpeg or Libav is used in this path.
packages/video/src/codecs/video/native_encoder.cadded+157-0
Changes to packages/video/src/codecs/video/native_encoder.c
@@ -0,0 +1,157 @@
1/* Home's header-checked x264/x265 adapter. No hand-declared vendor structures. */
2#include "native_encoder.h"
3#include <stdlib.h>
4#include <string.h>
5#include <limits.h>
6#include <x264.h>
7#include <x265.h>
8struct home_encoder {
9 uint32_t codec;
10 x264_t *avc;
11 x265_encoder *hevc;
12 x265_param *param;
13 x265_picture *in, *out;
14 uint8_t *buffer;
15 size_t capacity;
16 uint32_t width, height;
17 int force_key;
18};
19void home_encoder_close(home_encoder *e) {
20 if (!e) return;
21 if (e->avc) x264_encoder_close(e->avc);
22 if (e->hevc) x265_encoder_close(e->hevc);
23 if (e->in) x265_picture_free(e->in);
24 if (e->out) x265_picture_free(e->out);
25 if (e->param) x265_param_free(e->param);
26 free(e->buffer); free(e);
27}
28int home_encoder_open(const home_encoder_config *c, home_encoder **out) {
29 if (!out) return -1;
30 *out=NULL;
31 if (!c || (c->codec!=1 && c->codec!=2) || c->width<16 || c->height<16 || c->width>8192 || c->height>8192 || (c->width|c->height)&1 || !c->fps_num || !c->fps_den || c->fps_num>1000000 || c->fps_den>1000000 || c->quality<1 || c->quality>100 || c->gop>INT_MAX || (c->bitrate && c->bitrate<1000)) return -1;
32 home_encoder *e=calloc(1,sizeof(*e));
33 if (!e) return -2;
34 e->codec=c->codec; e->width=c->width; e->height=c->height;
35 /* Quality 1..100 -> CRF 51..0; AVC clamps at 1 to keep High-profile
36 * compatibility (CRF0 requires High444 lossless). bitrate>0 selects ABR. */
37 const double crf=51.0*(100-c->quality)/99.0;
38 if (c->codec==1) {
39 x264_param_t p;
40 if (x264_param_default_preset(&p,"veryfast","zerolatency")<0) goto fail;
41 p.i_width=(int)c->width; p.i_height=(int)c->height; p.i_csp=X264_CSP_I420;
42 p.i_fps_num=c->fps_num; p.i_fps_den=c->fps_den;
43 p.i_timebase_num=1; p.i_timebase_den=1000000; p.b_vfr_input=1;
44 p.i_threads=1; p.i_bframe=0; p.rc.i_lookahead=0;
45 p.i_keyint_max=c->gop ? (int)c->gop : 60; p.i_keyint_min=1; p.i_scenecut_threshold=0;
46 p.b_open_gop=0; p.b_annexb=0; p.b_repeat_headers=0; p.i_log_level=X264_LOG_ERROR;
47 p.vui.b_fullrange=0; p.vui.i_colorprim=1; p.vui.i_transfer=13; p.vui.i_colmatrix=6;
48 p.rc.i_rc_method=c->bitrate ? X264_RC_ABR : X264_RC_CRF;
49 p.rc.i_bitrate=(int)(c->bitrate/1000); p.rc.f_rf_constant=(float)(crf<1.0 ? 1.0 : crf);
50 if (x264_param_apply_profile(&p,"high")<0) { x264_param_cleanup(&p); goto fail; }
51 e->avc=x264_encoder_open(&p);
52 x264_param_cleanup(&p);
53 if (!e->avc) goto fail;
54 } else {
55 e->param=x265_param_alloc(); e->in=x265_picture_alloc(); e->out=x265_picture_alloc();
56 if (!e->param || !e->in || !e->out) goto fail;
57 x265_param *p=e->param;
58 if (x265_param_default_preset(p,"veryfast","zerolatency")<0) goto fail;
59 p->sourceWidth=(int)c->width; p->sourceHeight=(int)c->height;
60 p->fpsNum=c->fps_num; p->fpsDenom=c->fps_den; p->internalCsp=X265_CSP_I420; p->internalBitDepth=8;
61 p->bframes=0; p->lookaheadDepth=0; p->frameNumThreads=1;
62 p->numaPools="none"; p->bEnableWavefront=0;
63 p->keyframeMax=c->gop ? (int)c->gop : 60; p->keyframeMin=1; p->scenecutThreshold=0; p->bOpenGOP=0;
64 p->bAnnexB=0; p->bRepeatHeaders=0; p->bEmitInfoSEI=0; p->logLevel=X265_LOG_ERROR;
65 /* Fixed CTU sizes across instances avoid x265's process-global CTU conflict. */
66 p->maxCUSize=16; p->minCUSize=8;
67 p->vui.bEnableVideoSignalTypePresentFlag=1; p->vui.bEnableVideoFullRangeFlag=0;
68 p->vui.bEnableColorDescriptionPresentFlag=1; p->vui.colorPrimaries=1;
69 p->vui.transferCharacteristics=13; p->vui.matrixCoeffs=6;
70 p->rc.rateControlMode=c->bitrate ? X265_RC_ABR : X265_RC_CRF;
71 p->rc.bitrate=(int)(c->bitrate/1000); p->rc.rfConstant=crf;
72 if (x265_param_apply_profile(p,"main")<0) goto fail;
73 e->hevc=x265_encoder_open(p);
74 if (!e->hevc) goto fail;
75 x265_picture_init(p,e->in); x265_picture_init(p,e->out);
76 e->in->bitDepth=8;
77 }
78 *out=e; return 0;
79fail:
80 home_encoder_close(e); return -3;
81}
82static int collect(home_encoder *e, x264_nal_t *a, x265_nal *h, uint32_t count, home_packet *out) {
83 size_t total=0;
84 for (uint32_t i=0;i<count;i++) {
85 size_t n=a ? (size_t)a[i].i_payload : h[i].sizeBytes;
86 if (n>SIZE_MAX-total) return -2;
87 total+=n;
88 }
89 if (total>e->capacity) {
90 uint8_t *p=realloc(e->buffer,total);
91 if (!p) return -2;
92 e->buffer=p; e->capacity=total;
93 }
94 size_t offset=0;
95 for (uint32_t i=0;i<count;i++) {
96 size_t n=a ? (size_t)a[i].i_payload : h[i].sizeBytes;
97 memcpy(e->buffer+offset,a ? a[i].p_payload : h[i].payload,n); offset+=n;
98 }
99 out->data=e->buffer; out->size=total; return 0;
100}
101int home_encoder_headers(home_encoder *e, home_packet *out) {
102 memset(out,0,sizeof(*out));
103 if (e->avc) {
104 x264_nal_t *nal=NULL; int n=0;
105 if (x264_encoder_headers(e->avc,&nal,&n)<0 || n<=0) return -3;
106 return collect(e,nal,NULL,(uint32_t)n,out);
107 }
108 x265_nal *nal=NULL; uint32_t n=0;
109 if (x265_encoder_headers(e->hevc,&nal,&n)<0 || !n) return -3;
110 return collect(e,NULL,nal,n,out);
111}
112int home_encoder_encode(home_encoder *e, const uint8_t *const planes[3], const int strides[3], int64_t pts, home_packet *out) {
113 memset(out,0,sizeof(*out));
114 if (!planes[0] || !planes[1] || !planes[2] || strides[0]<(int)e->width || strides[1]<(int)e->width/2 || strides[2]<(int)e->width/2) return -1;
115 if (e->avc) {
116 x264_picture_t in, decoded; x264_picture_init(&in); x264_picture_init(&decoded);
117 in.i_type=e->force_key ? X264_TYPE_IDR : X264_TYPE_AUTO;
118 e->force_key=0;
119 in.img.i_csp=X264_CSP_I420; in.img.i_plane=3; in.i_pts=pts;
120 for (int i=0;i<3;i++) { in.img.plane[i]=(uint8_t *)planes[i]; in.img.i_stride[i]=strides[i]; }
121 x264_nal_t *nal=NULL; int n=0;
122 int ret=x264_encoder_encode(e->avc,&nal,&n,&in,&decoded);
123 if(ret<0) return -3;
124 if(ret==0) return 0;
125 out->pts=decoded.i_pts; out->key=decoded.b_keyframe;
126 return collect(e,nal,NULL,(uint32_t)n,out);
127 }
128 for (int i=0;i<3;i++) { e->in->planes[i]=(void *)planes[i]; e->in->stride[i]=strides[i]; }
129 e->in->pts=pts;
130 e->in->sliceType=e->force_key ? X265_TYPE_IDR : X265_TYPE_AUTO;e->force_key=0;
131 x265_nal *nal=NULL; uint32_t n=0;
132 if (x265_encoder_encode(e->hevc,&nal,&n,e->in,e->out)<=0 || !n) return -3;
133 out->pts=e->out->pts; out->key=IS_X265_TYPE_I(e->out->sliceType);
134 return collect(e,NULL,nal,n,out);
135}
136
137int home_encoder_flush(home_encoder *e, home_packet *out) {
138 memset(out,0,sizeof(*out));
139 if(e->avc) {
140 if(!x264_encoder_delayed_frames(e->avc)) return 0;
141 x264_picture_t picture; x264_picture_init(&picture);
142 x264_nal_t *nal=NULL; int n=0;
143 int ret=x264_encoder_encode(e->avc,&nal,&n,NULL,&picture);
144 if(ret<0) return -3;
145 if(ret==0) return 0;
146 out->pts=picture.i_pts;out->key=picture.b_keyframe;
147 return collect(e,nal,NULL,(uint32_t)n,out);
148 }
149 x265_nal *nal=NULL;uint32_t n=0;
150 int ret=x265_encoder_encode(e->hevc,&nal,&n,NULL,e->out);
151 if(ret<0) return -3;
152 if(ret==0) return 0;
153 out->pts=e->out->pts;out->key=IS_X265_TYPE_I(e->out->sliceType);
154 return collect(e,NULL,nal,n,out);
155}
156
157int home_encoder_request_keyframe(home_encoder *e) { if(!e)return -1;e->force_key=1;return 0; }
packages/video/src/codecs/video/native_encoder.hadded+18-0
Changes to packages/video/src/codecs/video/native_encoder.h
@@ -0,0 +1,18 @@
1/* Home codec bridge, MIT. Compile against installed x264.h/x265.h. */
2#ifndef HOME_NATIVE_ENCODER_H
3#define HOME_NATIVE_ENCODER_H
4#include <stddef.h>
5#include <stdint.h>
6typedef struct home_encoder home_encoder;
7typedef struct { uint32_t codec, width, height, fps_num, fps_den, bitrate, quality, gop; } home_encoder_config;
8/* Length-prefixed NAL units; borrowed until the next call. pts uses microseconds.
9 * Low latency: no B frames/lookahead; AVC VFR buffers one input for duration.
10 * Empty output means buffered. Drain flush() before close; serialize calls. */
11typedef struct { const uint8_t *data; size_t size; int64_t pts; int key; } home_packet;
12int home_encoder_open(const home_encoder_config *, home_encoder **);
13int home_encoder_headers(home_encoder *, home_packet *);
14int home_encoder_encode(home_encoder *, const uint8_t *const planes[3], const int strides[3], int64_t pts, home_packet *);
15int home_encoder_request_keyframe(home_encoder *);
16int home_encoder_flush(home_encoder *, home_packet *);
17void home_encoder_close(home_encoder *);
18#endif
packages/video/src/codecs/video/native_encoder.zigadded+80-0
Changes to packages/video/src/codecs/video/native_encoder.zig
@@ -0,0 +1,80 @@
1//! Safe optional Home AVC/HEVC integration. Compile native_encoder.c and link
2//! x264/x265. Only Home's fixed scalar ABI crosses the boundary, never vendor
3//! structures; this module works before/after Zig's removal of @cImport.
4const std = ;
5const Handle = opaque {};
6pub const Config = extern struct { codec: u32, width: u32, height: u32, fps_num: u32, fps_den: u32, bitrate: u32, quality: u32, gop: u32 };
7const RawPacket = extern struct { data: [*]const u8, size: usize, pts: i64, key: c_int };
8extern fn home_encoder_open(*const Config, *?*Handle) c_int;
9extern fn home_encoder_headers(*Handle, *RawPacket) c_int;
10extern fn home_encoder_encode(*Handle, *const [3][*]const u8, *const [3]c_int, i64, *RawPacket) c_int;
11extern fn home_encoder_request_keyframe(*Handle) c_int;
12extern fn home_encoder_flush(*Handle, *RawPacket) c_int;
13extern fn home_encoder_close(*Handle) void;
14pub const Packet = struct { data: []const u8, pts: i64, key: bool };
15pub const Encoder = struct {
16 handle: *Handle,
17 width: u32,
18 height: u32,
19 last_pts: ?i64 = null,
20 draining: bool = false,
21 failed: bool = false,
22 pub fn init(config: Config) !Encoder {
23 var out: ?*Handle = null;
24 try check(home_encoder_open(&config, &out));
25 return .{ .handle = out.?, .width = config.width, .height = config.height };
26 }
27 pub fn deinit(self: *Encoder) void {
28 home_encoder_close(self.handle);
29 }
30 pub fn headers(self: *Encoder) ![]const u8 {
31 var packet: RawPacket = undefined;
32 try check(home_encoder_headers(self.handle, &packet));
33 return packet.data[0..packet.size];
34 }
35 pub fn requestKeyframe(self: *Encoder) !void {
36 if (self.failed or self.draining) return error.EncoderFailed;
37 try check(home_encoder_request_keyframe(self.handle));
38 }
39 pub fn flush(self: *Encoder) !?Packet {
40 if (self.failed) return error.EncoderFailed;
41 self.draining = true;
42 var packet: RawPacket = undefined;
43 errdefer self.failed = true;
44 try check(home_encoder_flush(self.handle, &packet));
45 return view(packet);
46 }
47 /// Borrowed output lasts until the next call; input may be released on return.
48 /// Null means buffered (AVC VFR retains one input); always drain flush().
49 pub fn encode(self: *Encoder, planes: [3][]const u8, strides: [3]u32, width: u32, height: u32, pts: i64) !?Packet {
50 if (self.failed or self.draining) return error.EncoderFailed;
51 if (pts < 0 or (self.last_pts != null and pts <= self.last_pts.?)) return error.InvalidTimestamp;
52 if (width != self.width or height != self.height) return error.InvalidFrame;
53 for (0..3) |i| {
54 const w = if (i == 0) width else width / 2;
55 const h = if (i == 0) height else height / 2;
56 if (strides[i] < w or strides[i] > std.math.maxInt(c_int) or planes[i].len < @as(u64, strides[i]) * (h - 1) + w) return error.InvalidFrame;
57 }
58 const pointers = [3][*]const u8{ planes[0].ptr, planes[1].ptr, planes[2].ptr };
59 const pitch = [3]c_int{ @intCast(strides[0]), @intCast(strides[1]), @intCast(strides[2]) };
60 var packet: RawPacket = undefined;
61 check(home_encoder_encode(self.handle, &pointers, &pitch, pts, &packet)) catch |err| {
62 self.failed = true;
63 return err;
64 };
65 self.last_pts = pts;
66 return view(packet);
67 }
68};
69fn check(status: c_int) !void {
70 return switch (status) {
71 0 => {},
72 -1 => error.InvalidConfiguration,
73 -2 => error.OutOfMemory,
74 else => error.EncodeFailed,
75 };
76}
77fn view(packet: RawPacket) ?Packet {
78 if (packet.size == 0) return null;
79 return .{ .data = packet.data[0..packet.size], .pts = packet.pts, .key = packet.key != 0 };
80}
packages/video/src/codecs/video/rgb_i420.zigadded+37-0
Changes to packages/video/src/codecs/video/rgb_i420.zig
@@ -0,0 +1,37 @@
1//! Home RGB -> limited-range BT.601 I420. Adapted from perf/simd.zig, with
2//! checked strides and actual 2x2 chroma averaging rather than corner sampling.
3const std = ;
4pub fn convert(rgb: []const u8, width: u32, height: u32, stride: u32, out: []u8) !void {
5 if (width == 0 or height == 0 or (width | height) & 1 != 0 or stride < @as(u64, width) * 3) return error.InvalidGeometry;
6 const pixels = std.math.mul(usize, width, height) catch return error.InvalidGeometry;
7 if (rgb.len < @as(u64, stride) * (height - 1) + @as(u64, width) * 3 or out.len < pixels + pixels / 2) return error.InvalidGeometry;
8 for (0..height / 2) |cy| {
9 for (0..width / 2) |cx| {
10 var r: i32 = 0;
11 var g: i32 = 0;
12 var b: i32 = 0;
13 for (0..2) |dy| {
14 for (0..2) |dx| {
15 const x = cx * 2 + dx;
16 const y = cy * 2 + dy;
17 const p = rgb[y * stride + x * 3 ..][0..3];
18 out[y * width + x] = @intCast(((66 * @as(i32, p[0]) + 129 * @as(i32, p[1]) + 25 * @as(i32, p[2]) + 128) >> 8) + 16);
19 r += p[0];
20 g += p[1];
21 b += p[2];
22 }
23 }
24 const uv = cy * (width / 2) + cx;
25 out[pixels + uv] = @intCast(((-38 * r - 74 * g + 112 * b + 512) >> 10) + 128);
26 out[pixels + pixels / 4 + uv] = @intCast(((112 * r - 94 * g - 18 * b + 512) >> 10) + 128);
27 }
28 }
29}
30test "strided RGB conversion averages chroma and rejects truncation" {
31 var out: [6]u8 = undefined;
32 const rgb = [_]u8{ 255, 0, 0, 0, 255, 0, 99, 99, 0, 0, 255, 255, 255, 255 };
33 try convert(&rgb, 2, 2, 8, &out);
34 try std.testing.expectEqualSlices(u8, &.{ 82, 144, 41, 235, 128, 128 }, &out);
35 try std.testing.expectError(error.InvalidGeometry, convert(rgb[0..13], 2, 2, 8, &out));
36 try std.testing.expectError(error.InvalidGeometry, convert(&rgb, 1, 2, 8, &out));
37}
packages/video/src/codecs/video/x264.zigmodified+2-0
Changes to packages/video/src/codecs/video/x264.zig
@@ -1,3 +1,5 @@
1// Legacy handwritten vendor ABI. New integrations should use native_encoder.zig,
2// whose C boundary is compiled against installed headers. See NATIVE_MEDIA.md.
13// Home Video Library - x264 FFI Integration
24// Production H.264 encoding via libx264
35// https://www.videolan.org/developers/x264.html
packages/video/src/codecs/video/x265.zigmodified+2-0
Changes to packages/video/src/codecs/video/x265.zig
@@ -1,3 +1,5 @@
1// Legacy handwritten vendor ABI. New integrations should use native_encoder.zig,
2// whose C boundary is compiled against installed headers. See NATIVE_MEDIA.md.
13// Home Video Library - x265 FFI Integration
24// Production H.265/HEVC encoding via libx265
35// https://x265.readthedocs.io/
packages/video/src/containers/mp4_stream.zigadded+571-0
Changes to packages/video/src/containers/mp4_stream.zig
@@ -0,0 +1,571 @@
1//! Home streaming fragmented MP4 with optional AAC-LC audio, adapted from mp4_muxer.zig.
2//! Complete sample description + mvex; exact sizes/offsets; no file-sized buffer.
3//! AVC/HEVC are length-prefixed, with out-of-band parameter sets (avc1/hvc1).
4const std = ;
5pub const Codec = enum { h264, hevc };
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
14const Buffer = struct {
15 data: [16384]u8 = undefined,
16 len: usize = 0,
17 fn bytes(s: *Buffer, b: []const u8) !void {
18 if (b.len > s.data.len - s.len) return error.HeaderTooLarge;
19 @memcpy(s.data[s.len..][0..b.len], b);
20 s.len += b.len;
21 }
22 fn zero(s: *Buffer, n: usize) !void {
23 if (n > s.data.len - s.len) return error.HeaderTooLarge;
24 @memset(s.data[s.len..][0..n], 0);
25 s.len += n;
26 }
27 fn int16(s: *Buffer, n: u16) !void {
28 var b: [2]u8 = undefined;
29 std.mem.writeInt(u16, &b, n, .big);
30 try s.bytes(&b);
31 }
32 fn int32(s: *Buffer, n: u32) !void {
33 var b: [4]u8 = undefined;
34 std.mem.writeInt(u32, &b, n, .big);
35 try s.bytes(&b);
36 }
37 fn int64(s: *Buffer, n: u64) !void {
38 var b: [8]u8 = undefined;
39 std.mem.writeInt(u64, &b, n, .big);
40 try s.bytes(&b);
41 }
42 fn box(s: *Buffer, name: *const [4]u8) !usize {
43 const at = s.len;
44 try s.int32(0);
45 try s.bytes(name);
46 return at;
47 }
48 fn end(s: *Buffer, at: usize) void {
49 std.mem.writeInt(u32, s.data[at..][0..4], @intCast(s.len - at), .big);
50 }
51 fn full(s: *Buffer, name: *const [4]u8, flags: u32) !usize {
52 const at = try s.box(name);
53 try s.int32(flags);
54 return at;
55 }
56 fn slice(s: *const Buffer) []const u8 {
57 return s.data[0..s.len];
58 }
59 fn matrix(s: *Buffer) !void {
60 for ([_]u32{ 0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000 }) |v| try s.int32(v);
61 }
62};
63pub const Nals = struct {
64 data: []const u8,
65 at: usize = 0,
66 pub fn next(s: *Nals) !?[]const u8 {
67 if (s.at == s.data.len) return null;
68 if (s.data.len - s.at < 4) return error.InvalidNal;
69 const n = std.mem.readInt(u32, s.data[s.at..][0..4], .big);
70 s.at += 4;
71 if (n == 0 or n > s.data.len - s.at) return error.InvalidNal;
72 const nal = s.data[s.at..][0..n];
73 s.at += n;
74 return nal;
75 }
76};
77fn config(b: *Buffer, codec: Codec, headers: []const u8) !void {
78 var nals: Nals = .{ .data = headers };
79 var sps: []const u8 = &.{};
80 var pps: []const u8 = &.{};
81 var vps: []const u8 = &.{};
82 while (try nals.next()) |nal| {
83 if (nal.len > 2048) return error.HeaderTooLarge;
84 const kind = if (codec == .h264) nal[0] & 31 else (nal[0] >> 1) & 63;
85 if (kind == (if (codec == .h264) @as(u8, 7) else 33)) sps = nal;
86 if (kind == (if (codec == .h264) @as(u8, 8) else 34)) pps = nal;
87 if (codec == .hevc and kind == 32) vps = nal;
88 }
89 if (sps.len < 4 or pps.len < 2) return error.MissingParameterSets;
90 const at = try b.box(if (codec == .h264) "avcC" else "hvcC");
91 if (codec == .h264) {
92 // The adapter fixes AVC to 8-bit 4:2:0 High profile.
93 if (sps[1] != 100) return error.UnsupportedProfile;
94 try b.bytes(&.{ 1, sps[1], sps[2], sps[3], 255, 225 });
95 try b.int16(@intCast(sps.len));
96 try b.bytes(sps);
97 try b.bytes(&.{1});
98 try b.int16(@intCast(pps.len));
99 try b.bytes(pps);
100 try b.bytes(&.{ 253, 248, 248, 0 }); // high profile: 4:2:0, 8-bit, no SPS extensions
101 } else {
102 if (vps.len < 2 or sps.len < 16) return error.MissingParameterSets;
103 // Remove emulation prevention before reading SPS profile_tier_level.
104 var rbsp: [2048]u8 = undefined;
105 var len: usize = 0;
106 var zeros: usize = 0;
107 for (sps[2..]) |byte| {
108 if (zeros >= 2 and byte == 3) {
109 zeros = 0;
110 continue;
111 }
112 rbsp[len] = byte;
113 len += 1;
114 zeros = if (byte == 0) zeros + 1 else 0;
115 }
116 if (len < 13 or (rbsp[0] & 14) != 0 or (rbsp[1] & 31) != 1) return error.UnsupportedProfile;
117 try b.bytes(&.{1});
118 try b.bytes(rbsp[1..13]);
119 try b.bytes(&.{ 240, 0, 252, 253, 248, 248, 0, 0, 15, 3 });
120 for ([_]u8{ 32, 33, 34 }, [_][]const u8{ vps, sps, pps }) |kind, nal| {
121 try b.bytes(&.{128 | kind});
122 try b.int16(1);
123 try b.int16(@intCast(nal.len));
124 try b.bytes(nal);
125 }
126 }
127 b.end(at);
128}
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}
243pub const Writer = struct {
244 sink: Sink,
245 sequence: u32 = 1,
246 last_dts: u64 = 0,
247 frames: u64 = 0,
248 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 }
263 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 {
267 if (width == 0 or height == 0 or width > 8192 or height > 8192) return error.InvalidConfiguration;
268 var b: Buffer = .{};
269 const ftyp = try b.box("ftyp");
270 try b.bytes("iso6");
271 try b.int32(512);
272 try b.bytes("iso6isommp41");
273 b.end(ftyp);
274 const moov = try b.box("moov");
275 const mvhd = try b.full("mvhd", 0x1000000);
276 try b.zero(16);
277 try b.int32(1000000);
278 const movie_duration = b.len;
279 try b.int64(0);
280 try b.int32(0x10000);
281 try b.int16(0x100);
282 try b.zero(10);
283 try b.matrix();
284 try b.zero(24);
285 try b.int32(if (audio_cfg != null) 3 else 2);
286 b.end(mvhd);
287 const trak = try b.box("trak");
288 const tkhd = try b.full("tkhd", 0x1000007);
289 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);
295 try b.zero(8);
296 try b.matrix();
297 try b.int32(width << 16);
298 try b.int32(height << 16);
299 b.end(tkhd);
300 const mdia = try b.box("mdia");
301 const mdhd = try b.full("mdhd", 0x1000000);
302 try b.zero(16);
303 try b.int32(1000000);
304 const video_media_duration = b.len;
305 try b.int64(0);
306 try b.int16(0x55c4);
307 try b.int16(0);
308 b.end(mdhd);
309 const hdlr = try b.full("hdlr", 0);
310 try b.int32(0);
311 try b.bytes("vide");
312 try b.zero(12);
313 try b.bytes("Home Video\x00");
314 b.end(hdlr);
315 const minf = try b.box("minf");
316 const vmhd = try b.full("vmhd", 1);
317 try b.zero(8);
318 b.end(vmhd);
319 const dinf = try b.box("dinf");
320 const dref = try b.full("dref", 0);
321 try b.int32(1);
322 const url = try b.full("url ", 1);
323 b.end(url);
324 b.end(dref);
325 b.end(dinf);
326 const stbl = try b.box("stbl");
327 const stsd = try b.full("stsd", 0);
328 try b.int32(1);
329 const entry = try b.box(if (codec == .h264) "avc1" else "hvc1");
330 try b.zero(6);
331 try b.int16(1);
332 try b.zero(16);
333 try b.int16(@intCast(width));
334 try b.int16(@intCast(height));
335 try b.int32(0x480000);
336 try b.int32(0x480000);
337 try b.int32(0);
338 try b.int16(1);
339 try b.zero(32);
340 try b.int16(24);
341 try b.int16(65535);
342 try config(&b, codec, headers);
343 const colr = try b.box("colr");
344 try b.bytes("nclx");
345 try b.int16(1);
346 try b.int16(13);
347 try b.int16(6);
348 try b.bytes(&.{0});
349 b.end(colr);
350 b.end(entry);
351 b.end(stsd);
352 for ([_]*const [4]u8{ "stts", "stsc", "stco" }) |name| {
353 const at = try b.full(name, 0);
354 try b.int32(0);
355 b.end(at);
356 }
357 const stsz = try b.full("stsz", 0);
358 try b.zero(8);
359 b.end(stsz);
360 b.end(stbl);
361 b.end(minf);
362 b.end(mdia);
363 b.end(trak);
364 const offsets = if (audio_cfg) |cfg| try audioTrack(&b, cfg) else AudioOffsets{};
365 const mvex = try b.box("mvex");
366 const trex = try b.full("trex", 0);
367 try b.int32(1);
368 try b.int32(1);
369 try b.zero(12);
370 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 }
380 b.end(mvex);
381 b.end(moov);
382 try sink.write(sink.context, b.slice());
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 };
384 }
385 /// One complete sample. Caller supplies its actual duration, using the next
386 /// capture time; the last sample uses nominal frame duration. PTS == DTS.
387 pub fn frame(s: *Writer, sample: []const u8, dts_us: u64, duration_us: u32, key: bool) !void {
388 if (s.failed or s.finished) return error.WriterFailed;
389 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;
390 var nals: Nals = .{ .data = sample };
391 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;
446 var b: Buffer = .{};
447 const moof = try b.box("moof");
448 const mfhd = try b.full("mfhd", 0);
449 try b.int32(s.sequence);
450 b.end(mfhd);
451 const traf = try b.box("traf");
452 const tfhd = try b.full("tfhd", 0x20000);
453 try b.int32(track);
454 b.end(tfhd);
455 const tfdt = try b.full("tfdt", 0x1000000);
456 try b.int64(time);
457 b.end(tfdt);
458 const trun = try b.full("trun", 0x701);
459 try b.int32(1);
460 const offset = b.len;
461 try b.int32(0);
462 try b.int32(duration);
463 try b.int32(@intCast(sample.len));
464 try b.int32(if (key) 0x02000000 else 0x01010000);
465 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 }
474 b.end(traf);
475 b.end(moof);
476 std.mem.writeInt(u32, b.data[offset..][0..4], @intCast(b.len + 8), .big);
477 try b.int32(@intCast(sample.len + 8));
478 try b.bytes("mdat");
479 errdefer s.failed = true;
480 try s.sink.write(s.sink.context, b.slice());
481 try s.sink.write(s.sink.context, sample);
482 s.sequence += 1;
483 }
484};
485test "NAL lengths reject truncation and empty units" {
486 var n: Nals = .{ .data = &.{ 0, 0, 0, 2, 0x65 } };
487 try std.testing.expectError(error.InvalidNal, n.next());
488 n = .{ .data = &.{ 0, 0, 0, 0 } };
489 try std.testing.expectError(error.InvalidNal, n.next());
490}
491test "fragment sizes, offsets and 64-bit time are exact; sink failure poisons writer" {
492 const Out = struct {
493 data: std.ArrayList(u8) = .empty,
494 fail: bool = false,
495 fn write(ctx: *anyopaque, bytes: []const u8) anyerror!void {
496 const s: *@This() = @ptrCast(@alignCast(ctx));
497 if (s.fail) return error.DiskFull;
498 try s.data.appendSlice(std.testing.allocator, bytes);
499 }
500 };
501 var out: Out = .{};
502 defer out.data.deinit(std.testing.allocator);
503 var writer: Writer = .{ .sink = .{ .context = &out, .write = Out.write } };
504 try writer.frame(&.{ 0, 0, 0, 2, 0x65, 0x80 }, 0x100000000, 41667, true);
505 const bytes = out.data.items;
506 const size = std.mem.readInt(u32, bytes[0..4], .big);
507 try std.testing.expectEqualStrings("moof", bytes[4..8]);
508 try std.testing.expectEqual(@as(u32, 114), std.mem.readInt(u32, bytes[size..][0..4], .big) + size);
509 try std.testing.expectEqual(@as(u64, 0x100000000), std.mem.readInt(u64, bytes[60..68], .big));
510 try std.testing.expectEqual(size + 8, std.mem.readInt(u32, bytes[84..88], .big));
511 try std.testing.expectError(error.InvalidSample, writer.frame(&.{ 0, 0, 0, 1, 1 }, 0x100000000, 1, false));
512 out.fail = true;
513 try std.testing.expectError(error.DiskFull, writer.frame(&.{ 0, 0, 0, 1, 1 }, 0x100000001, 1, false));
514 try std.testing.expectError(error.WriterFailed, writer.frame(&.{ 0, 0, 0, 1, 1 }, 0x100000002, 1, false));
515}
516
517/// CodecPrivate for Matroska, without the enclosing avcC/hvcC box header.
518pub fn codecPrivate(codec: Codec, headers: []const u8, destination: []u8) ![]const u8 {
519 var b: Buffer = .{};
520 try config(&b, codec, headers);
521 const data = b.slice()[8..];
522 if (destination.len < data.len) return error.HeaderTooLarge;
523 @memcpy(destination[0..data.len], data);
524 return destination[0..data.len];
525}
526test "Matroska CodecPrivate reuses checked config and respects destination bounds" {
527 const headers = [_]u8{ 0, 0, 0, 4, 0x67, 100, 0, 40, 0, 0, 0, 2, 0x68, 0x80 };
528 var destination: [128]u8 = undefined;
529 const data = try codecPrivate(.h264, &headers, &destination);
530 try std.testing.expectEqualSlices(u8, &.{ 1, 100, 0, 40, 255, 225 }, data[0..6]);
531 try std.testing.expectError(error.HeaderTooLarge, codecPrivate(.h264, &headers, destination[0..1]));
532 try std.testing.expectError(error.MissingParameterSets, codecPrivate(.h264, &.{}, &destination));
533}
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}
packages/video/src/native_media_tests.zigadded+49-0
Changes to packages/video/src/native_media_tests.zig
@@ -0,0 +1,49 @@
1//! Focused optional codec suite, independent of Home's broad video import graph.
2const std = ;
3const native = ;
4test {
5 _ = ;
6 _ = ;
7}
8test "AVC/HEVC drain every frame with original timestamps and reject invalid lifecycle" {
9 var y: [1024]u8 = undefined;
10 @memset(&y, 90);
11 var uv: [256]u8 = undefined;
12 @memset(&uv, 128);
13 for ([_]u32{ 1, 2 }) |codec| {
14 var encoder = try native.Encoder.init(.{ .codec = codec, .width = 32, .height = 32, .fps_num = 24, .fps_den = 1, .quality = 90, .bitrate = 0, .gop = 4 });
15 defer encoder.deinit();
16 try std.testing.expect((try encoder.headers()).len > 8);
17 var count: usize = 0;
18 for (0..6) |i| {
19 const pts: i64 = @intCast(i * 41667);
20 if (try encoder.encode(.{ &y, &uv, &uv }, .{ 32, 16, 16 }, 32, 32, pts)) |packet| {
21 try std.testing.expectEqual(@as(i64, @intCast(count * 41667)), packet.pts);
22 count += 1;
23 }
24 try std.testing.expectError(error.InvalidTimestamp, encoder.encode(.{ &y, &uv, &uv }, .{ 32, 16, 16 }, 32, 32, pts));
25 }
26 while (try encoder.flush()) |packet| {
27 try std.testing.expectEqual(@as(i64, @intCast(count * 41667)), packet.pts);
28 count += 1;
29 }
30 try std.testing.expectEqual(@as(usize, 6), count);
31 try std.testing.expectError(error.EncoderFailed, encoder.encode(.{ &y, &uv, &uv }, .{ 32, 16, 16 }, 32, 32, 999999));
32 }
33}
34test "codec configuration and plane bounds fail before vendor calls" {
35 try std.testing.expectError(error.InvalidConfiguration, native.Encoder.init(.{ .codec = 1, .width = 17, .height = 16, .fps_num = 30, .fps_den = 1, .quality = 90, .bitrate = 0, .gop = 0 }));
36 var encoder = try native.Encoder.init(.{ .codec = 1, .width = 32, .height = 32, .fps_num = 30, .fps_den = 1, .quality = 90, .bitrate = 0, .gop = 0 });
37 defer encoder.deinit();
38 try std.testing.expectError(error.InvalidFrame, encoder.encode(.{ &.{}, &.{}, &.{} }, .{ 32, 16, 16 }, 32, 32, 0));
39}
40test "quality endpoints preserve supported profiles" {
41 for ([_]u32{ 1, 2 }) |codec| {
42 for ([_]u32{ 1, 100 }) |quality| {
43 var encoder = try native.Encoder.init(.{ .codec = codec, .width = 32, .height = 32, .fps_num = 30, .fps_den = 1, .quality = quality, .bitrate = 0, .gop = 0 });
44 defer encoder.deinit();
45 try std.testing.expect((try encoder.headers()).len > 8);
46 try std.testing.expectEqual(@as(?native.Packet, null), try encoder.flush());
47 }
48 }
49}
packages/video/src/video.zigmodified+6-0
Changes to packages/video/src/video.zig
@@ -97,6 +97,12 @@ pub const TrackInfo = mp4.TrackInfo;
9797pub const TrackType = mp4.TrackType;
9898pub const SampleTable = mp4.SampleTable;
9999
100// Optional header-checked AVC/HEVC integration: compile native_encoder.c and
101// link x264/x265 only when this module is used. See NATIVE_MEDIA.md.
102pub const native_encoder = ;
103pub const rgb_i420 = ;
104pub const mp4_stream = ;
105
100106pub const mp4_muxer = ;
101107pub const Mp4Muxer = mp4_muxer.Mp4Muxer;
102108pub const VideoTrackConfig = mp4_muxer.VideoTrackConfig;
packages/video/test-native-media.shadded+11-0
Changes to packages/video/test-native-media.sh
@@ -0,0 +1,11 @@
1#!/bin/sh
2# Focused suite for the optional Home AVC/HEVC + MP4 modules. No FFmpeg.
3set -eu
4cd "$(dirname "$0")"
5build_dir=$(mktemp -d "${TMPDIR:-/tmp}/home-native-media.XXXXXX")
6trap 'rm -rf "$build_dir"' EXIT HUP INT TERM
7cc -std=c11 -Wall -Wextra -Werror $(pkg-config --cflags x264 x265) -c src/codecs/video/native_encoder.c -o "$build_dir/encoder.o"
8${ZIG:-zig} fmt --check src/codecs/video/native_encoder.zig src/codecs/video/rgb_i420.zig src/containers/mp4_stream.zig src/native_media_tests.zig
9for mode in Debug ReleaseSafe; do
10 ${ZIG:-zig} test src/native_media_tests.zig -O "$mode" -lc "$build_dir/encoder.o" $(pkg-config --libs x264 x265) ${HOME_MEDIA_LDFLAGS:-}
11done
packages/video/tests/native_encoder_idr.zigadded+38-0
Changes to packages/video/tests/native_encoder_idr.zig
@@ -0,0 +1,38 @@
1//! Standalone adapter test; link native_encoder.c with system x264/x265.
2const std = ;
3const native = ;
4fn observe(packet: native.Packet, codec: u32, seen: *[8]bool) !void {
5 const index: usize = @intCast(@divTrunc(packet.pts, 41667));
6 try std.testing.expect(index < 8 and !seen[index]);
7 seen[index] = true;
8 const key = index == 0 or index == 3 or index == 6;
9 try std.testing.expectEqual(key, packet.key);
10 var at: usize = 0;
11 var idr = false;
12 while (at < packet.data.len) {
13 try std.testing.expect(packet.data.len - at >= 4);
14 const n = std.mem.readInt(u32, packet.data[at..][0..4], .big);
15 at += 4;
16 try std.testing.expect(n > 0 and n <= packet.data.len - at);
17 const kind = if (codec == 1) packet.data[at] & 31 else (packet.data[at] >> 1) & 63;
18 idr = idr or (if (codec == 1) kind == 5 else kind == 19 or kind == 20);
19 at += n;
20 }
21 try std.testing.expectEqual(key, idr);
22}
23test "requested IDRs override long GOP without losing delayed AVC frames" {
24 const y: [128 * 96]u8 = @splat(64);
25 const uv: [64 * 48]u8 = @splat(128);
26 for ([_]u32{ 1, 2 }) |codec| {
27 var encoder = try native.Encoder.init(.{ .codec = codec, .width = 128, .height = 96, .fps_num = 24, .fps_den = 1, .bitrate = 0, .quality = 90, .gop = 100 });
28 defer encoder.deinit();
29 var seen: [8]bool = @splat(false);
30 for (0..8) |i| {
31 if (i == 3 or i == 6) try encoder.requestKeyframe();
32 if (try encoder.encode(.{ &y, &uv, &uv }, .{ 128, 64, 64 }, 128, 96, @intCast(i * 41667))) |packet| try observe(packet, codec, &seen);
33 }
34 while (try encoder.flush()) |packet| try observe(packet, codec, &seen);
35 for (seen) |value| try std.testing.expect(value);
36 try std.testing.expectError(error.EncoderFailed, encoder.requestKeyframe());
37 }
38}