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