Skip to main content

mcraw_tui/
file.rs

1use anyhow::{Context, Result};
2use serde::Deserialize;
3use std::fs;
4use std::io::{Read, Seek, SeekFrom};
5use std::path::Path;
6
7#[derive(Debug, Deserialize)]
8struct MotionJsonMetadata {
9    #[serde(rename = "sensorArrangment", default)]
10    sensor_arrangement: Option<String>,
11    #[serde(rename = "sensorOrientation", default)]
12    sensor_orientation: Option<i64>,
13    #[serde(rename = "forwardMatrix1", default)]
14    forward_matrix1: Option<Vec<f64>>,
15    #[serde(rename = "forwardMatrix2", default)]
16    forward_matrix2: Option<Vec<f64>>,
17    #[serde(rename = "colorMatrix1", default)]
18    color_matrix1: Option<Vec<f64>>,
19    #[serde(rename = "colorMatrix2", default)]
20    color_matrix2: Option<Vec<f64>>,
21    #[serde(rename = "calibrationMatrix1", default)]
22    calibration_matrix1: Option<Vec<f64>>,
23    #[serde(rename = "calibrationMatrix2", default)]
24    calibration_matrix2: Option<Vec<f64>>,
25    #[serde(rename = "whiteLevel", default)]
26    white_level: Option<f64>,
27    #[serde(rename = "blackLevel", default)]
28    black_level: Option<Vec<f64>>,
29    #[serde(rename = "baselineExposure", default)]
30    baseline_exposure: Option<f64>,
31    #[serde(rename = "apertures", default)]
32    apertures: Option<Vec<f64>>,
33    #[serde(rename = "focalLengths", default)]
34    focal_lengths: Option<Vec<f64>>,
35    #[serde(rename = "uniqueCameraModel", default)]
36    unique_camera_model: Option<String>,
37    #[serde(rename = "numSegments", default)]
38    num_segments: Option<i64>,
39    #[serde(rename = "extraData", default)]
40    extra_data: Option<ExtraData>,
41    #[serde(rename = "deviceSpecificProfile", default)]
42    device_specific_profile: Option<DeviceProfile>,
43    #[serde(rename = "colorIlluminant1", default)]
44    color_illuminant1: Option<String>,
45    #[serde(rename = "colorIlluminant2", default)]
46    color_illuminant2: Option<String>,
47    #[serde(rename = "lensShadingMap", default)]
48    lens_shading_map: Option<Vec<Vec<f64>>>,
49    #[serde(rename = "lensShadingMapWidth", default)]
50    lens_shading_map_width: Option<i64>,
51    #[serde(rename = "lensShadingMapHeight", default)]
52    lens_shading_map_height: Option<i64>,
53}
54
55#[derive(Debug, Deserialize)]
56struct ExtraData {
57    #[serde(rename = "recordingType", default)]
58    recording_type: Option<String>,
59    #[serde(rename = "audioSampleRate", default)]
60    audio_sample_rate: Option<i64>,
61    #[serde(rename = "audioChannels", default)]
62    audio_channels: Option<i64>,
63    #[serde(rename = "useAccurateTimestamp", default)]
64    use_accurate_timestamp: Option<bool>,
65    #[serde(rename = "metadata", default)]
66    metadata: Option<BuildMetadata>,
67}
68
69#[derive(Debug, Deserialize)]
70struct BuildMetadata {
71    #[serde(rename = "build.model", default)]
72    build_model: Option<String>,
73    #[serde(rename = "build.manufacturer", default)]
74    build_manufacturer: Option<String>,
75    #[serde(rename = "version.major", default)]
76    version_major: Option<String>,
77    #[serde(rename = "version.build", default)]
78    version_build: Option<String>,
79}
80
81#[derive(Debug, Deserialize)]
82struct DeviceProfile {
83    #[serde(rename = "cameraId", default)]
84    camera_id: Option<String>,
85    #[serde(rename = "deviceModel", default)]
86    device_model: Option<String>,
87}
88
89const INDEX_MAGIC: u32 = 0x8A905612;
90
91/// Bayer filter pattern IDs as defined in the MCRAW spec (Appendix A)
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum BayerPattern {
94    RGGB,
95    GRBG,
96    GBRG,
97    BGGR,
98    QuadBayerRGGB,
99    QuadBayerGRBG,
100    QuadBayerGBRG,
101    QuadBayerBGGR,
102}
103
104impl BayerPattern {
105    pub fn from_u8(value: u8) -> Self {
106        match value {
107            0 => BayerPattern::RGGB,
108            1 => BayerPattern::GRBG,
109            2 => BayerPattern::GBRG,
110            3 => BayerPattern::BGGR,
111            4 => BayerPattern::QuadBayerRGGB,
112            5 => BayerPattern::QuadBayerGRBG,
113            6 => BayerPattern::QuadBayerGBRG,
114            7 => BayerPattern::QuadBayerBGGR,
115            _ => BayerPattern::RGGB,
116        }
117    }
118
119    pub fn to_u8(&self) -> u8 {
120        match self {
121            BayerPattern::RGGB => 0,
122            BayerPattern::GRBG => 1,
123            BayerPattern::GBRG => 2,
124            BayerPattern::BGGR => 3,
125            BayerPattern::QuadBayerRGGB => 4,
126            BayerPattern::QuadBayerGRBG => 5,
127            BayerPattern::QuadBayerGBRG => 6,
128            BayerPattern::QuadBayerBGGR => 7,
129        }
130    }
131
132    pub fn name(&self) -> &'static str {
133        match self {
134            BayerPattern::RGGB => "RGGB",
135            BayerPattern::GRBG => "GRBG",
136            BayerPattern::GBRG => "GBRG",
137            BayerPattern::BGGR => "BGGR",
138            BayerPattern::QuadBayerRGGB => "QuadBayer RGGB",
139            BayerPattern::QuadBayerGRBG => "QuadBayer GRBG",
140            BayerPattern::QuadBayerGBRG => "QuadBayer GBRG",
141            BayerPattern::QuadBayerBGGR => "QuadBayer BGGR",
142        }
143    }
144
145    /// Dcraw-style `filters` encoding for WGSL shaders.
146    /// Maps Bayer pattern to a u32 bitfield R=0, G1=1, G2=3(!), B=2.
147    pub fn to_dcraw_filters(&self) -> u32 {
148        match self {
149            BayerPattern::RGGB => 0x94949494,
150            BayerPattern::BGGR => 0x16161616,
151            BayerPattern::GRBG => 0x61616161,
152            BayerPattern::GBRG => 0x49494949,
153            _ => 0x94949494, // QuadBayer patterns fall back to RGGB
154        }
155    }
156}
157
158/// Camera metadata extracted from the MCRAW header block
159#[derive(Debug, Clone)]
160pub struct CameraMetadata {
161    pub sensor_make: Option<String>,
162    pub sensor_model: Option<String>,
163    pub camera_model: Option<String>,
164    pub lens_model: Option<String>,
165    pub focal_length: Option<f64>,
166    pub aperture: Option<f64>,
167    pub iso: Option<u32>,
168    pub exposure_time: Option<f64>,
169    pub white_balance: Option<f64>,
170    pub capture_date: Option<String>,
171    pub color_matrix: Option<[f64; 9]>,
172    pub color_matrix2: Option<[f64; 9]>,
173    pub forward_matrix1: Option<[f64; 9]>,
174    pub forward_matrix2: Option<[f64; 9]>,
175    pub calibration_matrix1: Option<[f64; 9]>,
176    pub calibration_matrix2: Option<[f64; 9]>,
177    pub calibration_illuminant1: Option<i32>,
178    pub calibration_illuminant2: Option<i32>,
179    pub calibration_illuminant: Option<String>,
180    pub wb_multipliers: Option<[f32; 3]>,
181}
182
183impl Default for CameraMetadata {
184    fn default() -> Self {
185        CameraMetadata {
186            sensor_make: None,
187            sensor_model: None,
188            camera_model: None,
189            lens_model: None,
190            focal_length: None,
191            aperture: None,
192            iso: None,
193            exposure_time: None,
194            white_balance: None,
195            capture_date: None,
196            color_matrix: None,
197            color_matrix2: None,
198            forward_matrix1: None,
199            forward_matrix2: None,
200            calibration_matrix1: None,
201            calibration_matrix2: None,
202            calibration_illuminant1: None,
203            calibration_illuminant2: None,
204            calibration_illuminant: None,
205            wb_multipliers: None,
206        }
207    }
208}
209
210/// Complete parsed information from an MCRAW file header
211#[derive(Debug, Clone)]
212pub struct McrawFileInfo {
213    pub path: String,
214    pub size: u64,
215    pub format_version: u32,
216    pub frame_count: u32,
217    pub width: u16,
218    pub height: u16,
219    pub fps: f64,
220    pub has_audio: bool,
221    pub audio_sample_rate: u32,
222    pub audio_channels: u16,
223    pub bit_depth: u16,
224    pub bayer_pattern: BayerPattern,
225    pub camera_metadata: CameraMetadata,
226    pub frame_offsets: Vec<u64>,
227    pub audio_offset: Option<u64>,
228    pub audio_length: Option<u64>,
229    pub sensor_width: u16,
230    pub sensor_height: u16,
231    pub active_offset_x: u16,
232    pub active_offset_y: u16,
233    pub active_width: u16,
234    pub active_height: u16,
235    pub white_level: f64,
236    pub black_level: f64,
237    pub black_level_per_channel: [f64; 4],
238    pub black_level_count: i32,
239    pub lens_shading_map: Option<crate::decoder::LensShadingMap>,
240    pub dynamic_black_level: Option<[f32; 4]>,
241    pub dynamic_white_level: Option<f32>,
242    /// First frame timestamp from BufferIndex (None if not available without decoder).
243    pub first_timestamp: Option<i64>,
244}
245
246/// Data extracted from frame 0's JSON metadata header via bounded file read.
247struct FirstFrameMeta {
248    width: u16,
249    height: u16,
250    /// White balance gains [R, G, B] computed from asShotNeutral: G/R, 1.0, G/B.
251    wb_gains: Option<[f32; 3]>,
252}
253
254/// Read width, height and white balance gains from frame 0's JSON metadata.
255fn read_first_frame_meta(file: &mut fs::File, frame0_offset: u64) -> Option<FirstFrameMeta> {
256    file.seek(SeekFrom::Start(frame0_offset)).ok()?;
257    let mut hdr = [0u8; 8];
258    file.read_exact(&mut hdr).ok()?;
259    let buf_type = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
260    let buf_size = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]);
261    if buf_type != 2 {
262        return None;
263    }
264    file.seek(SeekFrom::Current(buf_size as i64)).ok()?;
265    file.read_exact(&mut hdr).ok()?;
266    let meta_type = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
267    let meta_size = u32::from_le_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]);
268    if meta_type != 3 {
269        return None;
270    }
271    let mut json_buf = vec![0u8; meta_size as usize];
272    file.read_exact(&mut json_buf).ok()?;
273    let json: serde_json::Value = serde_json::from_slice(&json_buf).ok()?;
274    let w = json.get("width")?.as_u64()? as u16;
275    let h = json.get("height")?.as_u64()? as u16;
276    if w == 0 || h == 0 {
277        return None;
278    }
279    let wb_gains = json.get("asShotNeutral").and_then(|v| v.as_array()).and_then(|arr| {
280        if arr.len() >= 3 {
281            let r = arr[0].as_f64()?;
282            let g = arr[1].as_f64()?;
283            let b = arr[2].as_f64()?;
284            if r > 1e-6 && g > 1e-6 && b > 1e-6 {
285                Some([(g / r) as f32, 1.0, (g / b) as f32])
286            } else {
287                None
288            }
289        } else {
290            None
291        }
292    });
293    tracing::debug!("read_first_frame_meta: w={} h={} wb_gains={:?}", w, h, wb_gains);
294    Some(FirstFrameMeta { width: w, height: h, wb_gains })
295}
296
297impl McrawFileInfo {
298    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
299        let path = path.as_ref();
300        tracing::debug!("McrawFileInfo::from_path: {:?}", path);
301        let file_meta = fs::metadata(&path)
302            .with_context(|| format!("Failed to read metadata for {:?}", path))?;
303        let file_size = file_meta.len();
304
305        let mut file = std::fs::File::open(path)
306            .with_context(|| format!("Failed to open {:?}", path))?;
307
308        let mut magic_buf = [0u8; 16];
309        file.read_exact(&mut magic_buf)
310            .with_context(|| format!("Failed to read header from {:?}", path))?;
311
312        let info = if magic_buf.starts_with(b"MOTION ") {
313            let json_len = u32::from_le_bytes([
314                magic_buf[12], magic_buf[13], magic_buf[14], magic_buf[15],
315            ]) as usize;
316
317            let mut json_buf = vec![0u8; json_len];
318            file.read_exact(&mut json_buf)
319                .with_context(|| format!("Failed to read MOTION JSON block from {:?}", path))?;
320
321            let mut data = Vec::with_capacity(16 + json_len);
322            data.extend_from_slice(&magic_buf);
323            data.extend_from_slice(&json_buf);
324
325            let mut info = parse_motion_header(&data, path)?;
326
327            // Read BufferIndex from end-24: Item(8) + BufferIndex(16: magic, num_offsets, index_data_offset)
328            if file_size >= 24 {
329                let mut end_buf = [0u8; 24];
330                file.seek(SeekFrom::End(-24))
331                    .with_context(|| format!("Failed to seek to BufferIndex in {:?}", path))?;
332                file.read_exact(&mut end_buf)
333                    .with_context(|| format!("Failed to read BufferIndex from {:?}", path))?;
334
335                let idx_magic = u32::from_le_bytes([end_buf[8], end_buf[9], end_buf[10], end_buf[11]]);
336                if idx_magic == INDEX_MAGIC {
337                    let num_offsets = u32::from_le_bytes([end_buf[12], end_buf[13], end_buf[14], end_buf[15]]);
338                    let idx_data_offset = i64::from_le_bytes([
339                        end_buf[16], end_buf[17], end_buf[18], end_buf[19],
340                        end_buf[20], end_buf[21], end_buf[22], end_buf[23],
341                    ]) as u64;
342
343                    info.frame_count = num_offsets;
344
345                    if num_offsets > 0 && idx_data_offset + (num_offsets as u64 * 16) <= file_size {
346                        let mut offset_buf = vec![0u8; num_offsets as usize * 16];
347                        file.seek(SeekFrom::Start(idx_data_offset))
348                            .with_context(|| format!("Failed to seek to offset data in {:?}", path))?;
349                        file.read_exact(&mut offset_buf)
350                            .with_context(|| format!("Failed to read offset data from {:?}", path))?;
351
352                        let mut first_frame_offset: u64 = 0;
353                        let mut timestamps = Vec::with_capacity(num_offsets as usize);
354                        for i in 0..num_offsets as usize {
355                            let off = i64::from_le_bytes([
356                                offset_buf[i*16], offset_buf[i*16+1], offset_buf[i*16+2], offset_buf[i*16+3],
357                                offset_buf[i*16+4], offset_buf[i*16+5], offset_buf[i*16+6], offset_buf[i*16+7],
358                            ]);
359                            let ts = i64::from_le_bytes([
360                                offset_buf[i*16+8], offset_buf[i*16+9], offset_buf[i*16+10], offset_buf[i*16+11],
361                                offset_buf[i*16+12], offset_buf[i*16+13], offset_buf[i*16+14], offset_buf[i*16+15],
362                            ]);
363                            if i == 0 { first_frame_offset = off as u64; }
364                            timestamps.push(ts);
365                        }
366
367                        // Sort timestamps to compute fps and first_timestamp
368                        timestamps.sort();
369                        info.first_timestamp = Some(timestamps[0]);
370                        if num_offsets >= 2 {
371                            let duration_ns = timestamps[num_offsets as usize - 1] - timestamps[0];
372                            if duration_ns > 0 {
373                                info.fps = (num_offsets as f64 - 1.0) / (duration_ns as f64 / 1_000_000_000.0);
374                            }
375                        }
376
377                        // Read width/height + wb gains from frame 0's JSON metadata (bounded read, 1-2KB)
378                        if first_frame_offset > 0 {
379                            if let Some(meta) = read_first_frame_meta(&mut file, first_frame_offset) {
380                                info.width = meta.width;
381                                info.height = meta.height;
382                                if let Some(wb) = meta.wb_gains {
383                                    info.camera_metadata.wb_multipliers = Some(wb);
384                                }
385                            } else {
386                                tracing::debug!("from_path: read_first_frame_meta returned None for offset={}", first_frame_offset);
387                            }
388                        }
389                    }
390                }
391            }
392
393            info
394        } else if &magic_buf[..5] == b"MCRAW" {
395            let mut rest_header = [0u8; 20];
396            file.read_exact(&mut rest_header)
397                .with_context(|| format!("Failed to read legacy header from {:?}", path))?;
398
399            let mut data = Vec::with_capacity(36);
400            data.extend_from_slice(&magic_buf);
401            data.extend_from_slice(&rest_header);
402
403            if file_size > 36 {
404                let mut block_len_buf = [0u8; 4];
405                file.read_exact(&mut block_len_buf)
406                    .with_context(|| format!("Failed to read TLV block length from {:?}", path))?;
407                let block_length = u32::from_be_bytes(block_len_buf) as usize;
408
409                let mut tlv_buf = vec![0u8; block_length];
410                file.read_exact(&mut tlv_buf)
411                    .with_context(|| format!("Failed to read TLV block from {:?}", path))?;
412
413                data.extend_from_slice(&block_len_buf);
414                data.extend_from_slice(&tlv_buf);
415            }
416
417            parse_header(&data, path)?
418        } else {
419            anyhow::bail!(
420                "Invalid MCRAW magic header in {:?}: expected 'MCRAW' or 'MOTION ', got {:?}",
421                path,
422                &magic_buf[..7]
423            );
424        };
425
426        Ok(McrawFileInfo {
427            path: path.to_string_lossy().into_owned(),
428            size: file_size,
429            ..info
430        })
431    }
432
433    /// Skip Decoder creation when all essential metadata is already populated.
434    pub fn is_metadata_complete(&self) -> bool {
435        self.width > 0 && self.height > 0 && self.frame_count > 0
436            && self.first_timestamp.is_some()
437            && self.camera_metadata.wb_multipliers.is_some()
438    }
439
440    pub fn enhance_from_decoder(&mut self, decoder: &crate::decoder::Decoder) {
441        // Container-level metadata (skip if already populated from JSON parse)
442        if self.camera_metadata.color_matrix.is_none() {
443            if let Ok(container_meta) = decoder.container_metadata() {
444                if container_meta.white_level > 0.0 {
445                    self.white_level = container_meta.white_level;
446                    tracing::debug!("white_level from container: {}", self.white_level);
447                }
448                if container_meta.black_level_count > 0 {
449                    self.black_level = container_meta.black_level[0];
450                    self.black_level_per_channel = container_meta.black_level;
451                    self.black_level_count = container_meta.black_level_count;
452                    tracing::debug!("black_level from container: {} ({} ch)", self.black_level, self.black_level_count);
453                }
454                self.lens_shading_map = container_meta.lens_shading_map.clone();
455
456                let as_f64 = |v: &[f32; 9]| -> [f64; 9] {
457                    let mut r = [0.0; 9];
458                    for (i, &x) in v.iter().enumerate() { r[i] = x as f64; }
459                    r
460                };
461
462                self.camera_metadata.color_matrix = Some(as_f64(&container_meta.color_matrix1));
463                let non_zero = |m: &[f32; 9]| m.iter().any(|&x| x != 0.0);
464
465                if non_zero(&container_meta.color_matrix2) {
466                    self.camera_metadata.color_matrix2 = Some(as_f64(&container_meta.color_matrix2));
467                }
468                if non_zero(&container_meta.forward_matrix1) {
469                    self.camera_metadata.forward_matrix1 = Some(as_f64(&container_meta.forward_matrix1));
470                }
471                if non_zero(&container_meta.forward_matrix2) {
472                    self.camera_metadata.forward_matrix2 = Some(as_f64(&container_meta.forward_matrix2));
473                }
474                if non_zero(&container_meta.calibration_matrix1) {
475                    self.camera_metadata.calibration_matrix1 = Some(as_f64(&container_meta.calibration_matrix1));
476                }
477                if non_zero(&container_meta.calibration_matrix2) {
478                    self.camera_metadata.calibration_matrix2 = Some(as_f64(&container_meta.calibration_matrix2));
479                }
480                if container_meta.has_calibration_illuminants {
481                    self.camera_metadata.calibration_illuminant1 = Some(container_meta.calibration_illuminant1);
482                    self.camera_metadata.calibration_illuminant2 = Some(container_meta.calibration_illuminant2);
483                    tracing::debug!("calibration_illuminants: illum1={}, illum2={}",
484                        container_meta.calibration_illuminant1, container_meta.calibration_illuminant2);
485                }
486            }
487        }
488        // Timestamps and first-frame data (always run, in-memory from mmap)
489        if let Ok(timestamps) = decoder.timestamps() {
490            if !timestamps.is_empty() {
491                self.frame_count = timestamps.len() as u32;
492                if timestamps.len() >= 2 {
493                    let duration_ns = timestamps[timestamps.len() - 1] - timestamps[0];
494                    if duration_ns > 0 {
495                        let duration_in_seconds = duration_ns as f64 / 1_000_000_000.0;
496                        self.fps = (self.frame_count.saturating_sub(1)) as f64 / duration_in_seconds;
497                    }
498                }
499                tracing::debug!("enhanced from timestamps: {} frames, {:.2} fps", self.frame_count, self.fps);
500            }
501            if let Ok(first_frame_meta) = decoder.load_frame_metadata(timestamps[0]) {
502                if self.width == 0 || self.height == 0 {
503                    self.width = first_frame_meta.width as u16;
504                    self.height = first_frame_meta.height as u16;
505                    tracing::debug!("enhanced dimensions: {}x{}", first_frame_meta.width, first_frame_meta.height);
506                }
507                let n = first_frame_meta.as_shot_neutral;
508                if self.camera_metadata.wb_multipliers.is_none()
509                    && n[0] > 1e-6 && n[1] > 1e-6 && n[2] > 1e-6
510                {
511                    let r_gain = n[1] / n[0];
512                    let b_gain = n[1] / n[2];
513                    self.camera_metadata.wb_multipliers = Some([r_gain, 1.0, b_gain]);
514                    tracing::debug!("wb_multipliers: R={:.3} G={:.3} B={:.3}", r_gain, 1.0, b_gain);
515                }
516                if first_frame_meta.dynamic_black_level.is_some() {
517                    self.dynamic_black_level = first_frame_meta.dynamic_black_level;
518                    tracing::debug!("dynamic_black_level from first frame: {:?}", self.dynamic_black_level);
519                }
520                if first_frame_meta.dynamic_white_level.is_some() {
521                    self.dynamic_white_level = first_frame_meta.dynamic_white_level;
522                    tracing::debug!("dynamic_white_level from first frame: {:?}", self.dynamic_white_level);
523                }
524                // Fallback: if we still don't have a lens shading map (e.g. it was
525                // not in the JSON header or the container-metadata block was
526                // skipped because color_matrix was already populated), read it
527                // from the first frame's per-frame metadata.
528                if self.lens_shading_map.is_none() {
529                    if let Some(ref lsm) = first_frame_meta.lens_shading_map {
530                        self.lens_shading_map = Some(crate::decoder::LensShadingMap {
531                            channels: lsm.channels.clone(),
532                            width: lsm.width,
533                            height: lsm.height,
534                        });
535                        tracing::info!("lens_shading_map from first frame: {}x{}", lsm.width, lsm.height);
536                    }
537                }
538            }
539        }
540    }
541
542    pub fn enhance_with_decoder(&mut self) {
543        if self.camera_metadata.color_matrix.is_some() {
544            tracing::debug!("enhance_with_decoder: metadata already populated, skipping decoder");
545            return;
546        }
547        let path = self.path.clone();
548        tracing::debug!("enhance_with_decoder: {}", path);
549        let decoder_result = crate::decoder::Decoder::new(&path);
550        let decoder = match decoder_result {
551            Ok(d) => d,
552            Err(e) => {
553                tracing::warn!("failed to open decoder for {}: {}", path, e);
554                return;
555            }
556        };
557        self.enhance_from_decoder(&decoder);
558    }
559
560    pub fn format_name(&self) -> &'static str {
561        match self.format_version {
562            1 => "MotionCam v1 (Legacy)",
563            2 => "MotionCam v2",
564            3 => "MotionCam v3",
565            _ => "Unknown format",
566        }
567    }
568
569    pub fn duration_seconds(&self) -> f64 {
570        self.frame_count as f64 / self.fps
571    }
572
573    pub fn resolution_label(&self) -> &'static str {
574        match (self.width, self.height) {
575            (1920, 1080) => "1080p",
576            (2560, 1440) => "1440p",
577            (3840, 2160) => "4K",
578            (4096, 2160) => "4K DCI",
579            _ => "Custom",
580        }
581    }
582}
583
584fn parse_motion_header(data: &[u8], path: &Path) -> Result<McrawFileInfo> {
585    if data.len() < 17 {
586        anyhow::bail!("File {:?} is too small for MOTION header", path);
587    }
588
589    let format_version = data[7] as u32;
590    tracing::debug!("parse_motion_header: version={} json_len={}", format_version, u32::from_le_bytes([data[12], data[13], data[14], data[15]]));
591    let json_len = u32::from_le_bytes([data[12], data[13], data[14], data[15]]) as usize;
592    let json_start = 16;
593    let json_end = json_start + json_len;
594
595    if json_end > data.len() {
596        anyhow::bail!("JSON metadata extends beyond file data");
597    }
598
599    let json_str = std::str::from_utf8(&data[json_start..json_end])
600        .with_context(|| "Invalid UTF-8 in MOTION JSON metadata")?;
601
602    let json: MotionJsonMetadata = serde_json::from_str(json_str)
603        .with_context(|| "Failed to parse MOTION JSON metadata")?;
604
605    let bayer_pattern = match json.sensor_arrangement.as_deref() {
606        Some("rggb") | Some("standard") => BayerPattern::RGGB,
607        Some("grbg") => BayerPattern::GRBG,
608        Some("gbrg") => BayerPattern::GBRG,
609        Some("bggr") => BayerPattern::BGGR,
610        _ => BayerPattern::RGGB,
611    };
612
613    let extra_data = json.extra_data;
614    let device_profile = json.device_specific_profile;
615
616    let build_model = extra_data
617        .as_ref()
618        .and_then(|e| e.metadata.as_ref())
619        .and_then(|m| m.build_model.clone());
620
621    let camera_model: Option<String> = device_profile.as_ref()
622        .and_then(|p| p.device_model.clone())
623        .filter(|s| !s.is_empty())
624        .or_else(|| json.unique_camera_model.filter(|s| !s.is_empty()))
625        .or_else(|| build_model.filter(|s| !s.is_empty()));
626
627    let sensor_make = extra_data
628        .as_ref()
629        .and_then(|e| e.metadata.as_ref())
630        .and_then(|m| m.build_manufacturer.clone())
631        .unwrap_or_default();
632
633    let aperture = json.apertures.and_then(|mut a| a.pop());
634    let focal_length = json.focal_lengths.and_then(|mut a| a.pop());
635    let audio_sample_rate = extra_data.as_ref()
636        .and_then(|e| e.audio_sample_rate)
637        .unwrap_or(0) as u32;
638    let audio_channels = extra_data.as_ref()
639        .and_then(|e| e.audio_channels)
640        .unwrap_or(0) as u16;
641    let has_audio = audio_channels > 0;
642
643    let color_matrix = json.color_matrix1.clone().or(json.forward_matrix1.clone())
644        .and_then(|m| {
645            if m.len() == 9 {
646                Some(m.try_into().ok()?)
647            } else {
648                None
649            }
650        });
651
652    let color_matrix2 = json.color_matrix2.clone().and_then(|m| {
653        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
654    });
655    let forward_matrix1 = json.forward_matrix1.clone().and_then(|m| {
656        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
657    });
658    let forward_matrix2 = json.forward_matrix2.clone().and_then(|m| {
659        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
660    });
661    let calibration_matrix1 = json.calibration_matrix1.clone().and_then(|m| {
662        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
663    });
664    let calibration_matrix2 = json.calibration_matrix2.clone().and_then(|m| {
665        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
666    });
667
668    let bit_depth = json.white_level
669        .map(detect_bit_depth_from_white_level)
670        .unwrap_or(12);
671
672    let frame_count: u32 = 0;
673    let width: u16 = 0;
674    let height: u16 = 0;
675    let fps: f64 = 0.0;
676
677    let (black_level, black_level_per_channel, black_level_count) = json.black_level
678        .as_ref()
679        .map(|levels| {
680            let count = levels.len() as i32;
681            let avg = if levels.is_empty() { 0.0 } else { levels.iter().sum::<f64>() / levels.len() as f64 };
682            let mut per_ch = [avg; 4];
683            for (i, &v) in levels.iter().enumerate().take(4) {
684                per_ch[i] = v;
685            }
686            (avg, per_ch, count)
687        })
688        .unwrap_or((0.0, [0.0; 4], 0));
689
690    let white_level = json.white_level.unwrap_or(16383.0);
691
692    // asShotNeutral is per-frame metadata only, not in container JSON.
693    // It's extracted in from_path via read_first_frame_meta from frame 0's
694    // per-frame JSON header, so this stays None here.
695    let wb_multipliers: Option<[f32; 3]> = None;
696
697
698    // Map JSON string illuminant names to DNG illuminant constants
699    let json_illuminant_to_const = |s: &str| -> Option<i32> {
700        match s.trim().to_lowercase().as_str() {
701            "d50" | "horizon" | "cool_white" => Some(23),
702            "d55" => Some(22),
703            "d65" | "daylight" | "fine_weather" | "cloudy" => Some(21),
704            "d75" | "shade" => Some(24),
705            "standardlighta" | "standard_a" | "tungsten" | "incandescent" | "warm_white" | "iso_studio_tungsten" => Some(17),
706            "fluorescent" | "tl84" => Some(12),
707            "flash" | "standardlightb" => Some(4),
708            _ => None,
709        }
710    };
711
712    let calibration_illuminant1 = json.color_illuminant1.as_deref().and_then(json_illuminant_to_const);
713    let calibration_illuminant2 = json.color_illuminant2.as_deref().and_then(json_illuminant_to_const);
714
715    let lens_shading_map = json.lens_shading_map.as_ref().and_then(|channels| {
716        let width = json.lens_shading_map_width? as u32;
717        let height = json.lens_shading_map_height? as u32;
718        if channels.len() < 4 { return None; }
719        let f32_channels: Vec<Vec<f32>> = channels.iter().take(4).map(|ch| ch.iter().map(|&v| v as f32).collect()).collect();
720        if f32_channels.len() < 4 { return None; }
721        Some(crate::decoder::LensShadingMap { channels: f32_channels, width, height })
722    });
723
724    Ok(McrawFileInfo {
725        path: path.to_string_lossy().into_owned(),
726        size: data.len() as u64,
727        format_version,
728        frame_count,
729        width,
730        height,
731        fps,
732        has_audio,
733        audio_sample_rate,
734        audio_channels,
735        bit_depth,
736        bayer_pattern,
737        camera_metadata: CameraMetadata {
738            sensor_make: if sensor_make.is_empty() { None } else { Some(sensor_make) },
739            sensor_model: None,
740            camera_model,
741            lens_model: None,
742            focal_length,
743            aperture,
744            iso: None,
745            exposure_time: None,
746            white_balance: None,
747            capture_date: None,
748            color_matrix,
749            color_matrix2,
750            forward_matrix1,
751            forward_matrix2,
752            calibration_matrix1,
753            calibration_matrix2,
754            calibration_illuminant1,
755            calibration_illuminant2,
756            calibration_illuminant: None,
757            wb_multipliers,
758        },
759        frame_offsets: Vec::new(),
760        audio_offset: None,
761        audio_length: None,
762        sensor_width: 0,
763        sensor_height: 0,
764        active_offset_x: 0,
765        active_offset_y: 0,
766        active_width: 0,
767        active_height: 0,
768        white_level,
769        black_level,
770        black_level_per_channel,
771        black_level_count,
772        lens_shading_map,
773        dynamic_black_level: None,
774        dynamic_white_level: None,
775        first_timestamp: None,
776    })
777}
778
779fn parse_header(data: &[u8], path: &Path) -> Result<McrawFileInfo> {
780    if data.len() < 17 {
781        anyhow::bail!("File {:?} is too small to be a valid file (need at least 17 bytes, got {})", path, data.len());
782    }
783
784    // Check for "MOTION " magic header (new format with JSON metadata)
785    if data.starts_with(b"MOTION ") {
786        tracing::debug!("detected MOTION format header");
787        return parse_motion_header(data, path);
788    }
789
790    // Check for "MCRAW" magic header (legacy binary format)
791    if data.len() < 36 {
792        anyhow::bail!("File {:?} is too small to be a valid MCRAW file (need at least 36 bytes, got {})", path, data.len());
793    }
794
795    let magic = &data[0..5];
796    if magic != b"MCRAW" {
797        anyhow::bail!(
798            "Invalid MCRAW magic header in {:?}: expected 'MCRAW', found {:?}",
799            path,
800            magic
801        );
802    }
803    tracing::debug!("detected MCRAW legacy format header");
804
805    let format_version = u32::from_be_bytes([data[5], data[6], data[7], data[8]]);
806    let frame_count = u32::from_be_bytes([data[9], data[10], data[11], data[12]]);
807    let width = u16::from_be_bytes([data[13], data[14]]);
808    let height = u16::from_be_bytes([data[15], data[16]]);
809    let fps = f64::from_be_bytes([
810        data[17], data[18], data[19], data[20], data[21], data[22], data[23], data[24],
811    ]);
812    let has_audio = data[25] != 0;
813
814    let audio_sample_rate = if has_audio && data.len() >= 30 {
815        u32::from_be_bytes([data[26], data[27], data[28], data[29]])
816    } else {
817        0
818    };
819
820    let audio_channels = if data.len() >= 32 {
821        u16::from_be_bytes([data[30], data[31]])
822    } else {
823        0
824    };
825
826    let bit_depth = if data.len() >= 34 {
827        u16::from_be_bytes([data[32], data[33]])
828    } else {
829        0
830    };
831
832    let bayer_pattern_id = if data.len() >= 35 {
833        data[34]
834    } else {
835        0
836    };
837    let bayer_pattern = BayerPattern::from_u8(bayer_pattern_id);
838
839    let mut offset = 36;
840    let mut camera_metadata = CameraMetadata::default();
841    let mut frame_offsets = Vec::new();
842    let mut audio_offset: Option<u64> = None;
843    let mut audio_length: Option<u64> = None;
844    let mut sensor_width: u16 = 0;
845    let mut sensor_height: u16 = 0;
846    let mut active_offset_x: u16 = 0;
847    let mut active_offset_y: u16 = 0;
848    let mut active_width: u16 = 0;
849    let mut active_height: u16 = 0;
850    let mut _color_matrix: Option<[f64; 9]> = None;
851    let mut _calibration_illuminant: Option<String> = None;
852
853    if offset < data.len() {
854        let block_length = read_u32_be(&data, offset) as usize;
855        offset += 4;
856        let block_end = offset + block_length;
857
858        while offset < block_end && offset < data.len() {
859            let tag = data[offset];
860            offset += 1;
861
862            match tag {
863                0x01 => {
864                    if let Ok(s) = parse_string(&data, &mut offset) {
865                        camera_metadata.sensor_make = Some(s);
866                    }
867                }
868                0x02 => {
869                    if let Ok(s) = parse_string(&data, &mut offset) {
870                        camera_metadata.sensor_model = Some(s);
871                    }
872                }
873                0x03 => {
874                    if let Ok(s) = parse_string(&data, &mut offset) {
875                        camera_metadata.camera_model = Some(s);
876                    }
877                }
878                0x04 => {
879                    if let Ok(s) = parse_string(&data, &mut offset) {
880                        camera_metadata.lens_model = Some(s);
881                    }
882                }
883                0x05 => {
884                    if let Ok(v) = parse_f64(&data, &mut offset) {
885                        camera_metadata.focal_length = Some(v);
886                    }
887                }
888                0x06 => {
889                    if let Ok(v) = parse_f64(&data, &mut offset) {
890                        camera_metadata.aperture = Some(v);
891                    }
892                }
893                0x07 => {
894                    if let Ok(v) = parse_u32_be(&data, &mut offset) {
895                        camera_metadata.iso = Some(v);
896                    }
897                }
898                0x08 => {
899                    if let Ok(v) = parse_f64(&data, &mut offset) {
900                        camera_metadata.exposure_time = Some(v);
901                    }
902                }
903                0x09 => {
904                    if let Ok(v) = parse_f64(&data, &mut offset) {
905                        camera_metadata.white_balance = Some(v);
906                    }
907                }
908                0x0A => {
909                    if let Ok(s) = parse_string(&data, &mut offset) {
910                        camera_metadata.capture_date = Some(s);
911                    }
912                }
913                0x0B => {
914                    let matrix = parse_f64_array(&data, &mut offset, 9);
915                    if matrix.len() == 9 {
916                        let arr: [f64; 9] = matrix.try_into().ok().unwrap_or([0.0; 9]);
917                          _color_matrix = Some(arr);
918                    }
919                }
920                0x0C => {
921                    if let Ok(s) = parse_string(&data, &mut offset) {
922                        _calibration_illuminant = Some(s);
923                    }
924                }
925                0x10 => {
926                    let count = parse_u32_be(&data, &mut offset);
927                    if let Ok(n) = count {
928                        let mut offsets = Vec::with_capacity(n as usize);
929                        for _ in 0..n {
930                            if offset + 8 <= data.len() {
931                                let val = u64::from_be_bytes([
932                                    data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
933                                    data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
934                                ]);
935                                offsets.push(val);
936                                offset += 8;
937                            } else {
938                                break;
939                            }
940                        }
941                        frame_offsets = offsets;
942                    }
943                }
944                0x11 => {
945                    if has_audio && offset + 8 <= data.len() {
946                        audio_offset = Some(u64::from_be_bytes([
947                            data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
948                            data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
949                        ]));
950                        offset += 8;
951                    }
952                }
953                0x12 => {
954                    if has_audio && offset + 8 <= data.len() {
955                        audio_length = Some(u64::from_be_bytes([
956                            data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
957                            data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
958                        ]));
959                        offset += 8;
960                    }
961                }
962                0x13 => {
963                    if offset + 2 <= data.len() {
964                        sensor_width = u16::from_be_bytes([data[offset], data[offset + 1]]);
965                        offset += 2;
966                    }
967                }
968                0x14 => {
969                    if offset + 2 <= data.len() {
970                        sensor_height = u16::from_be_bytes([data[offset], data[offset + 1]]);
971                        offset += 2;
972                    }
973                }
974                0x15 => {
975                    if offset + 2 <= data.len() {
976                        active_offset_x = u16::from_be_bytes([data[offset], data[offset + 1]]);
977                        offset += 2;
978                    }
979                }
980                0x16 => {
981                    if offset + 2 <= data.len() {
982                        active_offset_y = u16::from_be_bytes([data[offset], data[offset + 1]]);
983                        offset += 2;
984                    }
985                }
986                0x17 => {
987                    if offset + 2 <= data.len() {
988                        active_width = u16::from_be_bytes([data[offset], data[offset + 1]]);
989                        offset += 2;
990                    }
991                }
992                0x18 => {
993                    if offset + 2 <= data.len() {
994                        active_height = u16::from_be_bytes([data[offset], data[offset + 1]]);
995                        offset += 2;
996                    }
997                }
998                _ => {
999                    offset += 1;
1000                }
1001            }
1002        }
1003    }
1004
1005    Ok(McrawFileInfo {
1006        path: path.to_string_lossy().into_owned(),
1007        size: data.len() as u64,
1008        format_version,
1009        frame_count,
1010        width,
1011        height,
1012        fps,
1013        has_audio,
1014        audio_sample_rate,
1015        audio_channels,
1016        bit_depth,
1017        bayer_pattern,
1018        camera_metadata,
1019        frame_offsets,
1020        audio_offset,
1021        audio_length,
1022        sensor_width,
1023        sensor_height,
1024        active_offset_x,
1025        active_offset_y,
1026        active_width,
1027        active_height,
1028        white_level: 16383.0,
1029        black_level: 0.0,
1030        black_level_per_channel: [0.0; 4],
1031        black_level_count: 0,
1032        lens_shading_map: None,
1033        dynamic_black_level: None,
1034        dynamic_white_level: None,
1035        first_timestamp: None,
1036    })
1037}
1038
1039/// Read a big-endian u32 from a byte slice at the given offset.
1040fn read_u32_be(data: &[u8], offset: usize) -> u32 {
1041    u32::from_be_bytes([
1042        data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
1043    ])
1044}
1045
1046fn parse_u32_be(data: &[u8], offset: &mut usize) -> Result<u32> {
1047    if *offset + 4 > data.len() {
1048        return Err(anyhow::anyhow!("Unexpected end of data"));
1049    }
1050    let val = u32::from_be_bytes([
1051        data[*offset], data[*offset + 1], data[*offset + 2], data[*offset + 3],
1052    ]);
1053    *offset += 4;
1054    Ok(val)
1055}
1056
1057fn parse_f64(data: &[u8], offset: &mut usize) -> Result<f64> {
1058    if *offset + 8 > data.len() {
1059        return Err(anyhow::anyhow!("Unexpected end of data"));
1060    }
1061    let val = f64::from_be_bytes([
1062        data[*offset], data[*offset + 1], data[*offset + 2], data[*offset + 3],
1063        data[*offset + 4], data[*offset + 5], data[*offset + 6], data[*offset + 7],
1064    ]);
1065    *offset += 8;
1066    Ok(val)
1067}
1068
1069fn parse_f64_array(data: &[u8], offset: &mut usize, len: usize) -> Vec<f64> {
1070    let mut result = Vec::with_capacity(len);
1071    for _ in 0..len {
1072        if let Ok(v) = parse_f64(data, offset) {
1073            result.push(v);
1074        } else {
1075            break;
1076        }
1077    }
1078    result
1079}
1080
1081fn parse_string(data: &[u8], offset: &mut usize) -> Result<String> {
1082    if *offset + 4 > data.len() {
1083        return Err(anyhow::anyhow!("Unexpected end of data"));
1084    }
1085    let str_len = u32::from_be_bytes([
1086        data[*offset], data[*offset + 1], data[*offset + 2], data[*offset + 3],
1087    ]) as usize;
1088    *offset += 4;
1089    if *offset + str_len > data.len() {
1090        return Err(anyhow::anyhow!("String extends beyond data"));
1091    }
1092    let s = std::str::from_utf8(&data[*offset..*offset + str_len])
1093        .map_err(|e| anyhow::anyhow!("Invalid UTF-8 string: {}", e))?;
1094    *offset += str_len;
1095    Ok(s.to_string())
1096}
1097
1098pub fn detect_bit_depth_from_white_level(white_level: f64) -> u16 {
1099    if white_level <= 1024.0 {
1100        10
1101    } else if white_level <= 4096.0 {
1102        12
1103    } else if white_level <= 16384.0 {
1104        14
1105    } else if white_level <= 65536.0 {
1106        16
1107    } else {
1108        12
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    #[test]
1117    fn test_bayer_pattern_from_u8() {
1118        assert_eq!(BayerPattern::from_u8(0), BayerPattern::RGGB);
1119        assert_eq!(BayerPattern::from_u8(1), BayerPattern::GRBG);
1120        assert_eq!(BayerPattern::from_u8(2), BayerPattern::GBRG);
1121        assert_eq!(BayerPattern::from_u8(3), BayerPattern::BGGR);
1122        assert_eq!(BayerPattern::from_u8(4), BayerPattern::QuadBayerRGGB);
1123        assert_eq!(BayerPattern::from_u8(5), BayerPattern::QuadBayerGRBG);
1124        assert_eq!(BayerPattern::from_u8(6), BayerPattern::QuadBayerGBRG);
1125        assert_eq!(BayerPattern::from_u8(7), BayerPattern::QuadBayerBGGR);
1126        assert_eq!(BayerPattern::from_u8(99), BayerPattern::RGGB);
1127    }
1128
1129    #[test]
1130    fn test_bayer_pattern_to_u8() {
1131        assert_eq!(BayerPattern::RGGB.to_u8(), 0);
1132        assert_eq!(BayerPattern::GRBG.to_u8(), 1);
1133        assert_eq!(BayerPattern::GBRG.to_u8(), 2);
1134        assert_eq!(BayerPattern::BGGR.to_u8(), 3);
1135        assert_eq!(BayerPattern::QuadBayerRGGB.to_u8(), 4);
1136        assert_eq!(BayerPattern::QuadBayerGRBG.to_u8(), 5);
1137        assert_eq!(BayerPattern::QuadBayerGBRG.to_u8(), 6);
1138        assert_eq!(BayerPattern::QuadBayerBGGR.to_u8(), 7);
1139    }
1140
1141    #[test]
1142    fn test_detect_bit_depth_from_white_level() {
1143        assert_eq!(detect_bit_depth_from_white_level(1023.0), 10);
1144        assert_eq!(detect_bit_depth_from_white_level(1024.0), 10);
1145        assert_eq!(detect_bit_depth_from_white_level(1025.0), 12);
1146        assert_eq!(detect_bit_depth_from_white_level(4095.0), 12);
1147        assert_eq!(detect_bit_depth_from_white_level(4096.0), 12);
1148        assert_eq!(detect_bit_depth_from_white_level(4097.0), 14);
1149        assert_eq!(detect_bit_depth_from_white_level(16383.0), 14);
1150        assert_eq!(detect_bit_depth_from_white_level(16384.0), 14);
1151        assert_eq!(detect_bit_depth_from_white_level(16385.0), 16);
1152        assert_eq!(detect_bit_depth_from_white_level(65535.0), 16);
1153        assert_eq!(detect_bit_depth_from_white_level(65536.0), 16);
1154        assert_eq!(detect_bit_depth_from_white_level(65537.0), 12);
1155        assert_eq!(detect_bit_depth_from_white_level(0.0), 10);
1156    }
1157
1158    #[test]
1159    fn test_parse_header_minimal() {
1160        let mut data = vec![0u8; 36];
1161        data[0..5].copy_from_slice(b"MCRAW");
1162        data[5..9].copy_from_slice(&2u32.to_be_bytes());
1163        data[9..13].copy_from_slice(&10u32.to_be_bytes());
1164        data[13..15].copy_from_slice(&(1920u16).to_be_bytes());
1165        data[15..17].copy_from_slice(&(1080u16).to_be_bytes());
1166        data[17..25].copy_from_slice(&(30.0f64).to_be_bytes());
1167        data[25] = 0;
1168
1169        let info = parse_header(&data, std::path::Path::new("test.mcraw")).unwrap();
1170        assert_eq!(info.format_version, 2);
1171        assert_eq!(info.frame_count, 10);
1172        assert_eq!(info.width, 1920);
1173        assert_eq!(info.height, 1080);
1174        assert!((info.fps - 30.0).abs() < 0.001);
1175        assert!(!info.has_audio);
1176    }
1177
1178    #[test]
1179    fn test_duration_seconds() {
1180        let mut data = vec![0u8; 36];
1181        data[0..5].copy_from_slice(b"MCRAW");
1182        data[9..13].copy_from_slice(&600u32.to_be_bytes());
1183        data[17..25].copy_from_slice(&(30.0f64).to_be_bytes());
1184        let info = parse_header(&data, std::path::Path::new("test.mcraw")).unwrap();
1185        assert!((info.duration_seconds() - 20.0).abs() < 0.001);
1186    }
1187
1188    fn make_test_info(w: u16, h: u16) -> McrawFileInfo {
1189        McrawFileInfo {
1190            path: String::new(),
1191            size: 0,
1192            format_version: 2,
1193            frame_count: 0,
1194            width: w,
1195            height: h,
1196            fps: 30.0,
1197            has_audio: false,
1198            audio_sample_rate: 0,
1199            audio_channels: 0,
1200            bit_depth: 0,
1201            bayer_pattern: BayerPattern::RGGB,
1202            camera_metadata: CameraMetadata::default(),
1203            frame_offsets: Vec::new(),
1204            audio_offset: None,
1205            audio_length: None,
1206            sensor_width: 0,
1207            sensor_height: 0,
1208            active_offset_x: 0,
1209            active_offset_y: 0,
1210            active_width: 0,
1211            active_height: 0,
1212            white_level: 16383.0,
1213            black_level: 0.0,
1214            black_level_per_channel: [0.0; 4],
1215            black_level_count: 0,
1216            lens_shading_map: None,
1217            dynamic_black_level: None,
1218            dynamic_white_level: None,
1219            first_timestamp: None,
1220        }
1221    }
1222
1223    #[test]
1224    fn test_resolution_label() {
1225        assert_eq!(make_test_info(1920, 1080).resolution_label(), "1080p");
1226        assert_eq!(make_test_info(2560, 1440).resolution_label(), "1440p");
1227        assert_eq!(make_test_info(3840, 2160).resolution_label(), "4K");
1228        assert_eq!(make_test_info(4096, 2160).resolution_label(), "4K DCI");
1229        assert_eq!(make_test_info(1280, 720).resolution_label(), "Custom");
1230    }
1231
1232    #[test]
1233    fn test_parse_header_with_string_metadata() {
1234        let mut data = vec![0u8; 64];
1235        data[0..5].copy_from_slice(b"MCRAW");
1236        data[5] = 2;
1237        data[9..13].copy_from_slice(&1u32.to_be_bytes());
1238        data[13..15].copy_from_slice(&(1920u16).to_be_bytes());
1239        data[15..17].copy_from_slice(&(1080u16).to_be_bytes());
1240        data[17..25].copy_from_slice(&(30.0f64).to_be_bytes());
1241        data[25] = 0;
1242
1243        let camera_model = "TestCamera";
1244        let block_offset = 36;
1245        let str_len = camera_model.len() as u32;
1246        let block_len = 1 + 4 + str_len as u32; // 1 tag + 4 str_len + str data
1247        data[block_offset..block_offset + 4].copy_from_slice(&(block_len as u32).to_be_bytes());
1248        data[block_offset + 4] = 0x03;
1249        data[block_offset + 5..block_offset + 9].copy_from_slice(&str_len.to_be_bytes());
1250        data[block_offset + 9..block_offset + 9 + camera_model.len()]
1251            .copy_from_slice(camera_model.as_bytes());
1252
1253        let info = parse_header(&data, std::path::Path::new("test.mcraw")).unwrap();
1254        assert_eq!(info.camera_metadata.camera_model, Some("TestCamera".to_string()));
1255    }
1256
1257    #[test]
1258    fn test_parse_header_invalid_magic() {
1259        let data = vec![b'X'; 36];
1260        let result = parse_header(&data, std::path::Path::new("test.mcraw"));
1261        assert!(result.is_err());
1262    }
1263
1264    #[test]
1265    fn test_parse_header_too_small() {
1266        let data = vec![0u8; 10];
1267        let result = parse_header(&data, std::path::Path::new("test.mcraw"));
1268        assert!(result.is_err());
1269    }
1270}