From 2d80a218c52cd1c30ed06170d53985576cf87350 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 20:06:07 +0900 Subject: [PATCH] feat(renderer): add Baseline JPEG decoder Self-contained Huffman/IDCT/MCU/YCbCr decoder. Supports SOF0, 4:4:4/4:2:2/4:2:0 subsampling, grayscale, restart markers. API matches parse_png pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_renderer/src/jpg.rs | 959 ++++++++++++++++++++++++++++++ crates/voltex_renderer/src/lib.rs | 2 + 2 files changed, 961 insertions(+) create mode 100644 crates/voltex_renderer/src/jpg.rs diff --git a/crates/voltex_renderer/src/jpg.rs b/crates/voltex_renderer/src/jpg.rs new file mode 100644 index 0000000..fd5adec --- /dev/null +++ b/crates/voltex_renderer/src/jpg.rs @@ -0,0 +1,959 @@ +/// Baseline JPEG decoder. Supports SOF0 (sequential DCT, Huffman). +/// Returns RGBA pixel data like parse_png. +/// Supports grayscale (1-component) and YCbCr (3-component) with +/// chroma subsampling (4:4:4, 4:2:2, 4:2:0). + +pub fn parse_jpg(data: &[u8]) -> Result<(Vec, u32, u32), String> { + if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 { + return Err("Invalid JPEG: missing SOI marker".into()); + } + + let mut pos = 2; + let mut width: u16 = 0; + let mut height: u16 = 0; + let mut num_components: u8 = 0; + let mut components: Vec = Vec::new(); + let mut qt_tables: [[u16; 64]; 4] = [[0; 64]; 4]; + let mut dc_tables: [Option; 4] = [None, None, None, None]; + let mut ac_tables: [Option; 4] = [None, None, None, None]; + let mut found_sof = false; + let mut restart_interval: u16 = 0; + + while pos + 1 < data.len() { + if data[pos] != 0xFF { + return Err(format!("Expected marker at position {}", pos)); + } + // Skip padding 0xFF bytes + while pos + 1 < data.len() && data[pos + 1] == 0xFF { + pos += 1; + } + if pos + 1 >= data.len() { + return Err("Unexpected end of data".into()); + } + let marker = data[pos + 1]; + pos += 2; + + match marker { + 0xD8 => {} // SOI (already handled) + 0xD9 => break, // EOI + 0xDA => { + // SOS — Start of Scan + if !found_sof { + return Err("SOS before SOF".into()); + } + let (rgb, scan_end) = decode_scan( + data, pos, width, height, num_components, + &components, &qt_tables, &dc_tables, &ac_tables, + restart_interval, + )?; + let _ = scan_end; + // Convert RGB to RGBA + let w = width as u32; + let h = height as u32; + let mut rgba = Vec::with_capacity((w * h * 4) as usize); + for pixel in rgb.chunks_exact(3) { + rgba.push(pixel[0]); + rgba.push(pixel[1]); + rgba.push(pixel[2]); + rgba.push(255); + } + return Ok((rgba, w, h)); + } + 0xC0 => { + // SOF0 — Baseline DCT + let (sof, len) = parse_sof(data, pos)?; + width = sof.width; + height = sof.height; + num_components = sof.num_components; + components = sof.components; + found_sof = true; + pos += len; + } + 0xC4 => { + // DHT — Define Huffman Table + let len = parse_dht(data, pos, &mut dc_tables, &mut ac_tables)?; + pos += len; + } + 0xDB => { + // DQT — Define Quantization Table + let len = parse_dqt(data, pos, &mut qt_tables)?; + pos += len; + } + 0xDD => { + // DRI — Define Restart Interval + if pos + 4 > data.len() { + return Err("DRI too short".into()); + } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + restart_interval = + u16::from_be_bytes([data[pos + 2], data[pos + 3]]); + pos += seg_len; + } + 0xD0..=0xD7 => { + // RST markers — handled inside scan decoder + } + 0xE0..=0xEF | 0xFE => { + // APP0-APP15, COM — skip + if pos + 2 > data.len() { + return Err("Segment too short".into()); + } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += seg_len; + } + _ => { + // Unknown marker with length — skip + if pos + 2 > data.len() { + return Err(format!("Unknown marker 0x{:02X}", marker)); + } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += seg_len; + } + } + } + + Err("No image data found (missing SOS)".into()) +} + +// --------------------------------------------------------------------------- +// Data structures +// --------------------------------------------------------------------------- + +#[derive(Clone)] +struct JpegComponent { + #[allow(dead_code)] + id: u8, + h_sample: u8, + v_sample: u8, + qt_id: u8, + dc_table: u8, + ac_table: u8, +} + +struct SofData { + width: u16, + height: u16, + num_components: u8, + components: Vec, +} + +struct HuffTable { + symbols: Vec, + offsets: [u16; 17], + maxcode: [i32; 17], + mincode: [u16; 17], +} + +// --------------------------------------------------------------------------- +// Marker parsers +// --------------------------------------------------------------------------- + +fn parse_dqt(data: &[u8], pos: usize, qt_tables: &mut [[u16; 64]; 4]) -> Result { + if pos + 2 > data.len() { + return Err("DQT too short".into()); + } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + if pos + seg_len > data.len() { + return Err("DQT segment extends past data".into()); + } + + let mut off = pos + 2; + let seg_end = pos + seg_len; + while off < seg_end { + let pq_tq = data[off]; + let precision = pq_tq >> 4; + let table_id = (pq_tq & 0x0F) as usize; + off += 1; + if table_id >= 4 { + return Err(format!("DQT table id {} out of range", table_id)); + } + + if precision == 0 { + if off + 64 > seg_end { + return Err("DQT 8-bit data too short".into()); + } + for i in 0..64 { + qt_tables[table_id][i] = data[off + i] as u16; + } + off += 64; + } else { + if off + 128 > seg_end { + return Err("DQT 16-bit data too short".into()); + } + for i in 0..64 { + qt_tables[table_id][i] = + u16::from_be_bytes([data[off + i * 2], data[off + i * 2 + 1]]); + } + off += 128; + } + } + Ok(seg_len) +} + +fn parse_sof(data: &[u8], pos: usize) -> Result<(SofData, usize), String> { + if pos + 2 > data.len() { + return Err("SOF too short".into()); + } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + if pos + seg_len > data.len() { + return Err("SOF segment extends past data".into()); + } + + let precision = data[pos + 2]; + if precision != 8 { + return Err(format!("Unsupported sample precision: {}", precision)); + } + + let height = u16::from_be_bytes([data[pos + 3], data[pos + 4]]); + let width = u16::from_be_bytes([data[pos + 5], data[pos + 6]]); + let num_comp = data[pos + 7]; + + let mut components = Vec::new(); + let mut off = pos + 8; + for _ in 0..num_comp { + if off + 3 > pos + seg_len { + return Err("SOF component data too short".into()); + } + let id = data[off]; + let sampling = data[off + 1]; + let h_sample = sampling >> 4; + let v_sample = sampling & 0x0F; + let qt_id = data[off + 2]; + components.push(JpegComponent { + id, + h_sample, + v_sample, + qt_id, + dc_table: 0, + ac_table: 0, + }); + off += 3; + } + + Ok(( + SofData { + width, + height, + num_components: num_comp, + components, + }, + seg_len, + )) +} + +fn parse_dht( + data: &[u8], + pos: usize, + dc_tables: &mut [Option; 4], + ac_tables: &mut [Option; 4], +) -> Result { + if pos + 2 > data.len() { + return Err("DHT too short".into()); + } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + if pos + seg_len > data.len() { + return Err("DHT segment extends past data".into()); + } + + let mut off = pos + 2; + let seg_end = pos + seg_len; + while off < seg_end { + let tc_th = data[off]; + let table_class = tc_th >> 4; + let table_id = (tc_th & 0x0F) as usize; + off += 1; + if table_id >= 4 { + return Err(format!("DHT table id {} out of range", table_id)); + } + + if off + 16 > seg_end { + return Err("DHT counts too short".into()); + } + let mut counts = [0u8; 16]; + counts.copy_from_slice(&data[off..off + 16]); + off += 16; + + let total_symbols: usize = counts.iter().map(|&c| c as usize).sum(); + if off + total_symbols > seg_end { + return Err("DHT symbols too short".into()); + } + + let symbols: Vec = data[off..off + total_symbols].to_vec(); + off += total_symbols; + + // Build lookup tables + let mut offsets = [0u16; 17]; + let mut maxcode = [-1i32; 17]; + let mut mincode = [0u16; 17]; + let mut code: u16 = 0; + let mut sym_offset: u16 = 0; + + for i in 0..16 { + offsets[i] = sym_offset; + if counts[i] > 0 { + mincode[i] = code; + maxcode[i] = (code + counts[i] as u16 - 1) as i32; + sym_offset += counts[i] as u16; + } + code = (code + counts[i] as u16) << 1; + } + offsets[16] = sym_offset; + + let table = HuffTable { + symbols, + offsets, + maxcode, + mincode, + }; + if table_class == 0 { + dc_tables[table_id] = Some(table); + } else { + ac_tables[table_id] = Some(table); + } + } + Ok(seg_len) +} + +// --------------------------------------------------------------------------- +// BitReader — MSB-first bit reading with JPEG byte stuffing +// --------------------------------------------------------------------------- + +struct BitReader<'a> { + data: &'a [u8], + pos: usize, + bit_pos: u8, + current: u8, +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8], start: usize) -> Self { + Self { + data, + pos: start, + bit_pos: 0, + current: 0, + } + } + + fn read_byte(&mut self) -> Result { + if self.pos >= self.data.len() { + return Err("Unexpected end of scan data".into()); + } + let byte = self.data[self.pos]; + self.pos += 1; + if byte == 0xFF { + if self.pos >= self.data.len() { + return Err("Unexpected end after 0xFF".into()); + } + let next = self.data[self.pos]; + if next == 0x00 { + self.pos += 1; // skip stuffed 0x00 + } else if (0xD0..=0xD7).contains(&next) { + // RST marker — skip marker byte and read next actual byte + self.pos += 1; + return self.read_byte(); + } else { + return Err("Marker found in scan data".into()); + } + } + Ok(byte) + } + + fn ensure_bits(&mut self) -> Result<(), String> { + if self.bit_pos == 0 { + self.current = self.read_byte()?; + self.bit_pos = 8; + } + Ok(()) + } + + fn read_bit(&mut self) -> Result { + self.ensure_bits()?; + self.bit_pos -= 1; + Ok((self.current >> self.bit_pos) & 1) + } + + fn read_bits(&mut self, count: u8) -> Result { + let mut val: u16 = 0; + for _ in 0..count { + val = (val << 1) | self.read_bit()? as u16; + } + Ok(val) + } + + fn decode_huffman(&mut self, table: &HuffTable) -> Result { + let mut code: u16 = 0; + for len in 0..16 { + code = (code << 1) | self.read_bit()? as u16; + if table.maxcode[len] >= 0 && code as i32 <= table.maxcode[len] { + let idx = table.offsets[len] as usize + (code - table.mincode[len]) as usize; + return Ok(table.symbols[idx]); + } + } + Err("Invalid Huffman code".into()) + } + + /// Skip to next byte-aligned position (and handle RST markers) + fn align_to_byte(&mut self) { + self.bit_pos = 0; + self.current = 0; + } + + /// Find and skip RST marker in the byte stream + fn skip_to_rst_marker(&mut self) -> Result<(), String> { + // Align to byte boundary + self.align_to_byte(); + // Look for 0xFF 0xDn marker + loop { + if self.pos >= self.data.len() { + return Err("Unexpected end looking for RST marker".into()); + } + if self.data[self.pos] == 0xFF && self.pos + 1 < self.data.len() { + let next = self.data[self.pos + 1]; + if (0xD0..=0xD7).contains(&next) { + self.pos += 2; + return Ok(()); + } + } + self.pos += 1; + } + } + + fn scan_end_pos(&self) -> usize { + self.pos + } +} + +// --------------------------------------------------------------------------- +// IDCT +// --------------------------------------------------------------------------- + +/// Zig-zag order for 8x8 block +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, + 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +fn idct(coeffs: &[i32; 64]) -> [i32; 64] { + let mut workspace = [0.0f64; 64]; + + // Arrange from zigzag to row-major + let mut block = [0.0f64; 64]; + for i in 0..64 { + block[ZIGZAG[i]] = coeffs[i] as f64; + } + + // 1D IDCT on rows + for row in 0..8 { + let off = row * 8; + idct_1d(&mut block, off); + } + + // Transpose + for r in 0..8 { + for c in 0..8 { + workspace[c * 8 + r] = block[r * 8 + c]; + } + } + + // 1D IDCT on columns (now rows after transpose) + for row in 0..8 { + let off = row * 8; + idct_1d(&mut workspace, off); + } + + // Transpose back and round + let mut result = [0i32; 64]; + for r in 0..8 { + for c in 0..8 { + result[r * 8 + c] = workspace[c * 8 + r].round() as i32; + } + } + result +} + +fn idct_1d(data: &mut [f64], off: usize) { + use std::f64::consts::PI; + let mut tmp = [0.0f64; 8]; + for x in 0..8 { + let mut sum = 0.0; + for u in 0..8 { + let cu = if u == 0 { 1.0 / 2.0f64.sqrt() } else { 1.0 }; + sum += cu * data[off + u] * ((2.0 * x as f64 + 1.0) * u as f64 * PI / 16.0).cos(); + } + tmp[x] = sum / 2.0; + } + data[off..off + 8].copy_from_slice(&tmp); +} + +// --------------------------------------------------------------------------- +// Scan decoder +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn decode_scan( + data: &[u8], + pos: usize, + width: u16, + height: u16, + num_components: u8, + components: &[JpegComponent], + qt_tables: &[[u16; 64]; 4], + dc_tables: &[Option; 4], + ac_tables: &[Option; 4], + restart_interval: u16, +) -> Result<(Vec, usize), String> { + // Parse SOS header + if pos + 2 > data.len() { + return Err("SOS too short".into()); + } + let seg_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + let ns = data[pos + 2] as usize; + + let mut scan_components = components.to_vec(); + let mut off = pos + 3; + for i in 0..ns { + let _cs = data[off]; // component selector + let td_ta = data[off + 1]; + scan_components[i].dc_table = td_ta >> 4; + scan_components[i].ac_table = td_ta & 0x0F; + off += 2; + } + let scan_data_start = pos + seg_len; + + let mut reader = BitReader::new(data, scan_data_start); + + // Calculate MCU dimensions + let max_h = scan_components + .iter() + .take(num_components as usize) + .map(|c| c.h_sample) + .max() + .unwrap_or(1); + let max_v = scan_components + .iter() + .take(num_components as usize) + .map(|c| c.v_sample) + .max() + .unwrap_or(1); + let mcu_width = max_h as u16 * 8; + let mcu_height = max_v as u16 * 8; + let mcus_x = (width + mcu_width - 1) / mcu_width; + let mcus_y = (height + mcu_height - 1) / mcu_height; + + let mut dc_pred = vec![0i32; num_components as usize]; + let mut rgb = vec![0u8; (width as usize) * (height as usize) * 3]; + + let mut mcu_count: u16 = 0; + + for mcu_row in 0..mcus_y { + for mcu_col in 0..mcus_x { + // Handle restart interval + if restart_interval > 0 && mcu_count > 0 && mcu_count % restart_interval == 0 { + // Reset DC predictors + for dc in dc_pred.iter_mut() { + *dc = 0; + } + reader.skip_to_rst_marker()?; + } + + let mut mcu_blocks: Vec> = Vec::new(); + + for (ci, comp) in scan_components + .iter() + .enumerate() + .take(num_components as usize) + { + let blocks_h = comp.h_sample as usize; + let blocks_v = comp.v_sample as usize; + let mut blocks = Vec::with_capacity(blocks_h * blocks_v); + + for _ in 0..(blocks_h * blocks_v) { + let block = decode_block( + &mut reader, + dc_tables[comp.dc_table as usize] + .as_ref() + .ok_or("Missing DC Huffman table")?, + ac_tables[comp.ac_table as usize] + .as_ref() + .ok_or("Missing AC Huffman table")?, + &mut dc_pred[ci], + &qt_tables[comp.qt_id as usize], + )?; + blocks.push(block); + } + mcu_blocks.push(blocks); + } + + assemble_mcu( + &mcu_blocks, + &scan_components, + num_components, + max_h, + max_v, + mcu_col as usize, + mcu_row as usize, + width as usize, + height as usize, + &mut rgb, + ); + + mcu_count = mcu_count.wrapping_add(1); + } + } + + Ok((rgb, reader.scan_end_pos())) +} + +fn decode_block( + reader: &mut BitReader, + dc_table: &HuffTable, + ac_table: &HuffTable, + dc_pred: &mut i32, + qt: &[u16; 64], +) -> Result<[i32; 64], String> { + let mut coeffs = [0i32; 64]; + + // DC coefficient + let dc_len = reader.decode_huffman(dc_table)?; + let dc_val = if dc_len > 0 { + let bits = reader.read_bits(dc_len)? as i32; + if bits < (1 << (dc_len - 1)) { + bits - (1 << dc_len) + 1 + } else { + bits + } + } else { + 0 + }; + *dc_pred += dc_val; + coeffs[0] = *dc_pred * qt[0] as i32; + + // AC coefficients + let mut k = 1; + while k < 64 { + let rs = reader.decode_huffman(ac_table)?; + let run = (rs >> 4) as usize; + let size = (rs & 0x0F) as u8; + + if size == 0 { + if run == 0 { + break; + } // EOB + if run == 15 { + k += 16; + continue; + } // ZRL (16 zeros) + break; + } + + k += run; + if k >= 64 { + break; + } + + let bits = reader.read_bits(size)? as i32; + let val = if bits < (1 << (size - 1)) { + bits - (1 << size) + 1 + } else { + bits + }; + coeffs[k] = val * qt[k] as i32; + k += 1; + } + + Ok(idct(&coeffs)) +} + +// --------------------------------------------------------------------------- +// MCU assembly + color conversion +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn assemble_mcu( + mcu_blocks: &[Vec<[i32; 64]>], + components: &[JpegComponent], + num_components: u8, + max_h: u8, + max_v: u8, + mcu_col: usize, + mcu_row: usize, + img_width: usize, + img_height: usize, + rgb: &mut [u8], +) { + let mcu_px = mcu_col * max_h as usize * 8; + let mcu_py = mcu_row * max_v as usize * 8; + + for py in 0..(max_v as usize * 8) { + for px in 0..(max_h as usize * 8) { + let x = mcu_px + px; + let y = mcu_py + py; + if x >= img_width || y >= img_height { + continue; + } + + if num_components == 1 { + // Grayscale: IDCT output centered at 0, add 128 for level shift + let val = + sample_component(&mcu_blocks[0], &components[0], max_h, max_v, px, py) + 128; + let clamped = val.clamp(0, 255) as u8; + let offset = (y * img_width + x) * 3; + rgb[offset] = clamped; + rgb[offset + 1] = clamped; + rgb[offset + 2] = clamped; + } else { + // YCbCr -> RGB + // IDCT output centered at 0; Y needs +128 level shift, Cb/Cr centered at 0 (128 subtracted) + let yy = sample_component(&mcu_blocks[0], &components[0], max_h, max_v, px, py) + as f32 + + 128.0; + let cb = sample_component(&mcu_blocks[1], &components[1], max_h, max_v, px, py) + as f32; + let cr = sample_component(&mcu_blocks[2], &components[2], max_h, max_v, px, py) + as f32; + + let r = (yy + 1.402 * cr).round().clamp(0.0, 255.0) as u8; + let g = (yy - 0.344136 * cb - 0.714136 * cr).round().clamp(0.0, 255.0) as u8; + let b = (yy + 1.772 * cb).round().clamp(0.0, 255.0) as u8; + + let offset = (y * img_width + x) * 3; + rgb[offset] = r; + rgb[offset + 1] = g; + rgb[offset + 2] = b; + } + } + } +} + +fn sample_component( + blocks: &[[i32; 64]], + comp: &JpegComponent, + max_h: u8, + max_v: u8, + px: usize, + py: usize, +) -> i32 { + let scale_x = comp.h_sample as usize; + let scale_y = comp.v_sample as usize; + let cx = px * scale_x / (max_h as usize * 8); + let cy = py * scale_y / (max_v as usize * 8); + let bx = (px * scale_x / max_h as usize) % 8; + let by = (py * scale_y / max_v as usize) % 8; + let block_idx = cy * scale_x + cx; + if block_idx < blocks.len() { + blocks[block_idx][by * 8 + bx] + } else { + 0 + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_invalid_signature() { + let data = [0u8; 10]; + assert!(parse_jpg(&data).is_err()); + } + + #[test] + fn test_empty_data() { + assert!(parse_jpg(&[]).is_err()); + } + + #[test] + fn test_soi_only() { + let data = [0xFF, 0xD8]; + assert!(parse_jpg(&data).is_err()); + } + + #[test] + fn test_parse_dqt_8bit() { + let mut seg = Vec::new(); + seg.extend_from_slice(&67u16.to_be_bytes()); // length = 67 + seg.push(0x00); // precision=0 (8-bit), table_id=0 + for i in 0..64u8 { + seg.push(i + 1); + } + let mut qt_tables = [[0u16; 64]; 4]; + let len = parse_dqt(&seg, 0, &mut qt_tables).unwrap(); + assert_eq!(len, 67); + assert_eq!(qt_tables[0][0], 1); + assert_eq!(qt_tables[0][63], 64); + } + + #[test] + fn test_parse_dht() { + let mut seg = Vec::new(); + let mut body = Vec::new(); + body.push(0x00); // class=0 (DC), id=0 + // counts: 1 symbol at length 1, 0 for lengths 2-16 + body.push(1); + for _ in 1..16 { + body.push(0); + } + body.push(0x05); // the symbol + + seg.extend_from_slice(&((body.len() + 2) as u16).to_be_bytes()); + seg.extend_from_slice(&body); + + let mut dc_tables: [Option; 4] = [None, None, None, None]; + let mut ac_tables: [Option; 4] = [None, None, None, None]; + let len = parse_dht(&seg, 0, &mut dc_tables, &mut ac_tables).unwrap(); + assert_eq!(len, seg.len()); + assert!(dc_tables[0].is_some()); + let table = dc_tables[0].as_ref().unwrap(); + assert_eq!(table.symbols[0], 0x05); + } + + #[test] + fn test_bit_reader_basic() { + // 0xA5 = 10100101 + let data = [0xA5]; + let mut reader = BitReader::new(&data, 0); + assert_eq!(reader.read_bits(1).unwrap(), 1); + assert_eq!(reader.read_bits(1).unwrap(), 0); + assert_eq!(reader.read_bits(3).unwrap(), 0b100); + assert_eq!(reader.read_bits(3).unwrap(), 0b101); + } + + #[test] + fn test_bit_reader_byte_stuffing() { + // JPEG byte stuffing: 0xFF 0x00 -> single 0xFF byte + let data = [0xFF, 0x00, 0x80]; + let mut reader = BitReader::new(&data, 0); + let val = reader.read_bits(8).unwrap(); + assert_eq!(val, 0xFF); + let val2 = reader.read_bits(1).unwrap(); + assert_eq!(val2, 1); // 0x80 = 10000000 + } + + #[test] + fn test_idct_dc_only() { + let mut block = [0i32; 64]; + block[0] = 800; // after dequantization + let result = idct(&block); + let expected = 100; // 800/8 = 100 + for &v in &result { + assert!( + (v - expected).abs() <= 1, + "DC-only IDCT: expected ~{}, got {}", + expected, + v + ); + } + } + + #[test] + fn test_idct_known_values() { + let mut block = [0i32; 64]; + block[0] = 640; + block[1] = 100; + let result = idct(&block); + let avg: i32 = result.iter().sum::() / 64; + assert!((avg - 80).abs() <= 2); + } + + /// Build a minimal valid 8x8 Baseline JPEG (grayscale) for testing. + /// DC diff = 0 => Y = 0 => after +128 level shift => pixel = 128. + fn build_minimal_jpeg_8x8() -> Vec { + let mut out = Vec::new(); + + // SOI + out.extend_from_slice(&[0xFF, 0xD8]); + + // DQT — all-ones quantization table (id=0) + out.extend_from_slice(&[0xFF, 0xDB]); + let mut dqt = Vec::new(); + dqt.extend_from_slice(&0x0043u16.to_be_bytes()); // length = 67 + dqt.push(0x00); // 8-bit, table 0 + for _ in 0..64 { + dqt.push(1); + } + out.extend_from_slice(&dqt); + + // SOF0 — 8x8, 1 component (grayscale) + out.extend_from_slice(&[0xFF, 0xC0]); + out.extend_from_slice(&0x000Bu16.to_be_bytes()); // length = 11 + out.push(8); // precision + out.extend_from_slice(&8u16.to_be_bytes()); // height + out.extend_from_slice(&8u16.to_be_bytes()); // width + out.push(1); // 1 component + out.push(1); // component ID + out.push(0x11); // h_sample=1, v_sample=1 + out.push(0); // qt table 0 + + // DHT — DC table (class=0, id=0): 1 symbol at length 1, symbol = 0x00 + out.extend_from_slice(&[0xFF, 0xC4]); + let mut dht_body = Vec::new(); + dht_body.push(0x00); // DC, id=0 + dht_body.push(1); // 1 symbol at length 1 + for _ in 1..16 { + dht_body.push(0); + } + dht_body.push(0x00); // symbol: category 0 (DC diff = 0) + let dht_len = (dht_body.len() + 2) as u16; + out.extend_from_slice(&dht_len.to_be_bytes()); + out.extend_from_slice(&dht_body); + + // DHT — AC table (class=1, id=0): 1 symbol at length 1, symbol = 0x00 (EOB) + out.extend_from_slice(&[0xFF, 0xC4]); + let mut dht_ac = Vec::new(); + dht_ac.push(0x10); // AC, id=0 + dht_ac.push(1); // 1 symbol at length 1 + for _ in 1..16 { + dht_ac.push(0); + } + dht_ac.push(0x00); // symbol: 0x00 = EOB + let dht_ac_len = (dht_ac.len() + 2) as u16; + out.extend_from_slice(&dht_ac_len.to_be_bytes()); + out.extend_from_slice(&dht_ac); + + // SOS + out.extend_from_slice(&[0xFF, 0xDA]); + out.extend_from_slice(&0x0008u16.to_be_bytes()); // length=8 + out.push(1); // 1 component + out.push(1); // component id=1 + out.push(0x00); // DC table 0, AC table 0 + out.push(0); // Ss + out.push(63); // Se + out.push(0); // Ah=0, Al=0 + + // Scan data: DC=0 (code=0, 1 bit), AC=EOB (code=0, 1 bit) + // Bits: 0 (DC diff=0) + 0 (EOB) = 0b00 -> padded to byte: 0x00 + out.push(0x00); + out.push(0x00); + + // EOI + out.extend_from_slice(&[0xFF, 0xD9]); + + out + } + + #[test] + fn test_grayscale_flat() { + let jpg_data = build_minimal_jpeg_8x8(); + let (rgba, w, h) = parse_jpg(&jpg_data).unwrap(); + assert_eq!(w, 8); + assert_eq!(h, 8); + assert_eq!(rgba.len(), 8 * 8 * 4); + // Grayscale mid-gray: all pixels should be ~128 + for i in (0..rgba.len()).step_by(4) { + assert_eq!(rgba[i], rgba[i + 1]); // R == G + assert_eq!(rgba[i + 1], rgba[i + 2]); // G == B + assert_eq!(rgba[i + 3], 255); // alpha + } + } + + #[test] + fn test_invalid_marker() { + let data = [0xFF, 0xD8, 0x00]; // SOI then garbage + assert!(parse_jpg(&data).is_err()); + } +} diff --git a/crates/voltex_renderer/src/lib.rs b/crates/voltex_renderer/src/lib.rs index cabcebf..308ea4e 100644 --- a/crates/voltex_renderer/src/lib.rs +++ b/crates/voltex_renderer/src/lib.rs @@ -1,5 +1,6 @@ pub mod deflate; pub mod png; +pub mod jpg; pub mod gpu; pub mod light; pub mod obj; @@ -54,3 +55,4 @@ pub use hdr::{HdrTarget, HDR_FORMAT}; pub use bloom::{BloomResources, BloomUniform, mip_sizes, BLOOM_MIP_COUNT}; pub use tonemap::{TonemapUniform, aces_tonemap}; pub use png::parse_png; +pub use jpg::parse_jpg;