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            }
525        }
526    }
527
528    pub fn enhance_with_decoder(&mut self) {
529        if self.camera_metadata.color_matrix.is_some() {
530            tracing::debug!("enhance_with_decoder: metadata already populated, skipping decoder");
531            return;
532        }
533        let path = self.path.clone();
534        tracing::debug!("enhance_with_decoder: {}", path);
535        let decoder_result = crate::decoder::Decoder::new(&path);
536        let decoder = match decoder_result {
537            Ok(d) => d,
538            Err(e) => {
539                tracing::warn!("failed to open decoder for {}: {}", path, e);
540                return;
541            }
542        };
543        self.enhance_from_decoder(&decoder);
544    }
545
546    pub fn format_name(&self) -> &'static str {
547        match self.format_version {
548            1 => "MotionCam v1 (Legacy)",
549            2 => "MotionCam v2",
550            3 => "MotionCam v3",
551            _ => "Unknown format",
552        }
553    }
554
555    pub fn duration_seconds(&self) -> f64 {
556        self.frame_count as f64 / self.fps
557    }
558
559    pub fn resolution_label(&self) -> &'static str {
560        match (self.width, self.height) {
561            (1920, 1080) => "1080p",
562            (2560, 1440) => "1440p",
563            (3840, 2160) => "4K",
564            (4096, 2160) => "4K DCI",
565            _ => "Custom",
566        }
567    }
568}
569
570fn parse_motion_header(data: &[u8], path: &Path) -> Result<McrawFileInfo> {
571    if data.len() < 17 {
572        anyhow::bail!("File {:?} is too small for MOTION header", path);
573    }
574
575    let format_version = data[7] as u32;
576    tracing::debug!("parse_motion_header: version={} json_len={}", format_version, u32::from_le_bytes([data[12], data[13], data[14], data[15]]));
577    let json_len = u32::from_le_bytes([data[12], data[13], data[14], data[15]]) as usize;
578    let json_start = 16;
579    let json_end = json_start + json_len;
580
581    if json_end > data.len() {
582        anyhow::bail!("JSON metadata extends beyond file data");
583    }
584
585    let json_str = std::str::from_utf8(&data[json_start..json_end])
586        .with_context(|| "Invalid UTF-8 in MOTION JSON metadata")?;
587
588    let json: MotionJsonMetadata = serde_json::from_str(json_str)
589        .with_context(|| "Failed to parse MOTION JSON metadata")?;
590
591    let bayer_pattern = match json.sensor_arrangement.as_deref() {
592        Some("rggb") | Some("standard") => BayerPattern::RGGB,
593        Some("grbg") => BayerPattern::GRBG,
594        Some("gbrg") => BayerPattern::GBRG,
595        Some("bggr") => BayerPattern::BGGR,
596        _ => BayerPattern::RGGB,
597    };
598
599    let extra_data = json.extra_data;
600    let device_profile = json.device_specific_profile;
601
602    let build_model = extra_data
603        .as_ref()
604        .and_then(|e| e.metadata.as_ref())
605        .and_then(|m| m.build_model.clone());
606
607    let camera_model: Option<String> = device_profile.as_ref()
608        .and_then(|p| p.device_model.clone())
609        .filter(|s| !s.is_empty())
610        .or_else(|| json.unique_camera_model.filter(|s| !s.is_empty()))
611        .or_else(|| build_model.filter(|s| !s.is_empty()));
612
613    let sensor_make = extra_data
614        .as_ref()
615        .and_then(|e| e.metadata.as_ref())
616        .and_then(|m| m.build_manufacturer.clone())
617        .unwrap_or_default();
618
619    let aperture = json.apertures.and_then(|mut a| a.pop());
620    let focal_length = json.focal_lengths.and_then(|mut a| a.pop());
621    let audio_sample_rate = extra_data.as_ref()
622        .and_then(|e| e.audio_sample_rate)
623        .unwrap_or(0) as u32;
624    let audio_channels = extra_data.as_ref()
625        .and_then(|e| e.audio_channels)
626        .unwrap_or(0) as u16;
627    let has_audio = audio_channels > 0;
628
629    let color_matrix = json.color_matrix1.clone().or(json.forward_matrix1.clone())
630        .and_then(|m| {
631            if m.len() == 9 {
632                Some(m.try_into().ok()?)
633            } else {
634                None
635            }
636        });
637
638    let color_matrix2 = json.color_matrix2.clone().and_then(|m| {
639        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
640    });
641    let forward_matrix1 = json.forward_matrix1.clone().and_then(|m| {
642        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
643    });
644    let forward_matrix2 = json.forward_matrix2.clone().and_then(|m| {
645        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
646    });
647    let calibration_matrix1 = json.calibration_matrix1.clone().and_then(|m| {
648        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
649    });
650    let calibration_matrix2 = json.calibration_matrix2.clone().and_then(|m| {
651        if m.len() == 9 { Some(m.try_into().ok()?) } else { None }
652    });
653
654    let bit_depth = json.white_level
655        .map(detect_bit_depth_from_white_level)
656        .unwrap_or(12);
657
658    let frame_count: u32 = 0;
659    let width: u16 = 0;
660    let height: u16 = 0;
661    let fps: f64 = 0.0;
662
663    let (black_level, black_level_per_channel, black_level_count) = json.black_level
664        .as_ref()
665        .map(|levels| {
666            let count = levels.len() as i32;
667            let avg = if levels.is_empty() { 0.0 } else { levels.iter().sum::<f64>() / levels.len() as f64 };
668            let mut per_ch = [avg; 4];
669            for (i, &v) in levels.iter().enumerate().take(4) {
670                per_ch[i] = v;
671            }
672            (avg, per_ch, count)
673        })
674        .unwrap_or((0.0, [0.0; 4], 0));
675
676    let white_level = json.white_level.unwrap_or(16383.0);
677
678    // asShotNeutral is per-frame metadata only, not in container JSON.
679    // It's extracted in from_path via read_first_frame_meta from frame 0's
680    // per-frame JSON header, so this stays None here.
681    let wb_multipliers: Option<[f32; 3]> = None;
682
683
684    // Map JSON string illuminant names to DNG illuminant constants
685    let json_illuminant_to_const = |s: &str| -> Option<i32> {
686        match s.trim().to_lowercase().as_str() {
687            "d50" | "horizon" | "cool_white" => Some(23),
688            "d55" => Some(22),
689            "d65" | "daylight" | "fine_weather" | "cloudy" => Some(21),
690            "d75" | "shade" => Some(24),
691            "standardlighta" | "standard_a" | "tungsten" | "incandescent" | "warm_white" | "iso_studio_tungsten" => Some(17),
692            "fluorescent" | "tl84" => Some(12),
693            "flash" | "standardlightb" => Some(4),
694            _ => None,
695        }
696    };
697
698    let calibration_illuminant1 = json.color_illuminant1.as_deref().and_then(json_illuminant_to_const);
699    let calibration_illuminant2 = json.color_illuminant2.as_deref().and_then(json_illuminant_to_const);
700
701    let lens_shading_map = json.lens_shading_map.as_ref().and_then(|channels| {
702        let width = json.lens_shading_map_width? as u32;
703        let height = json.lens_shading_map_height? as u32;
704        if channels.len() < 4 { return None; }
705        let f32_channels: Vec<Vec<f32>> = channels.iter().take(4).map(|ch| ch.iter().map(|&v| v as f32).collect()).collect();
706        if f32_channels.len() < 4 { return None; }
707        Some(crate::decoder::LensShadingMap { channels: f32_channels, width, height })
708    });
709
710    Ok(McrawFileInfo {
711        path: path.to_string_lossy().into_owned(),
712        size: data.len() as u64,
713        format_version,
714        frame_count,
715        width,
716        height,
717        fps,
718        has_audio,
719        audio_sample_rate,
720        audio_channels,
721        bit_depth,
722        bayer_pattern,
723        camera_metadata: CameraMetadata {
724            sensor_make: if sensor_make.is_empty() { None } else { Some(sensor_make) },
725            sensor_model: None,
726            camera_model,
727            lens_model: None,
728            focal_length,
729            aperture,
730            iso: None,
731            exposure_time: None,
732            white_balance: None,
733            capture_date: None,
734            color_matrix,
735            color_matrix2,
736            forward_matrix1,
737            forward_matrix2,
738            calibration_matrix1,
739            calibration_matrix2,
740            calibration_illuminant1,
741            calibration_illuminant2,
742            calibration_illuminant: None,
743            wb_multipliers,
744        },
745        frame_offsets: Vec::new(),
746        audio_offset: None,
747        audio_length: None,
748        sensor_width: 0,
749        sensor_height: 0,
750        active_offset_x: 0,
751        active_offset_y: 0,
752        active_width: 0,
753        active_height: 0,
754        white_level,
755        black_level,
756        black_level_per_channel,
757        black_level_count,
758        lens_shading_map,
759        dynamic_black_level: None,
760        dynamic_white_level: None,
761        first_timestamp: None,
762    })
763}
764
765fn parse_header(data: &[u8], path: &Path) -> Result<McrawFileInfo> {
766    if data.len() < 17 {
767        anyhow::bail!("File {:?} is too small to be a valid file (need at least 17 bytes, got {})", path, data.len());
768    }
769
770    // Check for "MOTION " magic header (new format with JSON metadata)
771    if data.starts_with(b"MOTION ") {
772        tracing::debug!("detected MOTION format header");
773        return parse_motion_header(data, path);
774    }
775
776    // Check for "MCRAW" magic header (legacy binary format)
777    if data.len() < 36 {
778        anyhow::bail!("File {:?} is too small to be a valid MCRAW file (need at least 36 bytes, got {})", path, data.len());
779    }
780
781    let magic = &data[0..5];
782    if magic != b"MCRAW" {
783        anyhow::bail!(
784            "Invalid MCRAW magic header in {:?}: expected 'MCRAW', found {:?}",
785            path,
786            magic
787        );
788    }
789    tracing::debug!("detected MCRAW legacy format header");
790
791    let format_version = u32::from_be_bytes([data[5], data[6], data[7], data[8]]);
792    let frame_count = u32::from_be_bytes([data[9], data[10], data[11], data[12]]);
793    let width = u16::from_be_bytes([data[13], data[14]]);
794    let height = u16::from_be_bytes([data[15], data[16]]);
795    let fps = f64::from_be_bytes([
796        data[17], data[18], data[19], data[20], data[21], data[22], data[23], data[24],
797    ]);
798    let has_audio = data[25] != 0;
799
800    let audio_sample_rate = if has_audio && data.len() >= 30 {
801        u32::from_be_bytes([data[26], data[27], data[28], data[29]])
802    } else {
803        0
804    };
805
806    let audio_channels = if data.len() >= 32 {
807        u16::from_be_bytes([data[30], data[31]])
808    } else {
809        0
810    };
811
812    let bit_depth = if data.len() >= 34 {
813        u16::from_be_bytes([data[32], data[33]])
814    } else {
815        0
816    };
817
818    let bayer_pattern_id = if data.len() >= 35 {
819        data[34]
820    } else {
821        0
822    };
823    let bayer_pattern = BayerPattern::from_u8(bayer_pattern_id);
824
825    let mut offset = 36;
826    let mut camera_metadata = CameraMetadata::default();
827    let mut frame_offsets = Vec::new();
828    let mut audio_offset: Option<u64> = None;
829    let mut audio_length: Option<u64> = None;
830    let mut sensor_width: u16 = 0;
831    let mut sensor_height: u16 = 0;
832    let mut active_offset_x: u16 = 0;
833    let mut active_offset_y: u16 = 0;
834    let mut active_width: u16 = 0;
835    let mut active_height: u16 = 0;
836    let mut _color_matrix: Option<[f64; 9]> = None;
837    let mut _calibration_illuminant: Option<String> = None;
838
839    if offset < data.len() {
840        let block_length = read_u32_be(&data, offset) as usize;
841        offset += 4;
842        let block_end = offset + block_length;
843
844        while offset < block_end && offset < data.len() {
845            let tag = data[offset];
846            offset += 1;
847
848            match tag {
849                0x01 => {
850                    if let Ok(s) = parse_string(&data, &mut offset) {
851                        camera_metadata.sensor_make = Some(s);
852                    }
853                }
854                0x02 => {
855                    if let Ok(s) = parse_string(&data, &mut offset) {
856                        camera_metadata.sensor_model = Some(s);
857                    }
858                }
859                0x03 => {
860                    if let Ok(s) = parse_string(&data, &mut offset) {
861                        camera_metadata.camera_model = Some(s);
862                    }
863                }
864                0x04 => {
865                    if let Ok(s) = parse_string(&data, &mut offset) {
866                        camera_metadata.lens_model = Some(s);
867                    }
868                }
869                0x05 => {
870                    if let Ok(v) = parse_f64(&data, &mut offset) {
871                        camera_metadata.focal_length = Some(v);
872                    }
873                }
874                0x06 => {
875                    if let Ok(v) = parse_f64(&data, &mut offset) {
876                        camera_metadata.aperture = Some(v);
877                    }
878                }
879                0x07 => {
880                    if let Ok(v) = parse_u32_be(&data, &mut offset) {
881                        camera_metadata.iso = Some(v);
882                    }
883                }
884                0x08 => {
885                    if let Ok(v) = parse_f64(&data, &mut offset) {
886                        camera_metadata.exposure_time = Some(v);
887                    }
888                }
889                0x09 => {
890                    if let Ok(v) = parse_f64(&data, &mut offset) {
891                        camera_metadata.white_balance = Some(v);
892                    }
893                }
894                0x0A => {
895                    if let Ok(s) = parse_string(&data, &mut offset) {
896                        camera_metadata.capture_date = Some(s);
897                    }
898                }
899                0x0B => {
900                    let matrix = parse_f64_array(&data, &mut offset, 9);
901                    if matrix.len() == 9 {
902                        let arr: [f64; 9] = matrix.try_into().ok().unwrap_or([0.0; 9]);
903                          _color_matrix = Some(arr);
904                    }
905                }
906                0x0C => {
907                    if let Ok(s) = parse_string(&data, &mut offset) {
908                        _calibration_illuminant = Some(s);
909                    }
910                }
911                0x10 => {
912                    let count = parse_u32_be(&data, &mut offset);
913                    if let Ok(n) = count {
914                        let mut offsets = Vec::with_capacity(n as usize);
915                        for _ in 0..n {
916                            if offset + 8 <= data.len() {
917                                let val = u64::from_be_bytes([
918                                    data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
919                                    data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
920                                ]);
921                                offsets.push(val);
922                                offset += 8;
923                            } else {
924                                break;
925                            }
926                        }
927                        frame_offsets = offsets;
928                    }
929                }
930                0x11 => {
931                    if has_audio && offset + 8 <= data.len() {
932                        audio_offset = Some(u64::from_be_bytes([
933                            data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
934                            data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
935                        ]));
936                        offset += 8;
937                    }
938                }
939                0x12 => {
940                    if has_audio && offset + 8 <= data.len() {
941                        audio_length = Some(u64::from_be_bytes([
942                            data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
943                            data[offset + 4], data[offset + 5], data[offset + 6], data[offset + 7],
944                        ]));
945                        offset += 8;
946                    }
947                }
948                0x13 => {
949                    if offset + 2 <= data.len() {
950                        sensor_width = u16::from_be_bytes([data[offset], data[offset + 1]]);
951                        offset += 2;
952                    }
953                }
954                0x14 => {
955                    if offset + 2 <= data.len() {
956                        sensor_height = u16::from_be_bytes([data[offset], data[offset + 1]]);
957                        offset += 2;
958                    }
959                }
960                0x15 => {
961                    if offset + 2 <= data.len() {
962                        active_offset_x = u16::from_be_bytes([data[offset], data[offset + 1]]);
963                        offset += 2;
964                    }
965                }
966                0x16 => {
967                    if offset + 2 <= data.len() {
968                        active_offset_y = u16::from_be_bytes([data[offset], data[offset + 1]]);
969                        offset += 2;
970                    }
971                }
972                0x17 => {
973                    if offset + 2 <= data.len() {
974                        active_width = u16::from_be_bytes([data[offset], data[offset + 1]]);
975                        offset += 2;
976                    }
977                }
978                0x18 => {
979                    if offset + 2 <= data.len() {
980                        active_height = u16::from_be_bytes([data[offset], data[offset + 1]]);
981                        offset += 2;
982                    }
983                }
984                _ => {
985                    offset += 1;
986                }
987            }
988        }
989    }
990
991    Ok(McrawFileInfo {
992        path: path.to_string_lossy().into_owned(),
993        size: data.len() as u64,
994        format_version,
995        frame_count,
996        width,
997        height,
998        fps,
999        has_audio,
1000        audio_sample_rate,
1001        audio_channels,
1002        bit_depth,
1003        bayer_pattern,
1004        camera_metadata,
1005        frame_offsets,
1006        audio_offset,
1007        audio_length,
1008        sensor_width,
1009        sensor_height,
1010        active_offset_x,
1011        active_offset_y,
1012        active_width,
1013        active_height,
1014        white_level: 16383.0,
1015        black_level: 0.0,
1016        black_level_per_channel: [0.0; 4],
1017        black_level_count: 0,
1018        lens_shading_map: None,
1019        dynamic_black_level: None,
1020        dynamic_white_level: None,
1021        first_timestamp: None,
1022    })
1023}
1024
1025/// Read a big-endian u32 from a byte slice at the given offset.
1026fn read_u32_be(data: &[u8], offset: usize) -> u32 {
1027    u32::from_be_bytes([
1028        data[offset], data[offset + 1], data[offset + 2], data[offset + 3],
1029    ])
1030}
1031
1032fn parse_u32_be(data: &[u8], offset: &mut usize) -> Result<u32> {
1033    if *offset + 4 > data.len() {
1034        return Err(anyhow::anyhow!("Unexpected end of data"));
1035    }
1036    let val = u32::from_be_bytes([
1037        data[*offset], data[*offset + 1], data[*offset + 2], data[*offset + 3],
1038    ]);
1039    *offset += 4;
1040    Ok(val)
1041}
1042
1043fn parse_f64(data: &[u8], offset: &mut usize) -> Result<f64> {
1044    if *offset + 8 > data.len() {
1045        return Err(anyhow::anyhow!("Unexpected end of data"));
1046    }
1047    let val = f64::from_be_bytes([
1048        data[*offset], data[*offset + 1], data[*offset + 2], data[*offset + 3],
1049        data[*offset + 4], data[*offset + 5], data[*offset + 6], data[*offset + 7],
1050    ]);
1051    *offset += 8;
1052    Ok(val)
1053}
1054
1055fn parse_f64_array(data: &[u8], offset: &mut usize, len: usize) -> Vec<f64> {
1056    let mut result = Vec::with_capacity(len);
1057    for _ in 0..len {
1058        if let Ok(v) = parse_f64(data, offset) {
1059            result.push(v);
1060        } else {
1061            break;
1062        }
1063    }
1064    result
1065}
1066
1067fn parse_string(data: &[u8], offset: &mut usize) -> Result<String> {
1068    if *offset + 4 > data.len() {
1069        return Err(anyhow::anyhow!("Unexpected end of data"));
1070    }
1071    let str_len = u32::from_be_bytes([
1072        data[*offset], data[*offset + 1], data[*offset + 2], data[*offset + 3],
1073    ]) as usize;
1074    *offset += 4;
1075    if *offset + str_len > data.len() {
1076        return Err(anyhow::anyhow!("String extends beyond data"));
1077    }
1078    let s = std::str::from_utf8(&data[*offset..*offset + str_len])
1079        .map_err(|e| anyhow::anyhow!("Invalid UTF-8 string: {}", e))?;
1080    *offset += str_len;
1081    Ok(s.to_string())
1082}
1083
1084pub fn detect_bit_depth_from_white_level(white_level: f64) -> u16 {
1085    if white_level <= 1024.0 {
1086        10
1087    } else if white_level <= 4096.0 {
1088        12
1089    } else if white_level <= 16384.0 {
1090        14
1091    } else if white_level <= 65536.0 {
1092        16
1093    } else {
1094        12
1095    }
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101
1102    #[test]
1103    fn test_bayer_pattern_from_u8() {
1104        assert_eq!(BayerPattern::from_u8(0), BayerPattern::RGGB);
1105        assert_eq!(BayerPattern::from_u8(1), BayerPattern::GRBG);
1106        assert_eq!(BayerPattern::from_u8(2), BayerPattern::GBRG);
1107        assert_eq!(BayerPattern::from_u8(3), BayerPattern::BGGR);
1108        assert_eq!(BayerPattern::from_u8(4), BayerPattern::QuadBayerRGGB);
1109        assert_eq!(BayerPattern::from_u8(5), BayerPattern::QuadBayerGRBG);
1110        assert_eq!(BayerPattern::from_u8(6), BayerPattern::QuadBayerGBRG);
1111        assert_eq!(BayerPattern::from_u8(7), BayerPattern::QuadBayerBGGR);
1112        assert_eq!(BayerPattern::from_u8(99), BayerPattern::RGGB);
1113    }
1114
1115    #[test]
1116    fn test_bayer_pattern_to_u8() {
1117        assert_eq!(BayerPattern::RGGB.to_u8(), 0);
1118        assert_eq!(BayerPattern::GRBG.to_u8(), 1);
1119        assert_eq!(BayerPattern::GBRG.to_u8(), 2);
1120        assert_eq!(BayerPattern::BGGR.to_u8(), 3);
1121        assert_eq!(BayerPattern::QuadBayerRGGB.to_u8(), 4);
1122        assert_eq!(BayerPattern::QuadBayerGRBG.to_u8(), 5);
1123        assert_eq!(BayerPattern::QuadBayerGBRG.to_u8(), 6);
1124        assert_eq!(BayerPattern::QuadBayerBGGR.to_u8(), 7);
1125    }
1126
1127    #[test]
1128    fn test_detect_bit_depth_from_white_level() {
1129        assert_eq!(detect_bit_depth_from_white_level(1023.0), 10);
1130        assert_eq!(detect_bit_depth_from_white_level(1024.0), 10);
1131        assert_eq!(detect_bit_depth_from_white_level(1025.0), 12);
1132        assert_eq!(detect_bit_depth_from_white_level(4095.0), 12);
1133        assert_eq!(detect_bit_depth_from_white_level(4096.0), 12);
1134        assert_eq!(detect_bit_depth_from_white_level(4097.0), 14);
1135        assert_eq!(detect_bit_depth_from_white_level(16383.0), 14);
1136        assert_eq!(detect_bit_depth_from_white_level(16384.0), 14);
1137        assert_eq!(detect_bit_depth_from_white_level(16385.0), 16);
1138        assert_eq!(detect_bit_depth_from_white_level(65535.0), 16);
1139        assert_eq!(detect_bit_depth_from_white_level(65536.0), 16);
1140        assert_eq!(detect_bit_depth_from_white_level(65537.0), 12);
1141        assert_eq!(detect_bit_depth_from_white_level(0.0), 10);
1142    }
1143
1144    #[test]
1145    fn test_parse_header_minimal() {
1146        let mut data = vec![0u8; 36];
1147        data[0..5].copy_from_slice(b"MCRAW");
1148        data[5..9].copy_from_slice(&2u32.to_be_bytes());
1149        data[9..13].copy_from_slice(&10u32.to_be_bytes());
1150        data[13..15].copy_from_slice(&(1920u16).to_be_bytes());
1151        data[15..17].copy_from_slice(&(1080u16).to_be_bytes());
1152        data[17..25].copy_from_slice(&(30.0f64).to_be_bytes());
1153        data[25] = 0;
1154
1155        let info = parse_header(&data, std::path::Path::new("test.mcraw")).unwrap();
1156        assert_eq!(info.format_version, 2);
1157        assert_eq!(info.frame_count, 10);
1158        assert_eq!(info.width, 1920);
1159        assert_eq!(info.height, 1080);
1160        assert!((info.fps - 30.0).abs() < 0.001);
1161        assert!(!info.has_audio);
1162    }
1163
1164    #[test]
1165    fn test_duration_seconds() {
1166        let mut data = vec![0u8; 36];
1167        data[0..5].copy_from_slice(b"MCRAW");
1168        data[9..13].copy_from_slice(&600u32.to_be_bytes());
1169        data[17..25].copy_from_slice(&(30.0f64).to_be_bytes());
1170        let info = parse_header(&data, std::path::Path::new("test.mcraw")).unwrap();
1171        assert!((info.duration_seconds() - 20.0).abs() < 0.001);
1172    }
1173
1174    fn make_test_info(w: u16, h: u16) -> McrawFileInfo {
1175        McrawFileInfo {
1176            path: String::new(),
1177            size: 0,
1178            format_version: 2,
1179            frame_count: 0,
1180            width: w,
1181            height: h,
1182            fps: 30.0,
1183            has_audio: false,
1184            audio_sample_rate: 0,
1185            audio_channels: 0,
1186            bit_depth: 0,
1187            bayer_pattern: BayerPattern::RGGB,
1188            camera_metadata: CameraMetadata::default(),
1189            frame_offsets: Vec::new(),
1190            audio_offset: None,
1191            audio_length: None,
1192            sensor_width: 0,
1193            sensor_height: 0,
1194            active_offset_x: 0,
1195            active_offset_y: 0,
1196            active_width: 0,
1197            active_height: 0,
1198            white_level: 16383.0,
1199            black_level: 0.0,
1200            black_level_per_channel: [0.0; 4],
1201            black_level_count: 0,
1202            lens_shading_map: None,
1203            dynamic_black_level: None,
1204            dynamic_white_level: None,
1205            first_timestamp: None,
1206        }
1207    }
1208
1209    #[test]
1210    fn test_resolution_label() {
1211        assert_eq!(make_test_info(1920, 1080).resolution_label(), "1080p");
1212        assert_eq!(make_test_info(2560, 1440).resolution_label(), "1440p");
1213        assert_eq!(make_test_info(3840, 2160).resolution_label(), "4K");
1214        assert_eq!(make_test_info(4096, 2160).resolution_label(), "4K DCI");
1215        assert_eq!(make_test_info(1280, 720).resolution_label(), "Custom");
1216    }
1217
1218    #[test]
1219    fn test_parse_header_with_string_metadata() {
1220        let mut data = vec![0u8; 64];
1221        data[0..5].copy_from_slice(b"MCRAW");
1222        data[5] = 2;
1223        data[9..13].copy_from_slice(&1u32.to_be_bytes());
1224        data[13..15].copy_from_slice(&(1920u16).to_be_bytes());
1225        data[15..17].copy_from_slice(&(1080u16).to_be_bytes());
1226        data[17..25].copy_from_slice(&(30.0f64).to_be_bytes());
1227        data[25] = 0;
1228
1229        let camera_model = "TestCamera";
1230        let block_offset = 36;
1231        let str_len = camera_model.len() as u32;
1232        let block_len = 1 + 4 + str_len as u32; // 1 tag + 4 str_len + str data
1233        data[block_offset..block_offset + 4].copy_from_slice(&(block_len as u32).to_be_bytes());
1234        data[block_offset + 4] = 0x03;
1235        data[block_offset + 5..block_offset + 9].copy_from_slice(&str_len.to_be_bytes());
1236        data[block_offset + 9..block_offset + 9 + camera_model.len()]
1237            .copy_from_slice(camera_model.as_bytes());
1238
1239        let info = parse_header(&data, std::path::Path::new("test.mcraw")).unwrap();
1240        assert_eq!(info.camera_metadata.camera_model, Some("TestCamera".to_string()));
1241    }
1242
1243    #[test]
1244    fn test_parse_header_invalid_magic() {
1245        let data = vec![b'X'; 36];
1246        let result = parse_header(&data, std::path::Path::new("test.mcraw"));
1247        assert!(result.is_err());
1248    }
1249
1250    #[test]
1251    fn test_parse_header_too_small() {
1252        let data = vec![0u8; 10];
1253        let result = parse_header(&data, std::path::Path::new("test.mcraw"));
1254        assert!(result.is_err());
1255    }
1256}