Skip to main content

oximedia_codec/
tile.rs

1//! Generic tile-based parallel frame encoding for OxiMedia codecs.
2//!
3//! This module provides codec-agnostic infrastructure for splitting video
4//! frames into rectangular tiles and encoding them concurrently using
5//! Rayon's work-stealing thread pool.  Individual codec implementations
6//! (AV1, VP9, …) plug into the system by implementing [`TileEncodeOp`].
7//!
8//! # Architecture
9//!
10//! ```text
11//! TileConfig  ─── describes the tile grid & thread count
12//!     │
13//!     ▼
14//! TileEncoder ─── splits frame → parallel encode → collects TileResult
15//!     │
16//!     ▼
17//! assemble_tiles() ─── merges sorted TileResults into a single bitstream
18//! ```
19//!
20//! # Thread Safety
21//!
22//! All public types are `Send + Sync`.  Rayon's data-parallel iterators
23//! ensure that no `unsafe` code is required.
24//!
25//! # Example
26//!
27//! ```
28//! use oximedia_codec::tile::{TileConfig, TileEncoder, TileEncodeOp,
29//!                             TileResult, assemble_tiles};
30//! use oximedia_codec::error::CodecResult;
31//! use oximedia_codec::frame::VideoFrame;
32//! use oximedia_core::PixelFormat;
33//!
34//! /// Trivial encode op: store raw luma bytes.
35//! struct RawLumaOp;
36//!
37//! impl TileEncodeOp for RawLumaOp {
38//!     fn encode_tile(
39//!         &self,
40//!         frame: &VideoFrame,
41//!         x: u32, y: u32, w: u32, h: u32,
42//!     ) -> CodecResult<Vec<u8>> {
43//!         let mut out = Vec::new();
44//!         if let Some(plane) = frame.planes.first() {
45//!             for row in y..(y + h) {
46//!                 let start = row as usize * plane.stride + x as usize;
47//!                 out.extend_from_slice(&plane.data[start..start + w as usize]);
48//!             }
49//!         }
50//!         Ok(out)
51//!     }
52//! }
53//!
54//! let cfg = TileConfig::new(2, 2, 0)?;
55//! let encoder = TileEncoder::new(cfg, 1920, 1080);
56//!
57//! let mut frame = VideoFrame::new(PixelFormat::Yuv420p, 1920, 1080);
58//! frame.allocate();
59//!
60//! let results = encoder.encode(&frame, &RawLumaOp)?;
61//! assert_eq!(results.len(), 4);
62//!
63//! let bitstream = assemble_tiles(&results);
64//! assert!(!bitstream.is_empty());
65//! ```
66
67#![forbid(unsafe_code)]
68#![allow(clippy::doc_markdown)]
69#![allow(clippy::cast_possible_truncation)]
70#![allow(clippy::cast_sign_loss)]
71#![allow(clippy::cast_precision_loss)]
72
73use crate::error::{CodecError, CodecResult};
74use crate::frame::VideoFrame;
75use rayon::prelude::*;
76use std::sync::Arc;
77
78// =============================================================================
79// TileConfig
80// =============================================================================
81
82/// Configuration for the tile grid used during parallel encoding.
83///
84/// Tile counts must be positive integers ≤ 64.  A `threads` value of `0`
85/// means "use Rayon's global thread pool size".
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct TileConfig {
88    /// Number of tile columns (1–64).
89    pub tile_cols: u32,
90    /// Number of tile rows (1–64).
91    pub tile_rows: u32,
92    /// Worker thread count (0 = auto).
93    pub threads: usize,
94}
95
96impl TileConfig {
97    /// Create a validated `TileConfig`.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`CodecError::InvalidParameter`] if:
102    /// - `tile_cols` or `tile_rows` is 0 or greater than 64, or
103    /// - the total tile count exceeds 4 096.
104    pub fn new(tile_cols: u32, tile_rows: u32, threads: usize) -> CodecResult<Self> {
105        if tile_cols == 0 || tile_cols > 64 {
106            return Err(CodecError::InvalidParameter(format!(
107                "tile_cols must be 1–64, got {tile_cols}"
108            )));
109        }
110        if tile_rows == 0 || tile_rows > 64 {
111            return Err(CodecError::InvalidParameter(format!(
112                "tile_rows must be 1–64, got {tile_rows}"
113            )));
114        }
115        if tile_cols * tile_rows > 4096 {
116            return Err(CodecError::InvalidParameter(format!(
117                "total tile count {} exceeds 4096",
118                tile_cols * tile_rows
119            )));
120        }
121        Ok(Self {
122            tile_cols,
123            tile_rows,
124            threads,
125        })
126    }
127
128    /// Total number of tiles.
129    #[must_use]
130    pub const fn tile_count(&self) -> u32 {
131        self.tile_cols * self.tile_rows
132    }
133
134    /// Effective thread count (resolves `0` to the rayon pool size).
135    #[must_use]
136    pub fn thread_count(&self) -> usize {
137        if self.threads == 0 {
138            rayon::current_num_threads()
139        } else {
140            self.threads
141        }
142    }
143
144    /// Choose a reasonable tile layout for `width × height` and `threads`.
145    ///
146    /// Selects the largest power-of-two tile counts that keep individual
147    /// tile areas reasonable (≥ 64 × 64 pixels) while not exceeding the
148    /// thread count.
149    #[must_use]
150    pub fn auto(width: u32, height: u32, threads: usize) -> Self {
151        let t = if threads == 0 {
152            rayon::current_num_threads()
153        } else {
154            threads
155        };
156
157        // Distribute threads across columns and rows proportional to aspect.
158        let aspect = width as f32 / height.max(1) as f32;
159        let target = t.next_power_of_two() as u32;
160
161        let mut cols = ((target as f32 * aspect).sqrt().ceil() as u32)
162            .next_power_of_two()
163            .clamp(1, 64);
164        let mut rows = ((target as f32 / aspect).sqrt().ceil() as u32)
165            .next_power_of_two()
166            .clamp(1, 64);
167
168        // Clamp so that each tile is at least 64 pixels wide/tall.
169        while cols > 1 && width / cols < 64 {
170            cols /= 2;
171        }
172        while rows > 1 && height / rows < 64 {
173            rows /= 2;
174        }
175        // Keep total ≤ 4096.
176        while cols * rows > 4096 {
177            if cols > rows {
178                cols /= 2;
179            } else {
180                rows /= 2;
181            }
182        }
183
184        Self {
185            tile_cols: cols,
186            tile_rows: rows,
187            threads,
188        }
189    }
190}
191
192impl Default for TileConfig {
193    /// Single-tile, auto-thread default.
194    fn default() -> Self {
195        Self {
196            tile_cols: 1,
197            tile_rows: 1,
198            threads: 0,
199        }
200    }
201}
202
203// =============================================================================
204// Tile coordinate helper
205// =============================================================================
206
207/// Pixel coordinates and dimensions of a single tile within a frame.
208#[derive(Clone, Debug, PartialEq, Eq)]
209pub struct TileCoord {
210    /// Tile column index (0-based).
211    pub col: u32,
212    /// Tile row index (0-based).
213    pub row: u32,
214    /// X offset in pixels.
215    pub x: u32,
216    /// Y offset in pixels.
217    pub y: u32,
218    /// Tile width in pixels.
219    pub width: u32,
220    /// Tile height in pixels.
221    pub height: u32,
222    /// Linear raster index (`row * tile_cols + col`).
223    pub index: u32,
224}
225
226impl TileCoord {
227    /// Create a new `TileCoord`.
228    #[must_use]
229    pub const fn new(
230        col: u32,
231        row: u32,
232        x: u32,
233        y: u32,
234        width: u32,
235        height: u32,
236        tile_cols: u32,
237    ) -> Self {
238        Self {
239            col,
240            row,
241            x,
242            y,
243            width,
244            height,
245            index: row * tile_cols + col,
246        }
247    }
248
249    /// Tile area in pixels.
250    #[must_use]
251    pub const fn area(&self) -> u32 {
252        self.width * self.height
253    }
254
255    /// True if this tile is at the left frame boundary.
256    #[must_use]
257    pub const fn is_left_edge(&self) -> bool {
258        self.col == 0
259    }
260
261    /// True if this tile is at the top frame boundary.
262    #[must_use]
263    pub const fn is_top_edge(&self) -> bool {
264        self.row == 0
265    }
266}
267
268// =============================================================================
269// TileResult
270// =============================================================================
271
272/// The output produced by encoding a single tile.
273///
274/// Results are collected after parallel encoding and then re-ordered into
275/// raster order by [`TileEncoder::encode`] before being returned to the
276/// caller.
277#[derive(Clone, Debug)]
278pub struct TileResult {
279    /// Spatial coordinates of the tile within the frame.
280    pub coord: TileCoord,
281    /// Codec-specific encoded bytes for this tile.
282    pub data: Vec<u8>,
283}
284
285impl TileResult {
286    /// Create a new `TileResult`.
287    #[must_use]
288    pub fn new(coord: TileCoord, data: Vec<u8>) -> Self {
289        Self { coord, data }
290    }
291
292    /// Raster index of this tile.
293    #[must_use]
294    pub const fn index(&self) -> u32 {
295        self.coord.index
296    }
297
298    /// Encoded size in bytes.
299    #[must_use]
300    pub fn encoded_size(&self) -> usize {
301        self.data.len()
302    }
303
304    /// Returns `true` if the encoded data is empty.
305    #[must_use]
306    pub fn is_empty(&self) -> bool {
307        self.data.is_empty()
308    }
309}
310
311// =============================================================================
312// TileEncodeOp trait
313// =============================================================================
314
315/// Codec-specific tile encoding operation.
316///
317/// Implementors receive a reference to the full frame plus the pixel
318/// coordinates of the tile to encode.  They return the raw encoded bytes
319/// for that tile or a [`CodecError`].
320///
321/// # Thread Safety
322///
323/// Implementations **must** be `Send + Sync` because [`TileEncoder`] drives
324/// them from Rayon parallel iterators.
325pub trait TileEncodeOp: Send + Sync {
326    /// Encode the tile at `(x, y)` with size `(width, height)` pixels.
327    ///
328    /// # Errors
329    ///
330    /// Return a [`CodecError`] if encoding fails for any reason.
331    fn encode_tile(
332        &self,
333        frame: &VideoFrame,
334        x: u32,
335        y: u32,
336        width: u32,
337        height: u32,
338    ) -> CodecResult<Vec<u8>>;
339}
340
341// =============================================================================
342// TileEncoder
343// =============================================================================
344
345/// Splits a frame into tiles and encodes them in parallel using Rayon.
346///
347/// # Example
348///
349/// ```
350/// use oximedia_codec::tile::{TileConfig, TileEncoder, TileEncodeOp, TileResult};
351/// use oximedia_codec::error::CodecResult;
352/// use oximedia_codec::frame::VideoFrame;
353/// use oximedia_core::PixelFormat;
354///
355/// struct NullOp;
356/// impl TileEncodeOp for NullOp {
357///     fn encode_tile(&self, _f: &VideoFrame, _x: u32, _y: u32, _w: u32, _h: u32)
358///         -> CodecResult<Vec<u8>>
359///     {
360///         Ok(vec![0u8; 16])
361///     }
362/// }
363///
364/// let cfg = TileConfig::new(2, 2, 0)?;
365/// let encoder = TileEncoder::new(cfg, 1920, 1080);
366/// let mut frame = VideoFrame::new(PixelFormat::Yuv420p, 1920, 1080);
367/// frame.allocate();
368///
369/// let results = encoder.encode(&frame, &NullOp)?;
370/// assert_eq!(results.len(), 4);
371/// ```
372pub struct TileEncoder {
373    config: Arc<TileConfig>,
374    frame_width: u32,
375    frame_height: u32,
376    /// Pre-computed tile coordinates in raster order.
377    coords: Vec<TileCoord>,
378}
379
380impl TileEncoder {
381    /// Create a `TileEncoder` for frames of size `frame_width × frame_height`.
382    #[must_use]
383    pub fn new(config: TileConfig, frame_width: u32, frame_height: u32) -> Self {
384        let coords = Self::compute_coords(&config, frame_width, frame_height);
385        Self {
386            config: Arc::new(config),
387            frame_width,
388            frame_height,
389            coords,
390        }
391    }
392
393    /// Encode `frame` using `op` in parallel.
394    ///
395    /// The returned `Vec<TileResult>` is sorted in raster order (tile index
396    /// 0 first).
397    ///
398    /// # Errors
399    ///
400    /// Returns the first [`CodecError`] produced by any tile's encoding, or
401    /// [`CodecError::InvalidParameter`] if the frame dimensions do not match
402    /// the encoder configuration.
403    pub fn encode<O: TileEncodeOp>(
404        &self,
405        frame: &VideoFrame,
406        op: &O,
407    ) -> CodecResult<Vec<TileResult>> {
408        if frame.width != self.frame_width || frame.height != self.frame_height {
409            return Err(CodecError::InvalidParameter(format!(
410                "frame {}×{} does not match encoder {}×{}",
411                frame.width, frame.height, self.frame_width, self.frame_height
412            )));
413        }
414
415        // Parallel encode.
416        let results: Vec<CodecResult<TileResult>> = self
417            .coords
418            .par_iter()
419            .map(|coord| {
420                let data = op.encode_tile(frame, coord.x, coord.y, coord.width, coord.height)?;
421                Ok(TileResult::new(coord.clone(), data))
422            })
423            .collect();
424
425        // Propagate errors and sort.
426        let mut tiles = Vec::with_capacity(results.len());
427        for r in results {
428            tiles.push(r?);
429        }
430        tiles.sort_by_key(TileResult::index);
431        Ok(tiles)
432    }
433
434    /// The tile configuration.
435    #[must_use]
436    pub fn config(&self) -> &TileConfig {
437        &self.config
438    }
439
440    /// Frame width.
441    #[must_use]
442    pub const fn frame_width(&self) -> u32 {
443        self.frame_width
444    }
445
446    /// Frame height.
447    #[must_use]
448    pub const fn frame_height(&self) -> u32 {
449        self.frame_height
450    }
451
452    /// Pre-computed tile coordinates.
453    #[must_use]
454    pub fn coords(&self) -> &[TileCoord] {
455        &self.coords
456    }
457
458    /// Total number of tiles.
459    #[must_use]
460    pub fn tile_count(&self) -> usize {
461        self.coords.len()
462    }
463
464    /// Compute uniform tile coordinates for the given config and frame size.
465    fn compute_coords(config: &TileConfig, fw: u32, fh: u32) -> Vec<TileCoord> {
466        let cols = config.tile_cols;
467        let rows = config.tile_rows;
468        let tw = fw.div_ceil(cols); // nominal tile width
469        let th = fh.div_ceil(rows); // nominal tile height
470
471        let mut coords = Vec::with_capacity((cols * rows) as usize);
472        for row in 0..rows {
473            for col in 0..cols {
474                let x = col * tw;
475                let y = row * th;
476                let width = if col == cols - 1 { fw - x } else { tw };
477                let height = if row == rows - 1 { fh - y } else { th };
478                coords.push(TileCoord::new(col, row, x, y, width, height, cols));
479            }
480        }
481        coords
482    }
483}
484
485impl std::fmt::Debug for TileEncoder {
486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487        f.debug_struct("TileEncoder")
488            .field("config", &self.config)
489            .field("frame_width", &self.frame_width)
490            .field("frame_height", &self.frame_height)
491            .field("tile_count", &self.tile_count())
492            .finish()
493    }
494}
495
496// =============================================================================
497// assemble_tiles
498// =============================================================================
499
500/// Assemble an ordered slice of [`TileResult`]s into a single byte stream.
501///
502/// The format is:
503///
504/// ```text
505/// [4 bytes LE: number of tiles]
506/// For each tile except the last:
507///     [4 bytes LE: tile_data_length]
508///     [tile_data_length bytes]
509/// [last tile bytes with no length prefix]
510/// ```
511///
512/// Pass the output to a codec-specific container muxer that understands this
513/// layout (or use [`decode_tile_stream`] to reverse the process).
514///
515/// # Panics
516///
517/// Panics if `tiles` is empty (use the guard in your calling code).
518#[must_use]
519pub fn assemble_tiles(tiles: &[TileResult]) -> Vec<u8> {
520    if tiles.is_empty() {
521        return Vec::new();
522    }
523
524    // Rough pre-allocation.
525    let total_data: usize = tiles.iter().map(|t| t.encoded_size()).sum();
526    let mut out = Vec::with_capacity(4 + total_data + (tiles.len() - 1) * 4);
527
528    // Header: tile count.
529    out.extend_from_slice(&(tiles.len() as u32).to_le_bytes());
530
531    // Tile payloads.
532    for (i, tile) in tiles.iter().enumerate() {
533        let is_last = i == tiles.len() - 1;
534        if !is_last {
535            // Write size prefix for non-terminal tiles.
536            out.extend_from_slice(&(tile.data.len() as u32).to_le_bytes());
537        }
538        out.extend_from_slice(&tile.data);
539    }
540
541    out
542}
543
544/// Decode a tile stream produced by [`assemble_tiles`].
545///
546/// Returns a `Vec` of raw per-tile byte payloads in the order they were
547/// stored (which is raster order when the encoder was used correctly).
548///
549/// # Errors
550///
551/// Returns [`CodecError::InvalidBitstream`] if the stream is truncated or
552/// the encoded tile count is inconsistent with the data length.
553pub fn decode_tile_stream(stream: &[u8]) -> CodecResult<Vec<Vec<u8>>> {
554    if stream.len() < 4 {
555        return Err(CodecError::InvalidBitstream(
556            "tile stream too short for header".to_string(),
557        ));
558    }
559
560    let num_tiles = u32::from_le_bytes([stream[0], stream[1], stream[2], stream[3]]) as usize;
561    if num_tiles == 0 {
562        return Ok(Vec::new());
563    }
564
565    let mut tiles: Vec<Vec<u8>> = Vec::with_capacity(num_tiles);
566    let mut pos = 4usize;
567
568    for i in 0..num_tiles {
569        let is_last = i == num_tiles - 1;
570
571        if is_last {
572            // Last tile: rest of stream.
573            tiles.push(stream[pos..].to_vec());
574            pos = stream.len();
575        } else {
576            if pos + 4 > stream.len() {
577                return Err(CodecError::InvalidBitstream(format!(
578                    "tile {i}: stream truncated before size field"
579                )));
580            }
581            let tile_size = u32::from_le_bytes([
582                stream[pos],
583                stream[pos + 1],
584                stream[pos + 2],
585                stream[pos + 3],
586            ]) as usize;
587            pos += 4;
588
589            if pos + tile_size > stream.len() {
590                return Err(CodecError::InvalidBitstream(format!(
591                    "tile {i}: declared size {tile_size} exceeds remaining stream bytes"
592                )));
593            }
594            tiles.push(stream[pos..pos + tile_size].to_vec());
595            pos += tile_size;
596        }
597    }
598
599    Ok(tiles)
600}
601
602// =============================================================================
603// Built-in encode ops
604// =============================================================================
605
606/// A simple encode op that extracts raw luma samples from a tile.
607///
608/// Useful as a reference implementation and for testing.
609pub struct RawLumaEncodeOp;
610
611impl TileEncodeOp for RawLumaEncodeOp {
612    fn encode_tile(
613        &self,
614        frame: &VideoFrame,
615        x: u32,
616        y: u32,
617        width: u32,
618        height: u32,
619    ) -> CodecResult<Vec<u8>> {
620        let mut out = Vec::with_capacity((width * height) as usize);
621        if let Some(plane) = frame.planes.first() {
622            for row in y..(y + height) {
623                let start = row as usize * plane.stride + x as usize;
624                let end = start + width as usize;
625                if end <= plane.data.len() {
626                    out.extend_from_slice(&plane.data[start..end]);
627                } else {
628                    // Pad with zeros for out-of-plane rows.
629                    let available = plane.data.len().saturating_sub(start);
630                    out.extend_from_slice(&plane.data[start..start + available]);
631                    out.resize(out.len() + (width as usize - available), 0);
632                }
633            }
634        }
635        Ok(out)
636    }
637}
638
639/// A simple op that encodes a tile with a small header describing its
640/// position and appends placeholder compressed data.
641///
642/// Header layout:
643/// ```text
644/// [4 bytes LE: x offset]
645/// [4 bytes LE: y offset]
646/// [4 bytes LE: width]
647/// [4 bytes LE: height]
648/// [1 byte: tile col index]
649/// [1 byte: tile row index]
650/// ```
651/// Followed by raw luma bytes.
652pub struct HeaderedTileEncodeOp;
653
654impl TileEncodeOp for HeaderedTileEncodeOp {
655    fn encode_tile(
656        &self,
657        frame: &VideoFrame,
658        x: u32,
659        y: u32,
660        width: u32,
661        height: u32,
662    ) -> CodecResult<Vec<u8>> {
663        let mut out = Vec::with_capacity(14 + (width * height) as usize);
664        out.extend_from_slice(&x.to_le_bytes());
665        out.extend_from_slice(&y.to_le_bytes());
666        out.extend_from_slice(&width.to_le_bytes());
667        out.extend_from_slice(&height.to_le_bytes());
668
669        // Append raw luma.
670        let raw = RawLumaEncodeOp.encode_tile(frame, x, y, width, height)?;
671        out.extend_from_slice(&raw);
672        Ok(out)
673    }
674}
675
676// =============================================================================
677// Parallel statistics helper
678// =============================================================================
679
680/// Summary statistics over a completed parallel encode run.
681#[derive(Clone, Debug, Default)]
682pub struct TileEncodeStats {
683    /// Total encoded bytes across all tiles.
684    pub total_bytes: usize,
685    /// Smallest tile encoded size in bytes.
686    pub min_tile_bytes: usize,
687    /// Largest tile encoded size in bytes.
688    pub max_tile_bytes: usize,
689    /// Mean encoded bytes per tile.
690    pub mean_tile_bytes: f64,
691    /// Number of tiles.
692    pub tile_count: usize,
693}
694
695impl TileEncodeStats {
696    /// Compute stats from a slice of [`TileResult`]s.
697    ///
698    /// Returns `None` if `results` is empty.
699    #[must_use]
700    pub fn from_results(results: &[TileResult]) -> Option<Self> {
701        if results.is_empty() {
702            return None;
703        }
704        let sizes: Vec<usize> = results.iter().map(TileResult::encoded_size).collect();
705        let total: usize = sizes.iter().sum();
706        let min = *sizes.iter().min().unwrap_or(&0);
707        let max = *sizes.iter().max().unwrap_or(&0);
708        Some(Self {
709            total_bytes: total,
710            min_tile_bytes: min,
711            max_tile_bytes: max,
712            mean_tile_bytes: total as f64 / sizes.len() as f64,
713            tile_count: sizes.len(),
714        })
715    }
716
717    /// Compression ratio (encoded bytes / raw luma bytes).
718    ///
719    /// Returns `None` if `raw_luma_bytes` is 0.
720    #[must_use]
721    pub fn compression_ratio(&self, raw_luma_bytes: usize) -> Option<f64> {
722        if raw_luma_bytes == 0 {
723            return None;
724        }
725        Some(self.total_bytes as f64 / raw_luma_bytes as f64)
726    }
727}
728
729// =============================================================================
730// Tests
731// =============================================================================
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use oximedia_core::PixelFormat;
737
738    // ---------- Helpers -----------------------------------------------------
739
740    fn make_frame(w: u32, h: u32) -> VideoFrame {
741        let mut f = VideoFrame::new(PixelFormat::Yuv420p, w, h);
742        f.allocate();
743        f
744    }
745
746    /// Encode op that returns a fixed-size payload of `n` bytes.
747    struct FixedSizeOp(usize);
748
749    impl TileEncodeOp for FixedSizeOp {
750        fn encode_tile(
751            &self,
752            _frame: &VideoFrame,
753            _x: u32,
754            _y: u32,
755            _w: u32,
756            _h: u32,
757        ) -> CodecResult<Vec<u8>> {
758            Ok(vec![0xABu8; self.0])
759        }
760    }
761
762    /// Encode op that always returns an error.
763    struct ErrorOp;
764
765    impl TileEncodeOp for ErrorOp {
766        fn encode_tile(
767            &self,
768            _frame: &VideoFrame,
769            _x: u32,
770            _y: u32,
771            _w: u32,
772            _h: u32,
773        ) -> CodecResult<Vec<u8>> {
774            Err(CodecError::InvalidParameter("deliberate error".to_string()))
775        }
776    }
777
778    // ---------- TileConfig --------------------------------------------------
779
780    #[test]
781    fn test_tile_config_default() {
782        let cfg = TileConfig::default();
783        assert_eq!(cfg.tile_cols, 1);
784        assert_eq!(cfg.tile_rows, 1);
785        assert_eq!(cfg.tile_count(), 1);
786    }
787
788    #[test]
789    fn test_tile_config_new_valid() {
790        let cfg = TileConfig::new(4, 2, 8).expect("should succeed");
791        assert_eq!(cfg.tile_cols, 4);
792        assert_eq!(cfg.tile_rows, 2);
793        assert_eq!(cfg.tile_count(), 8);
794    }
795
796    #[test]
797    fn test_tile_config_new_zero_cols() {
798        assert!(TileConfig::new(0, 1, 0).is_err());
799    }
800
801    #[test]
802    fn test_tile_config_new_zero_rows() {
803        assert!(TileConfig::new(1, 0, 0).is_err());
804    }
805
806    #[test]
807    fn test_tile_config_new_too_many_cols() {
808        assert!(TileConfig::new(65, 1, 0).is_err());
809    }
810
811    #[test]
812    fn test_tile_config_new_too_many_rows() {
813        assert!(TileConfig::new(1, 65, 0).is_err());
814    }
815
816    #[test]
817    fn test_tile_config_overflow() {
818        // 64 × 64 = 4096, which is exactly the limit.
819        assert!(TileConfig::new(64, 64, 0).is_ok());
820        // 65 cols fails already, but a hypothetical 65×64 = 4160 would also fail.
821    }
822
823    #[test]
824    fn test_tile_config_auto_wide() {
825        let cfg = TileConfig::auto(3840, 1080, 8);
826        assert!(
827            cfg.tile_cols >= cfg.tile_rows,
828            "wide frame should have more columns"
829        );
830        assert!(cfg.tile_count() >= 1);
831    }
832
833    #[test]
834    fn test_tile_config_auto_tall() {
835        let cfg = TileConfig::auto(1080, 3840, 8);
836        assert!(
837            cfg.tile_rows >= cfg.tile_cols,
838            "tall frame should have more rows"
839        );
840    }
841
842    #[test]
843    fn test_tile_config_auto_single_thread() {
844        let cfg = TileConfig::auto(1920, 1080, 1);
845        // With 1 thread, tile count should still be valid.
846        assert!(cfg.tile_count() >= 1);
847    }
848
849    #[test]
850    fn test_tile_config_thread_count_auto() {
851        let cfg = TileConfig::new(1, 1, 0).expect("should succeed");
852        assert!(cfg.thread_count() >= 1);
853    }
854
855    #[test]
856    fn test_tile_config_thread_count_explicit() {
857        let cfg = TileConfig::new(1, 1, 4).expect("should succeed");
858        assert_eq!(cfg.thread_count(), 4);
859    }
860
861    // ---------- TileCoord ---------------------------------------------------
862
863    #[test]
864    fn test_tile_coord_index() {
865        // 2-column grid: (col=1, row=0) → index 1, (col=0, row=1) → index 2
866        let c = TileCoord::new(1, 0, 960, 0, 960, 540, 2);
867        assert_eq!(c.index, 1);
868        assert_eq!(c.area(), 960 * 540);
869        assert!(!c.is_left_edge());
870        assert!(c.is_top_edge());
871    }
872
873    #[test]
874    fn test_tile_coord_top_left() {
875        let c = TileCoord::new(0, 0, 0, 0, 480, 270, 4);
876        assert_eq!(c.index, 0);
877        assert!(c.is_left_edge());
878        assert!(c.is_top_edge());
879    }
880
881    // ---------- TileEncoder -------------------------------------------------
882
883    #[test]
884    fn test_encoder_single_tile() {
885        let cfg = TileConfig::new(1, 1, 0).expect("should succeed");
886        let encoder = TileEncoder::new(cfg, 1920, 1080);
887        assert_eq!(encoder.tile_count(), 1);
888
889        let c = &encoder.coords()[0];
890        assert_eq!(c.x, 0);
891        assert_eq!(c.y, 0);
892        assert_eq!(c.width, 1920);
893        assert_eq!(c.height, 1080);
894    }
895
896    #[test]
897    fn test_encoder_2x2_coverage() {
898        let cfg = TileConfig::new(2, 2, 0).expect("should succeed");
899        let encoder = TileEncoder::new(cfg, 1920, 1080);
900        assert_eq!(encoder.tile_count(), 4);
901
902        // Every pixel should be covered exactly once.
903        let mut covered = vec![0u32; 1920 * 1080];
904        for coord in encoder.coords() {
905            for row in coord.y..(coord.y + coord.height) {
906                for col in coord.x..(coord.x + coord.width) {
907                    covered[(row * 1920 + col) as usize] += 1;
908                }
909            }
910        }
911        assert!(
912            covered.iter().all(|&c| c == 1),
913            "some pixels covered ≠ 1 time"
914        );
915    }
916
917    #[test]
918    fn test_encoder_4x3_coverage() {
919        let cfg = TileConfig::new(4, 3, 0).expect("should succeed");
920        let encoder = TileEncoder::new(cfg, 1280, 720);
921        assert_eq!(encoder.tile_count(), 12);
922
923        let mut total_area: u64 = 0;
924        for coord in encoder.coords() {
925            assert!(coord.width > 0 && coord.height > 0, "empty tile");
926            total_area += u64::from(coord.area());
927        }
928        assert_eq!(total_area, 1280 * 720, "total tile area != frame area");
929    }
930
931    #[test]
932    fn test_encoder_raster_order() {
933        let cfg = TileConfig::new(3, 2, 0).expect("should succeed");
934        let encoder = TileEncoder::new(cfg, 1920, 1080);
935        for (i, coord) in encoder.coords().iter().enumerate() {
936            assert_eq!(coord.index as usize, i, "coords not in raster order");
937        }
938    }
939
940    #[test]
941    fn test_encoder_encode_parallel() {
942        let cfg = TileConfig::new(2, 2, 0).expect("should succeed");
943        let encoder = TileEncoder::new(cfg, 1920, 1080);
944        let frame = make_frame(1920, 1080);
945
946        let results = encoder
947            .encode(&frame, &FixedSizeOp(64))
948            .expect("encode should succeed");
949        assert_eq!(results.len(), 4);
950        // Results must be in raster order.
951        for (i, r) in results.iter().enumerate() {
952            assert_eq!(r.index() as usize, i);
953            assert_eq!(r.encoded_size(), 64);
954        }
955    }
956
957    #[test]
958    fn test_encoder_encode_error_propagates() {
959        let cfg = TileConfig::new(2, 2, 0).expect("should succeed");
960        let encoder = TileEncoder::new(cfg, 1920, 1080);
961        let frame = make_frame(1920, 1080);
962        assert!(encoder.encode(&frame, &ErrorOp).is_err());
963    }
964
965    #[test]
966    fn test_encoder_wrong_frame_dimensions() {
967        let cfg = TileConfig::new(2, 2, 0).expect("should succeed");
968        let encoder = TileEncoder::new(cfg, 1920, 1080);
969        let frame = make_frame(1280, 720);
970        assert!(encoder.encode(&frame, &FixedSizeOp(1)).is_err());
971    }
972
973    // ---------- assemble_tiles / decode_tile_stream -------------------------
974
975    #[test]
976    fn test_assemble_empty() {
977        assert!(assemble_tiles(&[]).is_empty());
978    }
979
980    #[test]
981    fn test_assemble_single_tile() {
982        let coord = TileCoord::new(0, 0, 0, 0, 1920, 1080, 1);
983        let result = TileResult::new(coord, vec![1u8, 2, 3, 4]);
984        let stream = assemble_tiles(&[result]);
985
986        // 4-byte header (tile count = 1) + 4 bytes data (last tile has no size prefix).
987        assert_eq!(stream.len(), 4 + 4);
988        assert_eq!(
989            u32::from_le_bytes([stream[0], stream[1], stream[2], stream[3]]),
990            1
991        );
992    }
993
994    #[test]
995    fn test_assemble_decode_roundtrip_two_tiles() {
996        let payload_a = vec![0xAA; 128];
997        let payload_b = vec![0xBB; 256];
998
999        let ta = TileResult::new(TileCoord::new(0, 0, 0, 0, 960, 540, 2), payload_a.clone());
1000        let tb = TileResult::new(TileCoord::new(1, 0, 960, 0, 960, 540, 2), payload_b.clone());
1001
1002        let stream = assemble_tiles(&[ta, tb]);
1003        let decoded = decode_tile_stream(&stream).expect("should succeed");
1004
1005        assert_eq!(decoded.len(), 2);
1006        assert_eq!(decoded[0], payload_a);
1007        assert_eq!(decoded[1], payload_b);
1008    }
1009
1010    #[test]
1011    fn test_assemble_decode_roundtrip_four_tiles() {
1012        let cfg = TileConfig::new(2, 2, 0).expect("should succeed");
1013        let encoder = TileEncoder::new(cfg, 640, 480);
1014        let frame = make_frame(640, 480);
1015
1016        let results = encoder
1017            .encode(&frame, &RawLumaEncodeOp)
1018            .expect("encode should succeed");
1019        let stream = assemble_tiles(&results);
1020        let decoded = decode_tile_stream(&stream).expect("should succeed");
1021
1022        assert_eq!(decoded.len(), 4);
1023        // Each decoded tile must match the original result's data.
1024        for (orig, dec) in results.iter().zip(decoded.iter()) {
1025            assert_eq!(&orig.data, dec, "tile data mismatch after roundtrip");
1026        }
1027    }
1028
1029    #[test]
1030    fn test_decode_tile_stream_truncated_header() {
1031        assert!(decode_tile_stream(&[0, 1]).is_err());
1032    }
1033
1034    #[test]
1035    fn test_decode_tile_stream_truncated_size() {
1036        // Header says 2 tiles, but there is no size field for tile 0.
1037        let stream = [2u8, 0, 0, 0]; // 4 bytes header, nothing else
1038        assert!(decode_tile_stream(&stream).is_err());
1039    }
1040
1041    #[test]
1042    fn test_decode_tile_stream_truncated_data() {
1043        // Header says 2 tiles; tile 0 claims 1000 bytes but stream is short.
1044        let mut stream = vec![2u8, 0, 0, 0]; // count = 2
1045        stream.extend_from_slice(&1000u32.to_le_bytes()); // tile 0 size
1046        stream.extend(vec![0u8; 10]); // only 10 bytes of data
1047        assert!(decode_tile_stream(&stream).is_err());
1048    }
1049
1050    #[test]
1051    fn test_decode_empty_stream() {
1052        // A stream declaring 0 tiles should yield an empty vec.
1053        let stream = [0u8, 0, 0, 0];
1054        let decoded = decode_tile_stream(&stream).expect("should succeed");
1055        assert!(decoded.is_empty());
1056    }
1057
1058    // ---------- Built-in encode ops -----------------------------------------
1059
1060    #[test]
1061    fn test_raw_luma_op_size() {
1062        let frame = make_frame(320, 240);
1063        let op = RawLumaEncodeOp;
1064        let data = op
1065            .encode_tile(&frame, 0, 0, 320, 240)
1066            .expect("should succeed");
1067        // Should contain exactly 320*240 luma bytes.
1068        assert_eq!(data.len(), 320 * 240);
1069    }
1070
1071    #[test]
1072    fn test_raw_luma_op_partial_tile() {
1073        let frame = make_frame(100, 50);
1074        let op = RawLumaEncodeOp;
1075        let data = op
1076            .encode_tile(&frame, 0, 0, 50, 25)
1077            .expect("should succeed");
1078        assert_eq!(data.len(), 50 * 25);
1079    }
1080
1081    #[test]
1082    fn test_headered_tile_op_header_content() {
1083        let frame = make_frame(128, 64);
1084        let op = HeaderedTileEncodeOp;
1085        let data = op
1086            .encode_tile(&frame, 32, 16, 64, 32)
1087            .expect("should succeed");
1088
1089        // First 16 bytes are the header.
1090        assert!(data.len() >= 16);
1091        let x = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
1092        let y = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
1093        let w = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
1094        let h = u32::from_le_bytes([data[12], data[13], data[14], data[15]]);
1095
1096        assert_eq!(x, 32);
1097        assert_eq!(y, 16);
1098        assert_eq!(w, 64);
1099        assert_eq!(h, 32);
1100        // Remaining bytes are luma samples.
1101        assert_eq!(data.len(), 16 + 64 * 32);
1102    }
1103
1104    // ---------- TileEncodeStats ---------------------------------------------
1105
1106    #[test]
1107    fn test_stats_from_empty() {
1108        assert!(TileEncodeStats::from_results(&[]).is_none());
1109    }
1110
1111    #[test]
1112    fn test_stats_from_uniform() {
1113        let cfg = TileConfig::new(4, 2, 0).expect("should succeed");
1114        let encoder = TileEncoder::new(cfg, 1920, 1080);
1115        let frame = make_frame(1920, 1080);
1116
1117        let results = encoder
1118            .encode(&frame, &FixedSizeOp(200))
1119            .expect("encode should succeed");
1120        let stats = TileEncodeStats::from_results(&results).expect("should succeed");
1121
1122        assert_eq!(stats.tile_count, 8);
1123        assert_eq!(stats.total_bytes, 8 * 200);
1124        assert_eq!(stats.min_tile_bytes, 200);
1125        assert_eq!(stats.max_tile_bytes, 200);
1126        assert!((stats.mean_tile_bytes - 200.0).abs() < 1e-9);
1127    }
1128
1129    #[test]
1130    fn test_stats_compression_ratio() {
1131        let cfg = TileConfig::new(1, 1, 0).expect("should succeed");
1132        let encoder = TileEncoder::new(cfg, 100, 100);
1133        let frame = make_frame(100, 100);
1134
1135        let results = encoder
1136            .encode(&frame, &FixedSizeOp(500))
1137            .expect("encode should succeed");
1138        let stats = TileEncodeStats::from_results(&results).expect("should succeed");
1139
1140        // raw luma = 100 * 100 = 10000 bytes; encoded = 500 → ratio ≈ 0.05
1141        let ratio = stats.compression_ratio(10000).expect("should succeed");
1142        assert!((ratio - 0.05).abs() < 1e-9);
1143
1144        assert!(stats.compression_ratio(0).is_none());
1145    }
1146
1147    // ---------- TileResult --------------------------------------------------
1148
1149    #[test]
1150    fn test_tile_result_metadata() {
1151        let coord = TileCoord::new(2, 1, 640, 360, 320, 180, 4);
1152        let result = TileResult::new(coord.clone(), vec![1, 2, 3]);
1153
1154        assert_eq!(result.index(), 1 * 4 + 2);
1155        assert_eq!(result.encoded_size(), 3);
1156        assert!(!result.is_empty());
1157    }
1158
1159    #[test]
1160    fn test_tile_result_empty() {
1161        let coord = TileCoord::new(0, 0, 0, 0, 10, 10, 1);
1162        let result = TileResult::new(coord, vec![]);
1163        assert!(result.is_empty());
1164        assert_eq!(result.encoded_size(), 0);
1165    }
1166
1167    // ---------- Debug impls -------------------------------------------------
1168
1169    #[test]
1170    fn test_tile_encoder_debug() {
1171        let cfg = TileConfig::new(2, 2, 0).expect("should succeed");
1172        let encoder = TileEncoder::new(cfg, 1920, 1080);
1173        let s = format!("{encoder:?}");
1174        assert!(s.contains("TileEncoder"));
1175        assert!(s.contains("1920"));
1176    }
1177}