Skip to main content

oximedia_codec/av1/
parallel_tile_encoder.rs

1// Copyright 2024 The OxiMedia Project Developers
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9//! Parallel AV1 tile encoding over raw frame bytes.
10//!
11//! This module provides a **low-level**, frame-buffer-oriented API for
12//! splitting a YUV420p luma plane into tile regions and encoding them in
13//! parallel using rayon.  It is the structural companion to
14//! `super::parallel_tile_decoder` on the encode side.
15//!
16//! For a higher-level API that works with [`crate::frame::VideoFrame`]
17//! objects see [`super::tile_encoder::ParallelTileEncoder`].
18//!
19//! # Structural implementation note
20//!
21//! A full AV1 tile encode requires mode decision, transform coding,
22//! quantisation, and entropy coding — all tightly coupled to codec state.
23//! This module provides the *structural scaffolding*: correct tile splitting,
24//! parallel dispatch via rayon, and a minimal binary encoding (tile header +
25//! QP-XOR pixel data) suitable as a drop-in stand-in for the real pipeline.
26//!
27//! # Example
28//!
29//! ```rust
30//! use oximedia_codec::av1::{RawTileEncoderConfig, encode_tiles_parallel};
31//!
32//! let config = RawTileEncoderConfig {
33//!     tile_cols: 2,
34//!     tile_rows: 2,
35//!     threads: 0,
36//!     base_qp: 32,
37//! };
38//! let frame = vec![128u8; 1920 * 1080]; // synthetic luma
39//! let tiles = encode_tiles_parallel(&frame, 1920, 1080, &config).expect("encode");
40//! assert_eq!(tiles.len(), 4);
41//! ```
42
43#![forbid(unsafe_code)]
44#![allow(clippy::cast_possible_truncation)]
45#![allow(clippy::missing_errors_doc)]
46
47use rayon::prelude::*;
48
49use crate::error::{CodecError, CodecResult};
50
51// ─────────────────────────────────────────────────────────────────────────────
52// Magic bytes written into every tile header
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// Four-byte magic that starts every encoded tile: `AV1T`.
56const TILE_MAGIC: [u8; 4] = [0x41, 0x56, 0x31, 0x54]; // "AV1T"
57
58/// Size of the tile header in bytes: magic(4) + width(4) + height(4) + qp(4).
59const TILE_HEADER_SIZE: usize = 16;
60
61// ─────────────────────────────────────────────────────────────────────────────
62// Configuration
63// ─────────────────────────────────────────────────────────────────────────────
64
65/// Configuration for the low-level parallel tile encoder.
66#[derive(Clone, Debug)]
67pub struct TileEncoderConfig {
68    /// Number of tile columns (must be ≥ 1).
69    pub tile_cols: u32,
70    /// Number of tile rows (must be ≥ 1).
71    pub tile_rows: u32,
72    /// Number of rayon threads to use (0 = auto-detect from rayon default pool).
73    pub threads: usize,
74    /// Base quantisation parameter (0 = highest quality / largest output,
75    /// 255 = lowest quality / smallest output).
76    pub base_qp: u32,
77}
78
79impl Default for TileEncoderConfig {
80    fn default() -> Self {
81        Self {
82            tile_cols: 1,
83            tile_rows: 1,
84            threads: 0,
85            base_qp: 32,
86        }
87    }
88}
89
90impl TileEncoderConfig {
91    /// Validate that the configuration is internally consistent.
92    ///
93    /// # Errors
94    ///
95    /// Returns `CodecError::InvalidParameter` if any field is out of range.
96    pub fn validate(&self) -> CodecResult<()> {
97        if self.tile_cols == 0 {
98            return Err(CodecError::InvalidParameter(
99                "tile_cols must be at least 1".to_string(),
100            ));
101        }
102        if self.tile_rows == 0 {
103            return Err(CodecError::InvalidParameter(
104                "tile_rows must be at least 1".to_string(),
105            ));
106        }
107        if self.base_qp > 255 {
108            return Err(CodecError::InvalidParameter(
109                "base_qp must be in range 0–255".to_string(),
110            ));
111        }
112        Ok(())
113    }
114
115    /// Total number of tiles.
116    #[must_use]
117    pub const fn tile_count(&self) -> u32 {
118        self.tile_cols * self.tile_rows
119    }
120}
121
122// ─────────────────────────────────────────────────────────────────────────────
123// Tile region descriptor
124// ─────────────────────────────────────────────────────────────────────────────
125
126/// Describes the location and dimensions of one tile within a frame.
127#[derive(Clone, Debug)]
128pub struct TileRegionInfo {
129    /// Tile column index (0-based, left to right).
130    pub col: u32,
131    /// Tile row index (0-based, top to bottom).
132    pub row: u32,
133    /// Pixel X offset of this tile's top-left corner.
134    pub x: u32,
135    /// Pixel Y offset of this tile's top-left corner.
136    pub y: u32,
137    /// Tile width in pixels.
138    pub width: u32,
139    /// Tile height in pixels.
140    pub height: u32,
141}
142
143impl TileRegionInfo {
144    /// Raster-order index of this tile: `row * tile_cols + col`.
145    #[must_use]
146    pub fn raster_index(&self, tile_cols: u32) -> u32 {
147        self.row * tile_cols + self.col
148    }
149
150    /// Area of this tile in pixels.
151    #[must_use]
152    pub const fn area(&self) -> u32 {
153        self.width * self.height
154    }
155}
156
157// ─────────────────────────────────────────────────────────────────────────────
158// Encoded tile result
159// ─────────────────────────────────────────────────────────────────────────────
160
161/// The result of encoding a single tile.
162#[derive(Clone, Debug)]
163pub struct EncodedTile {
164    /// Tile column index.
165    pub tile_col: u32,
166    /// Tile row index.
167    pub tile_row: u32,
168    /// Pixel offset of this tile's top-left corner: `(x, y)`.
169    pub tile_offset: (u32, u32),
170    /// Pixel dimensions of this tile: `(width, height)`.
171    pub tile_size: (u32, u32),
172    /// Encoded bitstream bytes for this tile.
173    pub data: Vec<u8>,
174    /// Quantisation parameter used.
175    pub qp: u32,
176}
177
178impl EncodedTile {
179    /// Raster-order index: `tile_row * tile_cols + tile_col`.
180    #[must_use]
181    pub fn raster_index(&self, tile_cols: u32) -> u32 {
182        self.tile_row * tile_cols + self.tile_col
183    }
184}
185
186// ─────────────────────────────────────────────────────────────────────────────
187// Free function (primary public API)
188// ─────────────────────────────────────────────────────────────────────────────
189
190/// Encode a raw luma frame into parallel tile bitstreams.
191///
192/// `frame` is interpreted as a contiguous row-major luma plane of
193/// `width × height` bytes.  The frame is split into a
194/// `config.tile_cols × config.tile_rows` grid and each tile is encoded
195/// independently and concurrently using rayon.
196///
197/// # Returns
198///
199/// `Vec<Vec<u8>>` — one inner `Vec<u8>` per tile, in raster order
200/// (row-by-row, left to right within each row).
201///
202/// # Errors
203///
204/// Returns `CodecError::InvalidParameter` when the configuration is invalid
205/// or `CodecError::InvalidBitstream` when an individual tile fails to encode.
206pub fn encode_tiles_parallel(
207    frame: &[u8],
208    width: u32,
209    height: u32,
210    config: &TileEncoderConfig,
211) -> CodecResult<Vec<Vec<u8>>> {
212    config.validate()?;
213
214    if width == 0 || height == 0 {
215        return Err(CodecError::InvalidParameter(
216            "frame width and height must be non-zero".to_string(),
217        ));
218    }
219
220    let encoder = ParallelTileEncoder::new(width, height, config.clone())?;
221    let split_tiles = encoder.split_frame(frame);
222
223    // Choose the rayon executor depending on threads setting.
224    let encoded: CodecResult<Vec<(u32, Vec<u8>)>> = if config.threads > 0 {
225        // Build a dedicated thread pool scoped to this call.
226        let pool = rayon::ThreadPoolBuilder::new()
227            .num_threads(config.threads)
228            .build()
229            .map_err(|e| CodecError::Internal(format!("thread pool error: {}", e)))?;
230
231        pool.install(|| {
232            split_tiles
233                .par_iter()
234                .map(|(region, tile_data)| {
235                    let idx = region.raster_index(config.tile_cols);
236                    let encoded = encode_single_tile(tile_data, region, config.base_qp)?;
237                    Ok((idx, encoded))
238                })
239                .collect()
240        })
241    } else {
242        split_tiles
243            .par_iter()
244            .map(|(region, tile_data)| {
245                let idx = region.raster_index(config.tile_cols);
246                let encoded = encode_single_tile(tile_data, region, config.base_qp)?;
247                Ok((idx, encoded))
248            })
249            .collect()
250    };
251
252    let mut indexed = encoded?;
253    // Sort by raster index to guarantee deterministic ordering.
254    indexed.sort_by_key(|(idx, _)| *idx);
255    Ok(indexed.into_iter().map(|(_, data)| data).collect())
256}
257
258// ─────────────────────────────────────────────────────────────────────────────
259// ParallelTileEncoder struct
260// ─────────────────────────────────────────────────────────────────────────────
261
262/// Parallel AV1 tile encoder operating on raw byte frames.
263///
264/// This struct provides the same functionality as [`encode_tiles_parallel`]
265/// but as a reusable object that caches frame geometry.
266#[derive(Clone, Debug)]
267pub struct ParallelTileEncoder {
268    /// Frame width in pixels.
269    pub frame_width: u32,
270    /// Frame height in pixels.
271    pub frame_height: u32,
272    /// Encoder configuration.
273    pub config: TileEncoderConfig,
274}
275
276impl ParallelTileEncoder {
277    /// Create a new `ParallelTileEncoder`.
278    ///
279    /// # Errors
280    ///
281    /// Returns `CodecError::InvalidParameter` if the configuration is invalid
282    /// or the frame dimensions are zero.
283    pub fn new(
284        frame_width: u32,
285        frame_height: u32,
286        config: TileEncoderConfig,
287    ) -> CodecResult<Self> {
288        config.validate()?;
289        if frame_width == 0 || frame_height == 0 {
290            return Err(CodecError::InvalidParameter(
291                "frame width and height must be non-zero".to_string(),
292            ));
293        }
294        Ok(Self {
295            frame_width,
296            frame_height,
297            config,
298        })
299    }
300
301    /// Split `frame` (luma bytes, row-major) into `(TileRegionInfo, Vec<u8>)` pairs.
302    ///
303    /// Each pair contains the region descriptor and the extracted luma bytes
304    /// for that tile, ready for encoding.  Tiles are returned in raster order.
305    #[must_use]
306    pub fn split_frame(&self, frame: &[u8]) -> Vec<(TileRegionInfo, Vec<u8>)> {
307        let tile_cols = self.config.tile_cols;
308        let tile_rows = self.config.tile_rows;
309
310        if tile_cols == 0 || tile_rows == 0 {
311            return Vec::new();
312        }
313
314        let base_w = self.frame_width / tile_cols;
315        let rem_w = self.frame_width % tile_cols;
316        let base_h = self.frame_height / tile_rows;
317        let rem_h = self.frame_height % tile_rows;
318
319        let mut result = Vec::with_capacity((tile_rows * tile_cols) as usize);
320
321        for row in 0..tile_rows {
322            let tile_h = if row == tile_rows - 1 {
323                base_h + rem_h
324            } else {
325                base_h
326            };
327            let y_off = row * base_h;
328
329            for col in 0..tile_cols {
330                let tile_w = if col == tile_cols - 1 {
331                    base_w + rem_w
332                } else {
333                    base_w
334                };
335                let x_off = col * base_w;
336
337                let tile_bytes =
338                    extract_luma_region(frame, self.frame_width, x_off, y_off, tile_w, tile_h);
339
340                let region = TileRegionInfo {
341                    col,
342                    row,
343                    x: x_off,
344                    y: y_off,
345                    width: tile_w,
346                    height: tile_h,
347                };
348
349                result.push((region, tile_bytes));
350            }
351        }
352
353        result
354    }
355
356    /// Encode all tiles in parallel and return [`EncodedTile`] results in
357    /// raster order.
358    ///
359    /// # Errors
360    ///
361    /// Returns `CodecError::InvalidBitstream` if any tile fails to encode.
362    pub fn encode_frame_parallel(&self, frame: &[u8]) -> CodecResult<Vec<EncodedTile>> {
363        let split = self.split_frame(frame);
364
365        let results: CodecResult<Vec<EncodedTile>> = split
366            .into_par_iter()
367            .map(|(region, tile_data)| {
368                let data = encode_single_tile(&tile_data, &region, self.config.base_qp)?;
369                Ok(EncodedTile {
370                    tile_col: region.col,
371                    tile_row: region.row,
372                    tile_offset: (region.x, region.y),
373                    tile_size: (region.width, region.height),
374                    data,
375                    qp: self.config.base_qp,
376                })
377            })
378            .collect();
379
380        let mut tiles = results?;
381        tiles.sort_by_key(|t| t.raster_index(self.config.tile_cols));
382        Ok(tiles)
383    }
384
385    /// Assemble a slice of [`EncodedTile`]s into a single byte stream.
386    ///
387    /// Format: for each tile except the last, a 4-byte LE tile size prefix is
388    /// written followed by the tile data.  The last tile has no size prefix
389    /// (matching AV1 tile group conventions where the last tile size is
390    /// implicit).
391    #[must_use]
392    pub fn assemble_encoded(&self, tiles: &[EncodedTile]) -> Vec<u8> {
393        assemble_encoded_tiles(tiles)
394    }
395}
396
397// ─────────────────────────────────────────────────────────────────────────────
398// Internal helpers
399// ─────────────────────────────────────────────────────────────────────────────
400
401/// Extract the luma bytes of a rectangular tile region from a row-major buffer.
402fn extract_luma_region(
403    frame: &[u8],
404    frame_width: u32,
405    x_off: u32,
406    y_off: u32,
407    tile_w: u32,
408    tile_h: u32,
409) -> Vec<u8> {
410    let mut out = Vec::with_capacity((tile_w * tile_h) as usize);
411
412    for row in 0..tile_h {
413        let src_start = ((y_off + row) * frame_width + x_off) as usize;
414        let src_end = src_start + tile_w as usize;
415
416        if src_start >= frame.len() {
417            // Pad with grey when input is exhausted.
418            out.extend(std::iter::repeat(128u8).take(tile_w as usize));
419        } else {
420            let avail_end = src_end.min(frame.len());
421            out.extend_from_slice(&frame[src_start..avail_end]);
422            if avail_end < src_end {
423                out.extend(std::iter::repeat(128u8).take(src_end - avail_end));
424            }
425        }
426    }
427
428    out
429}
430
431/// Structural single-tile encoder.
432///
433/// Produces:
434/// - 16-byte header: `TILE_MAGIC` (4) + `width` u32-LE (4) + `height` u32-LE (4) + `qp` u32-LE (4)
435/// - Payload: tile luma bytes XOR'd with `(qp & 0xFF) as u8`
436///
437/// # Errors
438///
439/// Returns `CodecError::InvalidBitstream` if the tile dimensions are zero.
440fn encode_single_tile(tile_data: &[u8], region: &TileRegionInfo, qp: u32) -> CodecResult<Vec<u8>> {
441    if region.width == 0 || region.height == 0 {
442        return Err(CodecError::InvalidBitstream(format!(
443            "tile ({},{}) has zero dimension: {}×{}",
444            region.col, region.row, region.width, region.height
445        )));
446    }
447
448    let payload_len = (region.width * region.height) as usize;
449    let mut out = Vec::with_capacity(TILE_HEADER_SIZE + payload_len);
450
451    // Write header.
452    out.extend_from_slice(&TILE_MAGIC);
453    out.extend_from_slice(&region.width.to_le_bytes());
454    out.extend_from_slice(&region.height.to_le_bytes());
455    out.extend_from_slice(&qp.to_le_bytes());
456
457    // Write XOR-encoded payload (structural stand-in).
458    let xor_mask = (qp & 0xFF) as u8;
459    let copy_len = payload_len.min(tile_data.len());
460    for &b in &tile_data[..copy_len] {
461        out.push(b ^ xor_mask);
462    }
463    // Pad if tile_data was shorter than expected.
464    for _ in copy_len..payload_len {
465        out.push(128u8 ^ xor_mask);
466    }
467
468    Ok(out)
469}
470
471/// Assemble [`EncodedTile`] slices into a single stream.
472///
473/// Each tile except the last is prefixed with a 4-byte LE size field.
474fn assemble_encoded_tiles(tiles: &[EncodedTile]) -> Vec<u8> {
475    if tiles.is_empty() {
476        return Vec::new();
477    }
478
479    let total: usize = tiles
480        .iter()
481        .enumerate()
482        .map(|(i, t)| {
483            if i < tiles.len() - 1 {
484                4 + t.data.len()
485            } else {
486                t.data.len()
487            }
488        })
489        .sum();
490
491    let mut out = Vec::with_capacity(total);
492
493    for (i, tile) in tiles.iter().enumerate() {
494        let is_last = i == tiles.len() - 1;
495        if !is_last {
496            let size = tile.data.len() as u32;
497            out.extend_from_slice(&size.to_le_bytes());
498        }
499        out.extend_from_slice(&tile.data);
500    }
501
502    out
503}
504
505// ─────────────────────────────────────────────────────────────────────────────
506// Tests
507// ─────────────────────────────────────────────────────────────────────────────
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    // ── helpers ───────────────────────────────────────────────────────────────
514
515    fn make_frame(width: u32, height: u32, fill: u8) -> Vec<u8> {
516        vec![fill; (width * height) as usize]
517    }
518
519    fn default_config_2x2() -> TileEncoderConfig {
520        TileEncoderConfig {
521            tile_cols: 2,
522            tile_rows: 2,
523            threads: 0,
524            base_qp: 32,
525        }
526    }
527
528    // ── TileEncoderConfig ─────────────────────────────────────────────────────
529
530    #[test]
531    fn test_config_default_valid() {
532        let cfg = TileEncoderConfig::default();
533        assert!(cfg.validate().is_ok());
534    }
535
536    #[test]
537    fn test_config_tile_count() {
538        let cfg = TileEncoderConfig {
539            tile_cols: 4,
540            tile_rows: 2,
541            ..Default::default()
542        };
543        assert_eq!(cfg.tile_count(), 8);
544    }
545
546    // ── ParallelTileEncoder::new ──────────────────────────────────────────────
547
548    #[test]
549    fn test_new_valid_config() {
550        let cfg = default_config_2x2();
551        let enc = ParallelTileEncoder::new(1920, 1080, cfg);
552        assert!(enc.is_ok());
553    }
554
555    #[test]
556    fn test_new_zero_cols_errors() {
557        let cfg = TileEncoderConfig {
558            tile_cols: 0,
559            tile_rows: 2,
560            threads: 0,
561            base_qp: 32,
562        };
563        let result = ParallelTileEncoder::new(1920, 1080, cfg);
564        assert!(result.is_err(), "zero tile_cols should fail");
565    }
566
567    #[test]
568    fn test_new_zero_rows_errors() {
569        let cfg = TileEncoderConfig {
570            tile_cols: 2,
571            tile_rows: 0,
572            threads: 0,
573            base_qp: 32,
574        };
575        let result = ParallelTileEncoder::new(1920, 1080, cfg);
576        assert!(result.is_err(), "zero tile_rows should fail");
577    }
578
579    #[test]
580    fn test_new_zero_width_errors() {
581        let cfg = default_config_2x2();
582        let result = ParallelTileEncoder::new(0, 1080, cfg);
583        assert!(result.is_err(), "zero width should fail");
584    }
585
586    // ── split_frame ───────────────────────────────────────────────────────────
587
588    #[test]
589    fn test_split_frame_tile_count_2x2() {
590        let cfg = default_config_2x2();
591        let enc = ParallelTileEncoder::new(640, 480, cfg).expect("ok");
592        let frame = make_frame(640, 480, 0);
593        let tiles = enc.split_frame(&frame);
594        assert_eq!(tiles.len(), 4, "2×2 grid must yield 4 tiles");
595    }
596
597    #[test]
598    fn test_split_frame_tile_sizes_sum_to_frame() {
599        let cfg = default_config_2x2();
600        let enc = ParallelTileEncoder::new(800, 600, cfg).expect("ok");
601        let frame = make_frame(800, 600, 0);
602        let tiles = enc.split_frame(&frame);
603
604        let row0_width: u32 = tiles
605            .iter()
606            .filter(|(r, _)| r.row == 0)
607            .map(|(r, _)| r.width)
608            .sum();
609        let col0_height: u32 = tiles
610            .iter()
611            .filter(|(r, _)| r.col == 0)
612            .map(|(r, _)| r.height)
613            .sum();
614        assert_eq!(
615            row0_width, 800,
616            "tile widths in row 0 must sum to frame width"
617        );
618        assert_eq!(
619            col0_height, 600,
620            "tile heights in col 0 must sum to frame height"
621        );
622    }
623
624    #[test]
625    fn test_split_frame_non_divisible() {
626        // 1000 / 3 = 333 remainder 1; 700 / 2 = 350 exactly
627        let cfg = TileEncoderConfig {
628            tile_cols: 3,
629            tile_rows: 2,
630            threads: 0,
631            base_qp: 16,
632        };
633        let enc = ParallelTileEncoder::new(1000, 700, cfg).expect("ok");
634        let frame = make_frame(1000, 700, 0);
635        let tiles = enc.split_frame(&frame);
636        assert_eq!(tiles.len(), 6);
637
638        let row0_width: u32 = tiles
639            .iter()
640            .filter(|(r, _)| r.row == 0)
641            .map(|(r, _)| r.width)
642            .sum();
643        let col0_height: u32 = tiles
644            .iter()
645            .filter(|(r, _)| r.col == 0)
646            .map(|(r, _)| r.height)
647            .sum();
648        assert_eq!(row0_width, 1000);
649        assert_eq!(col0_height, 700);
650    }
651
652    #[test]
653    fn test_split_frame_data_length_equals_area() {
654        let cfg = default_config_2x2();
655        let enc = ParallelTileEncoder::new(200, 100, cfg).expect("ok");
656        let frame = make_frame(200, 100, 42);
657        for (region, tile_data) in enc.split_frame(&frame) {
658            let expected = (region.width * region.height) as usize;
659            assert_eq!(
660                tile_data.len(),
661                expected,
662                "tile ({},{}) data length mismatch",
663                region.col,
664                region.row
665            );
666        }
667    }
668
669    // ── encode_tiles_parallel (free function) ─────────────────────────────────
670
671    #[test]
672    fn test_encode_tiles_parallel_output_count() {
673        let cfg = default_config_2x2();
674        let frame = make_frame(640, 480, 128);
675        let result = encode_tiles_parallel(&frame, 640, 480, &cfg).expect("ok");
676        assert_eq!(result.len(), 4, "must return one Vec<u8> per tile");
677    }
678
679    #[test]
680    fn test_encode_tiles_parallel_output_sizes() {
681        let cfg = default_config_2x2();
682        let frame = make_frame(640, 480, 0);
683        let tiles = encode_tiles_parallel(&frame, 640, 480, &cfg).expect("ok");
684        for tile in &tiles {
685            assert!(
686                tile.len() >= TILE_HEADER_SIZE,
687                "each tile must be at least {} bytes",
688                TILE_HEADER_SIZE
689            );
690        }
691    }
692
693    #[test]
694    fn test_encode_tiles_parallel_single_tile() {
695        let cfg = TileEncoderConfig {
696            tile_cols: 1,
697            tile_rows: 1,
698            threads: 0,
699            base_qp: 0,
700        };
701        let frame = make_frame(320, 240, 77);
702        let tiles = encode_tiles_parallel(&frame, 320, 240, &cfg).expect("ok");
703        assert_eq!(tiles.len(), 1);
704        // With qp=0, XOR mask is 0, so payload must equal original pixels.
705        let payload = &tiles[0][TILE_HEADER_SIZE..];
706        assert!(
707            payload.iter().all(|&b| b == 77),
708            "with qp=0 payload must equal original pixels"
709        );
710    }
711
712    #[test]
713    fn test_encode_tiles_parallel_content_header_magic() {
714        let cfg = default_config_2x2();
715        let frame = make_frame(64, 32, 0);
716        let tiles = encode_tiles_parallel(&frame, 64, 32, &cfg).expect("ok");
717        for tile in &tiles {
718            assert_eq!(
719                &tile[0..4],
720                &TILE_MAGIC,
721                "tile header must start with TILE_MAGIC"
722            );
723        }
724    }
725
726    #[test]
727    fn test_encode_tiles_parallel_header_width_height_encoded() {
728        let cfg = TileEncoderConfig {
729            tile_cols: 1,
730            tile_rows: 1,
731            threads: 0,
732            base_qp: 8,
733        };
734        let frame = make_frame(128, 96, 0);
735        let tiles = encode_tiles_parallel(&frame, 128, 96, &cfg).expect("ok");
736        assert_eq!(tiles.len(), 1);
737        // Bytes 4..8 = width LE, bytes 8..12 = height LE
738        let w = u32::from_le_bytes(tiles[0][4..8].try_into().expect("slice"));
739        let h = u32::from_le_bytes(tiles[0][8..12].try_into().expect("slice"));
740        assert_eq!(w, 128);
741        assert_eq!(h, 96);
742    }
743
744    #[test]
745    fn test_encode_tiles_parallel_zero_cols_errors() {
746        let cfg = TileEncoderConfig {
747            tile_cols: 0,
748            tile_rows: 2,
749            threads: 0,
750            base_qp: 32,
751        };
752        let frame = make_frame(640, 480, 0);
753        let result = encode_tiles_parallel(&frame, 640, 480, &cfg);
754        assert!(result.is_err());
755    }
756
757    // ── assemble_encoded ──────────────────────────────────────────────────────
758
759    #[test]
760    fn test_assemble_encoded_non_empty() {
761        let cfg = default_config_2x2();
762        let enc = ParallelTileEncoder::new(640, 480, cfg).expect("ok");
763        let frame = make_frame(640, 480, 55);
764        let encoded_tiles = enc.encode_frame_parallel(&frame).expect("ok");
765        let assembled = enc.assemble_encoded(&encoded_tiles);
766        assert!(!assembled.is_empty(), "assembled output must not be empty");
767    }
768
769    #[test]
770    fn test_assemble_encoded_single_tile_no_size_prefix() {
771        // A single tile must NOT have a size prefix (it is the last tile).
772        let cfg = TileEncoderConfig {
773            tile_cols: 1,
774            tile_rows: 1,
775            threads: 0,
776            base_qp: 0,
777        };
778        let enc = ParallelTileEncoder::new(64, 32, cfg).expect("ok");
779        let frame = make_frame(64, 32, 10);
780        let encoded_tiles = enc.encode_frame_parallel(&frame).expect("ok");
781        assert_eq!(encoded_tiles.len(), 1);
782
783        let assembled = enc.assemble_encoded(&encoded_tiles);
784        // Without prefix the assembled data equals the single tile data.
785        assert_eq!(assembled.len(), encoded_tiles[0].data.len());
786    }
787
788    // ── TileRegionInfo ────────────────────────────────────────────────────────
789
790    #[test]
791    fn test_tile_region_info_fields() {
792        let region = TileRegionInfo {
793            col: 1,
794            row: 2,
795            x: 320,
796            y: 240,
797            width: 320,
798            height: 240,
799        };
800        assert_eq!(region.raster_index(4), 2 * 4 + 1);
801        assert_eq!(region.area(), 320 * 240);
802    }
803}