Skip to main content

seyd_core/
packer.rs

1// Copyright 2026 Anton Gravestam
2// SPDX-License-Identifier: Apache-2.0
3//! Frames → wire chunks (docs/protocol/chunks.md).
4//!
5//! A video frame becomes one or more FEC **blocks** of up to
6//! `BLOCK_DATA_CHUNKS` data chunks plus `k` parity chunks computed over that
7//! block only. The packer is pure: it allocates the chunk buffers and returns
8//! them; the sender decides admission and transmits.
9
10use bytes::{BufMut, Bytes, BytesMut};
11use seyd_wire::v2::{self, ChunkHeader, FecType, FrameMeta, HEADER_LEN};
12
13pub const BLOCK_DATA_CHUNKS: usize = 8;
14pub const MAX_PARITY_PER_BLOCK: usize = 16;
15
16pub struct VideoPack {
17    pub chunks: Vec<Bytes>,
18    pub blocks: usize,
19    pub data_chunks: usize,
20    pub parity_chunks: usize,
21}
22
23#[derive(Debug, Clone, Copy)]
24pub struct FrameParams {
25    pub channel_id: u8,
26    pub frame_id: u16,
27    pub keyframe: bool,
28    /// The profile's delta or key FEC percentage.
29    pub fec_pct: u32,
30    pub chunk_len: u16,
31    pub send_ts: u32,
32    pub meta: Option<FrameMeta>,
33}
34
35/// Pack one access unit.
36pub fn pack_video(p: FrameParams, payload: &[u8]) -> VideoPack {
37    let FrameParams {
38        channel_id,
39        frame_id,
40        keyframe,
41        fec_pct,
42        chunk_len,
43        send_ts,
44        meta,
45    } = p;
46    let chunk_len_us = chunk_len as usize;
47    // Frame meta is prepended to the payload of block 0 / chunk 0 and is
48    // part of the chunked byte stream, so it is FEC-protected like the rest.
49    let mut body: Vec<u8>;
50    let bytes: &[u8] = if let Some(m) = meta {
51        body = Vec::with_capacity(payload.len() + FrameMeta::LEN);
52        body.extend_from_slice(&m.encode());
53        body.extend_from_slice(payload);
54        &body
55    } else {
56        payload
57    };
58
59    let total_data = bytes.len().div_ceil(chunk_len_us).max(1);
60    let blocks = total_data.div_ceil(BLOCK_DATA_CHUNKS);
61    let mut out = Vec::with_capacity(total_data + blocks * 4);
62    let (mut data_count, mut parity_count_total) = (0, 0);
63
64    for block_idx in 0..blocks {
65        let first = block_idx * BLOCK_DATA_CHUNKS;
66        let n = (total_data - first).min(BLOCK_DATA_CHUNKS);
67        let last = block_idx + 1 == blocks;
68        let k = seyd_fec::parity_count(n, fec_pct, MAX_PARITY_PER_BLOCK);
69
70        let slices: Vec<&[u8]> = (0..n)
71            .map(|i| {
72                let start = (first + i) * chunk_len_us;
73                let end = (start + chunk_len_us).min(bytes.len());
74                if start >= bytes.len() {
75                    &[][..]
76                } else {
77                    &bytes[start..end]
78                }
79            })
80            .collect();
81        let last_len = slices[n - 1].len() as u16;
82
83        let mut flags2 = 0u8;
84        if block_idx == 0 && meta.is_some() {
85            flags2 |= v2::FLAG2_FRAME_META;
86        }
87        if last {
88            flags2 |= v2::FLAG2_END_OF_FRAME;
89        }
90        let hdr = ChunkHeader {
91            keyframe,
92            fec_type: if k > 0 {
93                FecType::ReedSolomon
94            } else {
95                FecType::None
96            },
97            channel_id,
98            frame_id,
99            chunk_idx: 0,
100            n: n as u16,
101            k: k as u8,
102            flags2,
103            last_len,
104            chunk_len,
105            send_ts,
106            block_idx: block_idx as u16,
107        };
108
109        for (i, s) in slices.iter().enumerate() {
110            let mut b = BytesMut::with_capacity(HEADER_LEN + s.len());
111            b.put_slice(
112                &ChunkHeader {
113                    chunk_idx: i as u16,
114                    ..hdr
115                }
116                .encode(),
117            );
118            b.put_slice(s);
119            out.push(b.freeze());
120        }
121        data_count += n;
122
123        if k > 0 {
124            // Parity over zero-padded copies; data chunks go unpadded on the wire.
125            let padded: Vec<Vec<u8>> = slices
126                .iter()
127                .map(|s| {
128                    let mut v = s.to_vec();
129                    v.resize(chunk_len_us, 0);
130                    v
131                })
132                .collect();
133            let refs: Vec<&[u8]> = padded.iter().map(|v| v.as_slice()).collect();
134            for (j, p) in seyd_fec::encode_parity(&refs, k).into_iter().enumerate() {
135                let mut b = BytesMut::with_capacity(HEADER_LEN + p.len());
136                b.put_slice(
137                    &ChunkHeader {
138                        chunk_idx: (n + j) as u16,
139                        ..hdr
140                    }
141                    .encode(),
142                );
143                b.put_slice(&p);
144                out.push(b.freeze());
145            }
146            parity_count_total += k;
147        }
148    }
149    VideoPack {
150        chunks: out,
151        blocks,
152        data_chunks: data_count,
153        parity_chunks: parity_count_total,
154    }
155}
156
157/// Pack one sensor/command message: `n = 1, k = 0`, END_OF_FRAME.
158pub fn pack_message(channel_id: u8, seq: u16, send_ts: u32, payload: &[u8]) -> Bytes {
159    let len = payload.len().min(u16::MAX as usize) as u16;
160    let hdr = ChunkHeader {
161        keyframe: false,
162        fec_type: FecType::None,
163        channel_id,
164        frame_id: seq,
165        chunk_idx: 0,
166        n: 1,
167        k: 0,
168        flags2: v2::FLAG2_END_OF_FRAME,
169        last_len: len,
170        chunk_len: len,
171        send_ts,
172        block_idx: 0,
173    };
174    let mut b = BytesMut::with_capacity(HEADER_LEN + payload.len());
175    b.put_slice(&hdr.encode());
176    b.put_slice(&payload[..len as usize]);
177    b.freeze()
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    /// Reference receiver used only to validate the packer: reassembles from
185    /// chunks, recovering each block through seyd-fec after erasures.
186    fn reassemble(chunks: &[Bytes], erase: &[usize]) -> Option<(Vec<u8>, bool)> {
187        use std::collections::BTreeMap;
188        type Block = (ChunkHeader, Vec<Option<Vec<u8>>>, Vec<Option<Vec<u8>>>);
189        let mut blocks: BTreeMap<u16, Block> = BTreeMap::new();
190        let mut end_seen = None;
191        for (i, c) in chunks.iter().enumerate() {
192            if erase.contains(&i) {
193                continue;
194            }
195            let (h, p) = ChunkHeader::parse(c).unwrap();
196            let e = blocks
197                .entry(h.block_idx)
198                .or_insert_with(|| (h, vec![None; h.n as usize], vec![None; h.k as usize]));
199            if h.is_end_of_frame() {
200                end_seen = Some(h.block_idx);
201            }
202            if h.is_parity() {
203                e.2[(h.chunk_idx - h.n) as usize] = Some(p.to_vec());
204            } else {
205                let mut v = p.to_vec();
206                v.resize(h.chunk_len as usize, 0);
207                e.1[h.chunk_idx as usize] = Some(v);
208            }
209        }
210        let last = end_seen?;
211        let mut out = Vec::new();
212        let mut key = false;
213        for b in 0..=last {
214            let (h, data, parity) = blocks.get_mut(&b)?;
215            key = h.keyframe;
216            if !seyd_fec::decode(data, parity) {
217                return None;
218            }
219            for (i, d) in data.iter().enumerate() {
220                let take = if i + 1 == h.n as usize {
221                    h.last_len as usize
222                } else {
223                    h.chunk_len as usize
224                };
225                out.extend_from_slice(&d.as_ref().unwrap()[..take]);
226            }
227        }
228        Some((out, key))
229    }
230
231    #[test]
232    fn multi_block_keyframe_survives_k_losses_per_block() {
233        let payload: Vec<u8> = (0..23_456u32).map(|i| (i * 7 % 251) as u8).collect();
234        let pack = pack_video(
235            FrameParams {
236                channel_id: 1,
237                frame_id: 42,
238                keyframe: true,
239                fec_pct: 50,
240                chunk_len: 1000,
241                send_ts: 123,
242                meta: None,
243            },
244            &payload,
245        );
246        // 24 data chunks → 3 blocks of 8, each with k = 4 at 50 %
247        assert_eq!(pack.blocks, 3);
248        assert_eq!(pack.data_chunks, 24);
249        assert_eq!(pack.parity_chunks, 12);
250        assert_eq!(pack.chunks.len(), 36);
251        // erase 4 chunks from each block (block = 12 chunks on the wire)
252        let erase: Vec<usize> = [0usize, 5, 8, 11, 12, 13, 22, 23, 24, 30, 33, 35].to_vec();
253        let (out, key) = reassemble(&pack.chunks, &erase).unwrap();
254        assert!(key);
255        assert_eq!(out, payload);
256        // one more loss in a block is unrecoverable
257        let mut erase5 = erase.clone();
258        erase5.push(1);
259        assert!(reassemble(&pack.chunks, &erase5).is_none());
260    }
261
262    #[test]
263    fn tiny_frame_is_one_block_with_meta() {
264        let meta = FrameMeta {
265            capture_ts_us: 99,
266            seq_in_gop: 3,
267        };
268        let pack = pack_video(
269            FrameParams {
270                channel_id: 2,
271                frame_id: 7,
272                keyframe: false,
273                fec_pct: 15,
274                chunk_len: 1000,
275                send_ts: 5,
276                meta: Some(meta),
277            },
278            b"hello",
279        );
280        assert_eq!(pack.chunks.len(), 2); // 1 data + k=1 (floor of 1)
281        let (h, p) = ChunkHeader::parse(&pack.chunks[0]).unwrap();
282        assert!(h.has_frame_meta() && h.is_end_of_frame() && !h.keyframe);
283        assert_eq!(h.last_len as usize, FrameMeta::LEN + 5);
284        let (m, rest) = FrameMeta::parse(p).unwrap();
285        assert_eq!(m, meta);
286        assert_eq!(rest, b"hello");
287        let (out, _) = reassemble(&pack.chunks, &[0]).unwrap();
288        assert_eq!(&out[FrameMeta::LEN..], b"hello");
289    }
290
291    #[test]
292    fn exact_multiple_has_full_last_len() {
293        let payload = vec![1u8; 8000];
294        let pack = pack_video(
295            FrameParams {
296                channel_id: 1,
297                frame_id: 1,
298                keyframe: false,
299                fec_pct: 0,
300                chunk_len: 1000,
301                send_ts: 0,
302                meta: None,
303            },
304            &payload,
305        );
306        assert_eq!(pack.chunks.len(), 8);
307        let (h, _) = ChunkHeader::parse(&pack.chunks[7]).unwrap();
308        assert_eq!(h.last_len, 1000);
309        assert_eq!(h.fec_type, FecType::None);
310    }
311
312    #[test]
313    fn message_pack() {
314        let m = pack_message(3, 65535, 1, b"{\"pan\":1}");
315        let (h, p) = ChunkHeader::parse(&m).unwrap();
316        assert_eq!((h.n, h.k, h.frame_id, h.channel_id), (1, 0, 65535, 3));
317        assert!(h.is_end_of_frame());
318        assert_eq!(p, b"{\"pan\":1}");
319    }
320}