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#[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 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, }
155 }
156}
157
158#[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#[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 pub first_timestamp: Option<i64>,
244}
245
246struct FirstFrameMeta {
248 width: u16,
249 height: u16,
250 wb_gains: Option<[f32; 3]>,
252}
253
254fn 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 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 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 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 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 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 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 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 let wb_multipliers: Option<[f32; 3]> = None;
696
697
698 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 if data.starts_with(b"MOTION ") {
786 tracing::debug!("detected MOTION format header");
787 return parse_motion_header(data, path);
788 }
789
790 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
1039fn 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; 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}