Skip to main content

exiftool_rs/formats/
quicktime_stream.rs

1//! QuickTime/MP4 timed metadata extraction (ExtractEmbedded / -ee).
2//!
3//! Ported from ExifTool's QuickTimeStream.pl.
4//! When `-ee` is used on MP4/MOV files, scans sample data from timed
5//! metadata tracks and dispatches to format-specific GPS/sensor processors.
6
7use crate::tag::{Tag, TagGroup, TagId};
8use crate::value::Value;
9
10// ─── conversion factors ───
11const KNOTS_TO_KPH: f64 = 1.852;
12const MPS_TO_KPH: f64 = 3.6;
13const MPH_TO_KPH: f64 = 1.60934;
14
15// ─── per-track info collected during atom parsing ───
16
17/// Information about one timed-metadata track.
18#[derive(Debug, Clone, Default)]
19pub struct TrackInfo {
20    /// Handler type (e.g. b"vide", b"soun", b"meta", b"text", etc.)
21    pub handler_type: [u8; 4],
22    /// MetaFormat / OtherFormat from stsd (e.g. "gpmd", "camm", "mebx", "tx3g", etc.)
23    pub meta_format: Option<String>,
24    /// Media timescale from mdhd
25    pub media_timescale: u32,
26    /// Chunk offsets from stco/co64
27    pub stco: Vec<u64>,
28    /// Sample-to-chunk entries: (first_chunk, samples_per_chunk, desc_index)
29    pub stsc: Vec<(u32, u32, u32)>,
30    /// Sample sizes from stsz
31    pub stsz: Vec<u32>,
32    /// Time-to-sample entries: pairs of (count, delta)
33    pub stts: Vec<(u32, u32)>,
34}
35
36/// Collected track infos gathered during first-pass atom parsing.
37#[derive(Debug, Clone, Default)]
38pub struct StreamState {
39    pub tracks: Vec<TrackInfo>,
40    /// Scratch: current track being built (pushed into tracks on next trak)
41    pub current: TrackInfo,
42    /// Whether we are inside an stbl for the current track
43    pub in_stbl: bool,
44}
45
46// ─── public entry point ───
47
48/// Extract timed metadata tags from MP4 sample data.
49/// Called after normal atom parsing when `extract_embedded > 0`.
50pub fn extract_stream_tags(
51    data: &[u8],
52    tracks: &[TrackInfo],
53    _extract_embedded: u8,
54) -> Vec<Tag> {
55    let mut tags = Vec::new();
56    let mut doc_count: u32 = 0;
57
58    for track in tracks {
59        let handler = &track.handler_type;
60        // Skip audio/video tracks (we only want timed metadata)
61        if handler == b"soun" || handler == b"vide" {
62            continue;
63        }
64
65        // Compute per-sample (offset, size, time, duration)
66        let samples = compute_samples(track);
67        if samples.is_empty() {
68            continue;
69        }
70
71        let meta_format = track.meta_format.as_deref().unwrap_or("");
72
73        for s in &samples {
74            if s.offset as usize + s.size as usize > data.len() || s.size == 0 {
75                continue;
76            }
77            let sample_data =
78                &data[s.offset as usize..(s.offset as usize + s.size as usize)];
79
80            let mut sample_tags = Vec::new();
81
82            // Dispatch based on handler_type and meta_format
83            let dispatched = dispatch_sample(
84                sample_data,
85                handler,
86                meta_format,
87                s.time,
88                s.duration,
89                &mut sample_tags,
90            );
91
92            if dispatched && !sample_tags.is_empty() {
93                doc_count += 1;
94                // Prepend SampleTime / SampleDuration
95                if let Some(t) = s.time {
96                    sample_tags.insert(
97                        0,
98                        mk_stream(
99                            "SampleTime",
100                            "Sample Time",
101                            Value::String(format!("{:.6}", t)),
102                        ),
103                    );
104                }
105                if let Some(d) = s.duration {
106                    sample_tags.insert(
107                        1,
108                        mk_stream(
109                            "SampleDuration",
110                            "Sample Duration",
111                            Value::String(format!("{:.6}", d)),
112                        ),
113                    );
114                }
115                // Tag each with document number
116                for t in &mut sample_tags {
117                    t.description = format!("{} (Doc{})", t.description, doc_count);
118                }
119                tags.extend(sample_tags);
120            }
121        }
122    }
123
124    // Also do a brute-force mdat scan for freeGPS if we found nothing
125    if doc_count == 0 {
126        scan_mdat_for_freegps(data, &mut tags, &mut doc_count);
127    }
128
129    tags
130}
131
132// ─── sample computation ───
133
134struct SampleInfo {
135    offset: u64,
136    size: u32,
137    time: Option<f64>,
138    duration: Option<f64>,
139}
140
141fn compute_samples(track: &TrackInfo) -> Vec<SampleInfo> {
142    let mut result = Vec::new();
143    if track.stsz.is_empty() || track.stco.is_empty() || track.stsc.is_empty() {
144        return result;
145    }
146
147    let ts = if track.media_timescale > 0 {
148        track.media_timescale as f64
149    } else {
150        1.0
151    };
152
153    // Build flat stts list
154    let mut stts_flat: Vec<(u32, u32)> = Vec::new();
155    for &(count, delta) in &track.stts {
156        stts_flat.push((count, delta));
157    }
158    let mut stts_idx = 0;
159    let mut stts_remaining: u32 = if !stts_flat.is_empty() {
160        stts_flat[0].0
161    } else {
162        0
163    };
164    let mut stts_delta: u32 = if !stts_flat.is_empty() {
165        stts_flat[0].1
166    } else {
167        0
168    };
169    let mut time_acc: u64 = 0;
170    let has_time = !stts_flat.is_empty();
171
172    // Build sample list from stsc + stco
173    let mut stsc_idx = 0;
174    let mut samples_per_chunk = track.stsc[0].1;
175    let mut next_first_chunk: Option<u32> = if track.stsc.len() > 1 {
176        Some(track.stsc[1].0)
177    } else {
178        None
179    };
180
181    let mut sample_idx: usize = 0;
182
183    for (chunk_idx_0, &chunk_offset) in track.stco.iter().enumerate() {
184        let chunk_num = chunk_idx_0 as u32 + 1; // 1-based
185
186        // Advance stsc if needed
187        if let Some(nfc) = next_first_chunk {
188            if chunk_num >= nfc {
189                stsc_idx += 1;
190                if stsc_idx < track.stsc.len() {
191                    samples_per_chunk = track.stsc[stsc_idx].1;
192                    next_first_chunk = if stsc_idx + 1 < track.stsc.len() {
193                        Some(track.stsc[stsc_idx + 1].0)
194                    } else {
195                        None
196                    };
197                }
198            }
199        }
200
201        let mut offset_in_chunk: u64 = 0;
202        for _ in 0..samples_per_chunk {
203            if sample_idx >= track.stsz.len() {
204                break;
205            }
206            let sz = track.stsz[sample_idx];
207            let sample_time = if has_time {
208                Some(time_acc as f64 / ts)
209            } else {
210                None
211            };
212            let sample_dur = if has_time {
213                Some(stts_delta as f64 / ts)
214            } else {
215                None
216            };
217
218            result.push(SampleInfo {
219                offset: chunk_offset + offset_in_chunk,
220                size: sz,
221                time: sample_time,
222                duration: sample_dur,
223            });
224
225            offset_in_chunk += sz as u64;
226            sample_idx += 1;
227
228            // Advance stts
229            if has_time {
230                time_acc += stts_delta as u64;
231                if stts_remaining > 0 {
232                    stts_remaining -= 1;
233                }
234                if stts_remaining == 0 {
235                    stts_idx += 1;
236                    if stts_idx < stts_flat.len() {
237                        stts_remaining = stts_flat[stts_idx].0;
238                        stts_delta = stts_flat[stts_idx].1;
239                    }
240                }
241            }
242        }
243    }
244
245    result
246}
247
248// ─── sample dispatch ───
249
250fn dispatch_sample(
251    sample: &[u8],
252    handler: &[u8; 4],
253    meta_format: &str,
254    _time: Option<f64>,
255    _dur: Option<f64>,
256    tags: &mut Vec<Tag>,
257) -> bool {
258    // Try by meta_format first
259    match meta_format {
260        "camm" => return process_camm(sample, tags),
261        "gpmd" => return process_gpmd(sample, tags),
262        "mebx" => return process_mebx(sample, tags),
263        "tx3g" => return process_tx3g(sample, tags),
264        _ => {}
265    }
266
267    // Try by handler type
268    match handler {
269        b"text" | b"sbtl" => {
270            // Text/subtitle track: try NMEA/text GPS
271            if meta_format == "tx3g" {
272                return process_tx3g(sample, tags);
273            }
274            return process_text(sample, tags);
275        }
276        b"gps " => {
277            // GPS data list track: check for freeGPS
278            if sample.len() >= 12 && &sample[4..12] == b"freeGPS " {
279                return process_freegps(sample, tags);
280            }
281            // Try NMEA
282            return process_nmea(sample, tags);
283        }
284        b"meta" | b"data" => {
285            // Timed metadata: try by meta_format
286            match meta_format {
287                "RVMI" => return process_rvmi(sample, tags),
288                _ => {
289                    if sample.len() >= 12 && &sample[4..12] == b"freeGPS " {
290                        return process_freegps(sample, tags);
291                    }
292                }
293            }
294        }
295        _ => {
296            // Try Kenwood udta format
297            if sample.starts_with(b"VIDEO") && sample.windows(2).any(|w| w == b"\xfe\xfe") {
298                return process_kenwood(sample, tags);
299            }
300        }
301    }
302    false
303}
304
305// ─── freeGPS processor (covers ~20 dashcam variants) ───
306
307fn process_freegps(data: &[u8], tags: &mut Vec<Tag>) -> bool {
308    if data.len() < 82 {
309        return false;
310    }
311
312    // Type 1: encrypted Azdome/EEEkit (byte 18..26 = \xaa\xaa\xf2\xe1\xf0\xee\x54\x54)
313    if data.len() > 26 && &data[18..26] == b"\xaa\xaa\xf2\xe1\xf0\xee\x54\x54" {
314        return process_freegps_type1_encrypted(data, tags);
315    }
316
317    // Type 2: NMEA in freeGPS (Nextbase 512GW) - date at offset 52
318    if data.len() > 64 {
319        if let Some(dt) = try_ascii_digits(&data[52..], 14) {
320            if dt.len() == 14 {
321                return process_freegps_type2_nmea(data, tags);
322            }
323        }
324    }
325
326    // Type 3/17: Novatek binary at offset 72 (A[NS][EW]\0)
327    if data.len() > 75 && data[72] == b'A' && is_ns(data[73]) && is_ew(data[74]) && data[75] == 0
328    {
329        return process_freegps_novatek(data, tags);
330    }
331
332    // Type 3b: Viofo/Kenwood at offset 37/85 (\0\0\0A[NS][EW]\0)
333    if data.len() > 44 && &data[37..41] == b"\0\0\0A" && is_ns(data[41]) && is_ew(data[42]) {
334        return process_freegps_viofo(data, 0, tags);
335    }
336    if data.len() > 92 && &data[85..89] == b"\0\0\0A" && is_ns(data[89]) && is_ew(data[90]) {
337        // Kenwood DRV-A510W: header 48 bytes longer
338        return process_freegps_viofo(data, 48, tags);
339    }
340
341    // Type 6: Akaso (A\0\0\0 at offset 60, [NS]\0\0\0 at +8, [EW]\0\0\0 at +16)
342    if data.len() > 96
343        && data[60] == b'A'
344        && data[61] == 0
345        && data[62] == 0
346        && data[63] == 0
347        && is_ns(data[68])
348        && is_ew(data[76])
349    {
350        return process_freegps_akaso(data, tags);
351    }
352
353    // Type 10: Vantrue S1 (A[NS][EW]\0 at offset 64)
354    if data.len() > 100 && data[64] == b'A' && is_ns(data[65]) && is_ew(data[66]) && data[67] == 0
355    {
356        return process_freegps_vantrue_s1(data, tags);
357    }
358
359    // Type 12: double lat/lon format (A\0 at 60, [NS]\0 at 72, [EW]\0 at 88)
360    if data.len() >= 0x88
361        && data[60] == b'A'
362        && data[61] == 0
363        && is_ns(data[72])
364        && data[73] == 0
365        && is_ew(data[88])
366        && data[89] == 0
367    {
368        return process_freegps_type12(data, tags);
369    }
370
371    // Type 13: INNOVV (A[NS][EW]\0 at offset 16)
372    if data.len() > 48 && data[16] == b'A' && is_ns(data[17]) && is_ew(data[18]) && data[19] == 0
373    {
374        return process_freegps_innovv(data, tags);
375    }
376
377    // Type 15: Vantrue N4 (A at 28, [NS] at 40, [EW] at 56)
378    if data.len() > 80
379        && data[28] == b'A'
380        && is_ns(data[40])
381        && is_ew(data[56])
382    {
383        return process_freegps_vantrue_n4(data, tags);
384    }
385
386    // Type 20: Nextbase 512G binary (32-byte records starting at 0x32)
387    if data.len() > 0x50 {
388        return process_freegps_nextbase_binary(data, tags);
389    }
390
391    false
392}
393
394// ──── freeGPS Type 1: encrypted Azdome ────
395
396fn process_freegps_type1_encrypted(data: &[u8], tags: &mut Vec<Tag>) -> bool {
397    let n = (data.len() - 18).min(0x101);
398    let decrypted: Vec<u8> = data[18..18 + n].iter().map(|b| b ^ 0xaa).collect();
399
400    if decrypted.len() < 66 {
401        return false;
402    }
403
404    // date/time at decrypted[8..22]
405    let dt_bytes = &decrypted[8..22];
406    let dt_str = match std::str::from_utf8(dt_bytes) {
407        Ok(s) if s.chars().all(|c| c.is_ascii_digit()) => s,
408        _ => return false,
409    };
410    if dt_str.len() < 14 {
411        return false;
412    }
413    let yr = &dt_str[0..4];
414    let mo = &dt_str[4..6];
415    let dy = &dt_str[6..8];
416    let hr = &dt_str[8..10];
417    let mi = &dt_str[10..12];
418    let se = &dt_str[12..14];
419
420    // lat/lon: [NS] at decrypted[37], lat 8 digits at [38..46], [EW] at [46], lon 9 digits at [47..56]
421    if decrypted.len() < 57 {
422        return false;
423    }
424    let lat_ref = decrypted[37];
425    if lat_ref != b'N' && lat_ref != b'S' {
426        return false;
427    }
428    let lon_ref = decrypted[46];
429    if lon_ref != b'E' && lon_ref != b'W' {
430        return false;
431    }
432    let lat_str = match std::str::from_utf8(&decrypted[38..46]) {
433        Ok(s) if s.chars().all(|c| c.is_ascii_digit()) => s,
434        _ => return false,
435    };
436    let lon_str = match std::str::from_utf8(&decrypted[47..56]) {
437        Ok(s) if s.chars().all(|c| c.is_ascii_digit()) => s,
438        _ => return false,
439    };
440    let lat: f64 = lat_str.parse::<f64>().unwrap_or(0.0) / 1e4;
441    let lon: f64 = lon_str.parse::<f64>().unwrap_or(0.0) / 1e4;
442    let (lat_dd, lon_dd) = convert_lat_lon(lat, lon);
443    let lat_final = lat_dd * if lat_ref == b'S' { -1.0 } else { 1.0 };
444    let lon_final = lon_dd * if lon_ref == b'W' { -1.0 } else { 1.0 };
445
446    tags.push(mk_gps_dt(&format!(
447        "{}:{}:{} {}:{}:{}Z",
448        yr, mo, dy, hr, mi, se
449    )));
450    tags.push(mk_gps_lat(lat_final));
451    tags.push(mk_gps_lon(lon_final));
452
453    // speed: 8 digits at decrypted[56..64] (if present)
454    if decrypted.len() >= 65 {
455        if let Ok(s) = std::str::from_utf8(&decrypted[56..64]) {
456            if let Ok(spd) = s.trim_start_matches('0').parse::<f64>() {
457                tags.push(mk_gps_spd(spd));
458            }
459        }
460    }
461
462    true
463}
464
465// ──── freeGPS Type 2: NMEA in freeGPS ────
466
467fn process_freegps_type2_nmea(data: &[u8], tags: &mut Vec<Tag>) -> bool {
468    // Camera date/time at offset 52
469    if let Some(dt) = try_ascii_digits(&data[52..], 14) {
470        if dt.len() >= 14 {
471            let cam_dt = format!(
472                "{}:{}:{} {}:{}:{}",
473                &dt[0..4],
474                &dt[4..6],
475                &dt[6..8],
476                &dt[8..10],
477                &dt[10..12],
478                &dt[12..14]
479            );
480            tags.push(mk_stream(
481                "CameraDateTime",
482                "Camera Date/Time",
483                Value::String(cam_dt),
484            ));
485        }
486    }
487
488    // Search for NMEA RMC sentence in the data
489    let text = String::from_utf8_lossy(data);
490    if parse_nmea_rmc(&text, tags) {
491        return true;
492    }
493    if parse_nmea_gga(&text, tags) {
494        return true;
495    }
496    false
497}
498
499// ──── freeGPS Novatek binary (Type 17) ────
500
501fn process_freegps_novatek(data: &[u8], tags: &mut Vec<Tag>) -> bool {
502    // Offsets (from data start, LE):
503    // 0x30: hr, 0x34: min, 0x38: sec, 0x3c: yr-2000, 0x40: mon, 0x44: day
504    // 0x48: stat(A/V), 0x49: latRef, 0x4a: lonRef
505    // 0x4c: lat (float), 0x50: lon (float), 0x54: speed(knots,float), 0x58: heading(float)
506    if data.len() < 0x5c {
507        return false;
508    }
509    let hr = get_u32_le(data, 0x30);
510    let min = get_u32_le(data, 0x34);
511    let sec = get_u32_le(data, 0x38);
512    let yr = get_u32_le(data, 0x3c);
513    let mon = get_u32_le(data, 0x40);
514    let day = get_u32_le(data, 0x44);
515    let lat_ref = data[0x49];
516    let lon_ref = data[0x4a];
517
518    if mon < 1 || mon > 12 || day < 1 || day > 31 {
519        return false;
520    }
521
522    let full_yr = if yr < 2000 { yr + 2000 } else { yr };
523
524    let lat = get_f32_le(data, 0x4c) as f64;
525    let lon = get_f32_le(data, 0x50) as f64;
526    let spd = get_f32_le(data, 0x54) as f64 * KNOTS_TO_KPH;
527    let trk = get_f32_le(data, 0x58) as f64;
528
529    let (lat_dd, lon_dd) = convert_lat_lon(lat, lon);
530    let lat_final = lat_dd * if lat_ref == b'S' { -1.0 } else { 1.0 };
531    let lon_final = lon_dd * if lon_ref == b'W' { -1.0 } else { 1.0 };
532
533    tags.push(mk_gps_dt(&format!(
534        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}Z",
535        full_yr, mon, day, hr, min, sec
536    )));
537    tags.push(mk_gps_lat(lat_final));
538    tags.push(mk_gps_lon(lon_final));
539    tags.push(mk_gps_spd(spd));
540    tags.push(mk_gps_trk(trk));
541
542    true
543}
544
545// ──── freeGPS Viofo/Kenwood (Type 3) ────
546
547fn process_freegps_viofo(data: &[u8], extra_offset: usize, tags: &mut Vec<Tag>) -> bool {
548    let d = if extra_offset > 0 && data.len() > extra_offset {
549        &data[extra_offset..]
550    } else {
551        data
552    };
553    if d.len() < 0x3c {
554        return false;
555    }
556
557    let hr = get_u32_le(d, 0x10);
558    let min = get_u32_le(d, 0x14);
559    let sec = get_u32_le(d, 0x18);
560    let yr = get_u32_le(d, 0x1c);
561    let mon = get_u32_le(d, 0x20);
562    let day = get_u32_le(d, 0x24);
563
564    let lat_ref = d[0x29]; // N or S
565    let lon_ref = d[0x2a]; // E or W
566
567    if mon < 1 || mon > 12 || day < 1 || day > 31 {
568        return false;
569    }
570
571    let full_yr = if yr < 2000 { yr + 2000 } else { yr };
572
573    let lat = get_f32_le(d, 0x2c) as f64;
574    let lon = get_f32_le(d, 0x30) as f64;
575    let spd = get_f32_le(d, 0x34) as f64 * KNOTS_TO_KPH;
576    let trk = get_f32_le(d, 0x38) as f64;
577
578    tags.push(mk_gps_dt(&format!(
579        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}Z",
580        full_yr, mon, day, hr, min, sec
581    )));
582    tags.push(mk_gps_lat(lat * if lat_ref == b'S' { -1.0 } else { 1.0 }));
583    tags.push(mk_gps_lon(lon * if lon_ref == b'W' { -1.0 } else { 1.0 }));
584    tags.push(mk_gps_spd(spd));
585    tags.push(mk_gps_trk(trk));
586
587    true
588}
589
590// ──── freeGPS Akaso (Type 6) ────
591
592fn process_freegps_akaso(data: &[u8], tags: &mut Vec<Tag>) -> bool {
593    if data.len() < 0x58 {
594        return false;
595    }
596    let lat_ref = data[68];
597    let lon_ref = data[76];
598    let hr = get_u32_le(data, 48);
599    let min = get_u32_le(data, 52);
600    let sec = get_u32_le(data, 56);
601    let yr = get_u32_le(data, 84);
602    let mon = get_u32_le(data, 88);
603    let day = get_u32_le(data, 92);
604
605    if mon < 1 || mon > 12 {
606        return false;
607    }
608
609    let lat = get_f32_le(data, 0x40) as f64;
610    let lon = get_f32_le(data, 0x48) as f64;
611    let spd = get_f32_le(data, 0x50) as f64;
612    let trk = get_f32_le(data, 0x54) as f64;
613
614    tags.push(mk_gps_dt(&format!(
615        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}Z",
616        yr, mon, day, hr, min, sec
617    )));
618    tags.push(mk_gps_lat(lat * if lat_ref == b'S' { -1.0 } else { 1.0 }));
619    tags.push(mk_gps_lon(lon * if lon_ref == b'W' { -1.0 } else { 1.0 }));
620    tags.push(mk_gps_spd(spd));
621    tags.push(mk_gps_trk(trk));
622
623    true
624}
625
626// ──── freeGPS Vantrue S1 (Type 10) ────
627
628fn process_freegps_vantrue_s1(data: &[u8], tags: &mut Vec<Tag>) -> bool {
629    if data.len() < 0x70 {
630        return false;
631    }
632    let lat_ref = data[65];
633    let lon_ref = data[66];
634
635    let yr = get_u32_le(data, 68);
636    let mon = get_u32_le(data, 72);
637    let day = get_u32_le(data, 76);
638    let hr = get_u32_le(data, 80);
639    let min = get_u32_le(data, 84);
640    let sec = get_u32_le(data, 88);
641
642    if mon < 1 || mon > 12 || day < 1 || day > 31 {
643        return false;
644    }
645
646    let lon = get_f32_le(data, 0x5c) as f64;
647    let lat = get_f32_le(data, 0x60) as f64;
648    let spd = get_f32_le(data, 0x64) as f64 * KNOTS_TO_KPH;
649    let trk = get_f32_le(data, 0x68) as f64;
650    let alt = get_f32_le(data, 0x6c) as f64;
651
652    tags.push(mk_gps_dt(&format!(
653        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}Z",
654        yr, mon, day, hr, min, sec
655    )));
656    tags.push(mk_gps_lat(lat * if lat_ref == b'S' { -1.0 } else { 1.0 }));
657    tags.push(mk_gps_lon(lon * if lon_ref == b'W' { -1.0 } else { 1.0 }));
658    tags.push(mk_gps_spd(spd));
659    tags.push(mk_gps_trk(trk));
660    tags.push(mk_gps_alt(alt));
661
662    true
663}
664
665// ──── freeGPS Type 12: double lat/lon ────
666
667fn process_freegps_type12(data: &[u8], tags: &mut Vec<Tag>) -> bool {
668    if data.len() < 0x88 {
669        return false;
670    }
671    let lat_ref = data[72];
672    let lon_ref = data[88];
673
674    let hr = get_u32_le(data, 48);
675    let min = get_u32_le(data, 52);
676    let sec = get_u32_le(data, 56);
677    let yr = get_u32_le(data, 0x70);
678    let mon = get_u32_le(data, 0x74);
679    let day = get_u32_le(data, 0x78);
680
681    if mon < 1 || mon > 12 {
682        return false;
683    }
684
685    let full_yr = if yr < 2000 { yr + 2000 } else { yr };
686
687    let lat = get_f64_le(data, 0x40);
688    let lon = get_f64_le(data, 0x50);
689    let spd = get_f64_le(data, 0x60) * KNOTS_TO_KPH;
690    let trk = get_f64_le(data, 0x68);
691
692    let (lat_dd, lon_dd) = convert_lat_lon(lat, lon);
693
694    tags.push(mk_gps_dt(&format!(
695        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}Z",
696        full_yr, mon, day, hr, min, sec
697    )));
698    tags.push(mk_gps_lat(lat_dd * if lat_ref == b'S' { -1.0 } else { 1.0 }));
699    tags.push(mk_gps_lon(lon_dd * if lon_ref == b'W' { -1.0 } else { 1.0 }));
700    tags.push(mk_gps_spd(spd));
701    tags.push(mk_gps_trk(trk));
702
703    true
704}
705
706// ──── freeGPS INNOVV (Type 13) ────
707
708fn process_freegps_innovv(data: &[u8], tags: &mut Vec<Tag>) -> bool {
709    // Multiple 32-byte records starting at offset 16: A[NS][EW]\0 + 28 bytes
710    let mut pos = 16;
711    let mut found = false;
712    while pos + 32 <= data.len() {
713        if data[pos] != b'A' || !is_ns(data[pos + 1]) || !is_ew(data[pos + 2]) || data[pos + 3] != 0
714        {
715            break;
716        }
717        let lat_ref = data[pos + 1];
718        let lon_ref = data[pos + 2];
719        let lat = get_f32_le(data, pos + 4).abs() as f64;
720        let lon = get_f32_le(data, pos + 8).abs() as f64;
721        let spd = get_f32_le(data, pos + 12) as f64 * KNOTS_TO_KPH;
722        let trk = get_f32_le(data, pos + 16) as f64;
723
724        let (lat_dd, lon_dd) = convert_lat_lon(lat, lon);
725        tags.push(mk_gps_lat(lat_dd * if lat_ref == b'S' { -1.0 } else { 1.0 }));
726        tags.push(mk_gps_lon(lon_dd * if lon_ref == b'W' { -1.0 } else { 1.0 }));
727        tags.push(mk_gps_spd(spd));
728        tags.push(mk_gps_trk(trk));
729        found = true;
730        pos += 32;
731    }
732    found
733}
734
735// ──── freeGPS Vantrue N4 (Type 15) ────
736
737fn process_freegps_vantrue_n4(data: &[u8], tags: &mut Vec<Tag>) -> bool {
738    if data.len() < 80 {
739        return false;
740    }
741    let lat_ref = data[40];
742    let lon_ref = data[56];
743
744    let hr = get_u32_le(data, 16);
745    let min = get_u32_le(data, 20);
746    let sec = get_u32_le(data, 24);
747
748    // yr/mon/day at offset 80..92
749    if data.len() < 92 {
750        return false;
751    }
752    let yr = get_u32_le(data, 80);
753    let mon = get_u32_le(data, 84);
754    let day = get_u32_le(data, 88);
755
756    if mon < 1 || mon > 12 {
757        return false;
758    }
759
760    let lat = get_f64_le(data, 32).abs();
761    let lon = get_f64_le(data, 48).abs();
762    let spd = get_f64_le(data, 64) * KNOTS_TO_KPH;
763    let trk = get_f64_le(data, 72);
764
765    tags.push(mk_gps_dt(&format!(
766        "{:04}:{:02}:{:02} {:02}:{:02}:{:02}Z",
767        yr, mon, day, hr, min, sec
768    )));
769    tags.push(mk_gps_lat(lat * if lat_ref == b'S' { -1.0 } else { 1.0 }));
770    tags.push(mk_gps_lon(lon * if lon_ref == b'W' { -1.0 } else { 1.0 }));
771    tags.push(mk_gps_spd(spd));
772    tags.push(mk_gps_trk(trk));
773
774    true
775}
776
777// ──── freeGPS Nextbase 512G binary (Type 20) ────
778
779fn process_freegps_nextbase_binary(data: &[u8], tags: &mut Vec<Tag>) -> bool {
780    // 32-byte records at offset 0x32
781    // Big endian!
782    let mut pos = 0x32usize;
783    let mut found = false;
784    while pos + 0x1e <= data.len() {
785        let spd_raw = get_u16_be(data, pos);
786        let trk_raw = get_u16_be(data, pos + 2) as i16;
787        let yr = get_u16_be(data, pos + 4);
788        let mon = data[pos + 6];
789        let day = data[pos + 7];
790        let hr = data[pos + 8];
791        let min = data[pos + 9];
792        let sec10 = get_u16_be(data, pos + 10);
793
794        if yr < 2000 || yr > 2200 || mon < 1 || mon > 12 || day < 1 || day > 31 || hr > 59 || min > 59 || sec10 > 600
795        {
796            break;
797        }
798
799        let lat_raw = get_u32_be(data, pos + 13);
800        let lon_raw = get_u32_be(data, pos + 17);
801        let lat = signed_u32(lat_raw) as f64 / 1e7;
802        let lon = signed_u32(lon_raw) as f64 / 1e7;
803        let mut trk = trk_raw as f64 / 100.0;
804        if trk < 0.0 {
805            trk += 360.0;
806        }
807
808        let time = format!(
809            "{:04}:{:02}:{:02} {:02}:{:02}:{:04.1}Z",
810            yr,
811            mon,
812            day,
813            hr,
814            min,
815            sec10 as f64 / 10.0
816        );
817        tags.push(mk_gps_dt(&time));
818        tags.push(mk_gps_lat(lat));
819        tags.push(mk_gps_lon(lon));
820        tags.push(mk_gps_spd(spd_raw as f64 / 100.0 * MPS_TO_KPH));
821        tags.push(mk_gps_trk(trk));
822        found = true;
823
824        pos += 0x20;
825    }
826    found
827}
828
829// ─── CAMM processor (Google Street View Camera Motion Metadata) ───
830
831fn process_camm(data: &[u8], tags: &mut Vec<Tag>) -> bool {
832    if data.len() < 4 {
833        return false;
834    }
835    let camm_type = get_u16_le(data, 2);
836
837    match camm_type {
838        0 => {
839            // AngleAxis: 3 floats at offset 4
840            if data.len() >= 16 {
841                let x = get_f32_le(data, 4);
842                let y = get_f32_le(data, 8);
843                let z = get_f32_le(data, 12);
844                tags.push(mk_stream(
845                    "AngleAxis",
846                    "Angle Axis",
847                    Value::String(format!("{} {} {}", x, y, z)),
848                ));
849                return true;
850            }
851        }
852        2 => {
853            // AngularVelocity: 3 floats at offset 4
854            if data.len() >= 16 {
855                let x = get_f32_le(data, 4);
856                let y = get_f32_le(data, 8);
857                let z = get_f32_le(data, 12);
858                tags.push(mk_stream(
859                    "AngularVelocity",
860                    "Angular Velocity",
861                    Value::String(format!("{} {} {}", x, y, z)),
862                ));
863                return true;
864            }
865        }
866        3 => {
867            // Acceleration: 3 floats at offset 4
868            if data.len() >= 16 {
869                let x = get_f32_le(data, 4);
870                let y = get_f32_le(data, 8);
871                let z = get_f32_le(data, 12);
872                tags.push(mk_stream(
873                    "Accelerometer",
874                    "Accelerometer",
875                    Value::String(format!("{} {} {}", x, y, z)),
876                ));
877                return true;
878            }
879        }
880        5 => {
881            // GPS: lat(double), lon(double), alt(double) at offsets 4,12,20
882            if data.len() >= 28 {
883                let lat = get_f64_le(data, 4);
884                let lon = get_f64_le(data, 12);
885                let alt = get_f64_le(data, 20);
886                tags.push(mk_gps_lat(lat));
887                tags.push(mk_gps_lon(lon));
888                tags.push(mk_gps_alt(alt));
889                return true;
890            }
891        }
892        6 => {
893            // Full GPS: timestamp(double), mode(u32), lat(double), lon(double), alt(float), etc.
894            if data.len() >= 60 {
895                let _timestamp = get_f64_le(data, 4);
896                let lat = get_f64_le(data, 0x10);
897                let lon = get_f64_le(data, 0x18);
898                let alt = get_f32_le(data, 0x20) as f64;
899
900                tags.push(mk_gps_lat(lat));
901                tags.push(mk_gps_lon(lon));
902                tags.push(mk_gps_alt(alt));
903
904                if data.len() >= 0x38 {
905                    let vel_east = get_f32_le(data, 0x2c);
906                    let vel_north = get_f32_le(data, 0x30);
907                    let speed = ((vel_east * vel_east + vel_north * vel_north) as f64).sqrt()
908                        * MPS_TO_KPH;
909                    tags.push(mk_gps_spd(speed));
910                }
911                return true;
912            }
913        }
914        7 => {
915            // MagneticField: 3 floats at offset 4
916            if data.len() >= 16 {
917                let x = get_f32_le(data, 4);
918                let y = get_f32_le(data, 8);
919                let z = get_f32_le(data, 12);
920                tags.push(mk_stream(
921                    "MagneticField",
922                    "Magnetic Field",
923                    Value::String(format!("{} {} {}", x, y, z)),
924                ));
925                return true;
926            }
927        }
928        _ => {}
929    }
930    false
931}
932
933// ─── GoPro GPMF / gpmd processor ───
934
935fn process_gpmd(data: &[u8], tags: &mut Vec<Tag>) -> bool {
936    // GoPro GPMF uses KLV (Key-Length-Value) structure.
937    // We look for GPS5 (lat, lon, alt, speed2d, speed3d) entries.
938    process_gpmf_klv(data, 0, data.len(), tags)
939}
940
941fn process_gpmf_klv(data: &[u8], start: usize, end: usize, tags: &mut Vec<Tag>) -> bool {
942    let mut pos = start;
943    let mut found = false;
944
945    while pos + 8 <= end {
946        let fourcc = &data[pos..pos + 4];
947        let type_byte = data[pos + 4];
948        let size_byte = data[pos + 5];
949        let repeat = get_u16_be(data, pos + 6) as usize;
950
951        let struct_size = size_byte as usize;
952        let total_data = struct_size * repeat;
953        // Align to 4 bytes
954        let padded = (total_data + 3) & !3;
955        let data_start = pos + 8;
956
957        if data_start + padded > end {
958            break;
959        }
960
961        if type_byte == 0 && struct_size == 4 {
962            // Container: recurse
963            if process_gpmf_klv(data, data_start, data_start + total_data, tags) {
964                found = true;
965            }
966        } else if fourcc == b"GPS5" && struct_size >= 20 && type_byte == b'l' {
967            // GPS5: int32s[5] per sample: lat*1e7, lon*1e7, alt(cm), speed2d(cm/s), speed3d(cm/s)
968            for i in 0..repeat {
969                let off = data_start + i * struct_size;
970                if off + 20 > end {
971                    break;
972                }
973                let lat = get_i32_be(data, off) as f64 / 1e7;
974                let lon = get_i32_be(data, off + 4) as f64 / 1e7;
975                let alt = get_i32_be(data, off + 8) as f64 / 100.0;
976                let speed2d = get_i32_be(data, off + 12) as f64 / 100.0 * MPS_TO_KPH;
977
978                tags.push(mk_gps_lat(lat));
979                tags.push(mk_gps_lon(lon));
980                tags.push(mk_gps_alt(alt));
981                tags.push(mk_gps_spd(speed2d));
982                found = true;
983            }
984        } else if fourcc == b"GPSU" && type_byte == b'U' && total_data >= 16 {
985            // GPS UTC time: "yymmddhhmmss.sss"
986            if let Ok(s) = std::str::from_utf8(&data[data_start..data_start + total_data.min(16)])
987            {
988                let s = s.trim_end_matches('\0');
989                if s.len() >= 12 {
990                    let dt = format!(
991                        "20{}:{}:{} {}:{}:{}Z",
992                        &s[0..2],
993                        &s[2..4],
994                        &s[4..6],
995                        &s[6..8],
996                        &s[8..10],
997                        &s[10..]
998                    );
999                    tags.push(mk_gps_dt(&dt));
1000                    found = true;
1001                }
1002            }
1003        } else if fourcc == b"ACCL" && type_byte == b's' && struct_size >= 6 {
1004            // Accelerometer: int16s[3] per sample
1005            for i in 0..repeat.min(1) {
1006                let off = data_start + i * struct_size;
1007                if off + 6 > end {
1008                    break;
1009                }
1010                let x = get_i16_be(data, off) as f64 / 100.0;
1011                let y = get_i16_be(data, off + 2) as f64 / 100.0;
1012                let z = get_i16_be(data, off + 4) as f64 / 100.0;
1013                tags.push(mk_stream(
1014                    "Accelerometer",
1015                    "Accelerometer",
1016                    Value::String(format!("{:.4} {:.4} {:.4}", x, y, z)),
1017                ));
1018                found = true;
1019            }
1020        } else if fourcc == b"GYRO" && type_byte == b's' && struct_size >= 6 {
1021            // Gyroscope: int16s[3]
1022            for i in 0..repeat.min(1) {
1023                let off = data_start + i * struct_size;
1024                if off + 6 > end {
1025                    break;
1026                }
1027                let x = get_i16_be(data, off) as f64 / 100.0;
1028                let y = get_i16_be(data, off + 2) as f64 / 100.0;
1029                let z = get_i16_be(data, off + 4) as f64 / 100.0;
1030                tags.push(mk_stream(
1031                    "AngularVelocity",
1032                    "Angular Velocity",
1033                    Value::String(format!("{:.4} {:.4} {:.4}", x, y, z)),
1034                ));
1035                found = true;
1036            }
1037        }
1038
1039        pos = data_start + padded;
1040    }
1041
1042    found
1043}
1044
1045// ─── mebx (Apple metadata keys) processor ───
1046
1047fn process_mebx(data: &[u8], tags: &mut Vec<Tag>) -> bool {
1048    // mebx: size(4BE) + key(4) + data...
1049    let mut pos = 0;
1050    let mut found = false;
1051    while pos + 8 < data.len() {
1052        let len = get_u32_be(data, pos) as usize;
1053        if len < 8 || pos + len > data.len() {
1054            break;
1055        }
1056        let key = &data[pos + 4..pos + 8];
1057        let val_data = &data[pos + 8..pos + len];
1058
1059        // Try to decode as UTF-8 string
1060        if let Ok(s) = std::str::from_utf8(val_data) {
1061            let key_str = String::from_utf8_lossy(key).to_string();
1062            let name = key_str.trim().to_string();
1063            if !name.is_empty() {
1064                tags.push(mk_stream(&name, &name, Value::String(s.trim().to_string())));
1065                found = true;
1066            }
1067        }
1068        pos += len;
1069    }
1070    found
1071}
1072
1073// ─── tx3g subtitle processor ───
1074
1075fn process_tx3g(data: &[u8], tags: &mut Vec<Tag>) -> bool {
1076    if data.len() < 2 {
1077        return false;
1078    }
1079    let text = String::from_utf8_lossy(&data[2..]); // skip 2-byte length word
1080    let text = text.trim();
1081    if text.is_empty() {
1082        return false;
1083    }
1084
1085    // Autel Evo II drone: HOME(W: lon, N: lat) datetime
1086    if text.starts_with("HOME(") {
1087        return process_tx3g_autel(&text, tags);
1088    }
1089
1090    // Try key:value pairs
1091    let mut found = false;
1092    // Check for drone-style lat/lon pairs
1093    for line in text.lines() {
1094        let line = line.trim();
1095        // Simple key:value
1096        for cap in line.split_whitespace() {
1097            if let Some((k, v)) = cap.split_once(':') {
1098                match k {
1099                    "Lat" => {
1100                        if let Ok(val) = v.parse::<f64>() {
1101                            tags.push(mk_gps_lat(val));
1102                            found = true;
1103                        }
1104                    }
1105                    "Lon" => {
1106                        if let Ok(val) = v.parse::<f64>() {
1107                            tags.push(mk_gps_lon(val));
1108                            found = true;
1109                        }
1110                    }
1111                    "Alt" => {
1112                        if let Ok(val) = v.trim_end_matches('m').trim().parse::<f64>() {
1113                            tags.push(mk_gps_alt(val));
1114                            found = true;
1115                        }
1116                    }
1117                    _ => {}
1118                }
1119            }
1120        }
1121    }
1122
1123    if !found {
1124        // Just store as text
1125        tags.push(mk_stream("Text", "Text", Value::String(text.to_string())));
1126        // Try NMEA in text
1127        let _ = parse_nmea_rmc(text, tags) || parse_nmea_gga(text, tags);
1128        found = true;
1129    }
1130    found
1131}
1132
1133fn process_tx3g_autel(text: &str, tags: &mut Vec<Tag>) -> bool {
1134    let mut found = false;
1135    for line in text.lines() {
1136        let line = line.trim();
1137        // HOME(W: 109.318642, N: 40.769371) 2023-09-12 10:28:07
1138        if line.starts_with("HOME(") {
1139            // Parse lon/lat from HOME line
1140            if let Some(rest) = line.strip_prefix("HOME(") {
1141                if let Some(paren_end) = rest.find(')') {
1142                    let coords = &rest[..paren_end];
1143                    let after = rest[paren_end + 1..].trim();
1144                    // Parse two coord pairs
1145                    let parts: Vec<&str> = coords.split(',').collect();
1146                    if parts.len() == 2 {
1147                        for part in &parts {
1148                            let part = part.trim();
1149                            if let Some((dir, val_s)) = part.split_once(':') {
1150                                let dir = dir.trim();
1151                                let val_s = val_s.trim();
1152                                if let Ok(val) = val_s.parse::<f64>() {
1153                                    match dir {
1154                                        "N" | "S" => {
1155                                            let v = if dir == "S" { -val } else { val };
1156                                            tags.push(mk_stream(
1157                                                "GPSHomeLatitude",
1158                                                "GPS Home Latitude",
1159                                                Value::String(format!("{:.6}", v)),
1160                                            ));
1161                                            found = true;
1162                                        }
1163                                        "E" | "W" => {
1164                                            let v = if dir == "W" { -val } else { val };
1165                                            tags.push(mk_stream(
1166                                                "GPSHomeLongitude",
1167                                                "GPS Home Longitude",
1168                                                Value::String(format!("{:.6}", v)),
1169                                            ));
1170                                            found = true;
1171                                        }
1172                                        _ => {}
1173                                    }
1174                                }
1175                            }
1176                        }
1177                    }
1178                    // datetime after parenthesis
1179                    if !after.is_empty() {
1180                        let dt = after.replace('-', ":");
1181                        tags.push(mk_gps_dt(&dt));
1182                        found = true;
1183                    }
1184                }
1185            }
1186        } else if line.starts_with("GPS(") {
1187            // GPS(W: 109.339287, N: 40.768574, 2371.76m)
1188            if let Some(rest) = line.strip_prefix("GPS(") {
1189                if let Some(paren_end) = rest.find(')') {
1190                    let inner = &rest[..paren_end];
1191                    let parts: Vec<&str> = inner.split(',').collect();
1192                    for part in &parts {
1193                        let part = part.trim();
1194                        if let Some((dir, val_s)) = part.split_once(':') {
1195                            let dir = dir.trim();
1196                            let val_s = val_s.trim();
1197                            if let Ok(val) = val_s.parse::<f64>() {
1198                                match dir {
1199                                    "N" | "S" => {
1200                                        let v = if dir == "S" { -val } else { val };
1201                                        tags.push(mk_gps_lat(v));
1202                                        found = true;
1203                                    }
1204                                    "E" | "W" => {
1205                                        let v = if dir == "W" { -val } else { val };
1206                                        tags.push(mk_gps_lon(v));
1207                                        found = true;
1208                                    }
1209                                    _ => {}
1210                                }
1211                            }
1212                        } else if part.ends_with('m') {
1213                            if let Ok(alt) = part.trim_end_matches('m').trim().parse::<f64>() {
1214                                tags.push(mk_gps_alt(alt));
1215                                found = true;
1216                            }
1217                        }
1218                    }
1219                }
1220            }
1221        }
1222    }
1223    found
1224}
1225
1226// ─── NMEA processor ───
1227
1228fn process_nmea(data: &[u8], tags: &mut Vec<Tag>) -> bool {
1229    let text = String::from_utf8_lossy(data);
1230    parse_nmea_rmc(&text, tags) || parse_nmea_gga(&text, tags)
1231}
1232
1233fn parse_nmea_rmc(text: &str, tags: &mut Vec<Tag>) -> bool {
1234    // $GPRMC,HHMMSS.sss,A,DDMM.MMMM,N,DDDMM.MMMM,E,speed,track,DDMMYY,,,*CC
1235    // Find any xxRMC sentence
1236    let rmc_patterns = ["$GPRMC,", "$GNRMC,", "$GBRMC,"];
1237    for pat in &rmc_patterns {
1238        if let Some(start) = text.find(pat) {
1239            let rest = &text[start + pat.len()..];
1240            return parse_rmc_fields(rest, tags);
1241        }
1242    }
1243    false
1244}
1245
1246fn parse_rmc_fields(rest: &str, tags: &mut Vec<Tag>) -> bool {
1247    let fields: Vec<&str> = rest.split(',').collect();
1248    if fields.len() < 12 {
1249        return false;
1250    }
1251
1252    // fields[0]=time, [1]=status, [2]=lat, [3]=N/S, [4]=lon, [5]=E/W,
1253    // [6]=speed(knots), [7]=track, [8]=date(DDMMYY)
1254    let time_str = fields[0];
1255    let status = fields[1];
1256    if status != "A" && !status.is_empty() {
1257        // Accept empty status too (some devices)
1258    }
1259    let lat_str = fields[2];
1260    let lat_ref = fields[3];
1261    let lon_str = fields[4];
1262    let lon_ref = fields[5];
1263    let spd_str = fields[6];
1264    let trk_str = fields[7];
1265    let date_str = fields[8];
1266
1267    // Parse lat/lon from DDMM.MMMM format
1268    let lat = match parse_nmea_coord(lat_str) {
1269        Some(v) => v * if lat_ref == "S" { -1.0 } else { 1.0 },
1270        None => return false,
1271    };
1272    let lon = match parse_nmea_coord(lon_str) {
1273        Some(v) => v * if lon_ref == "W" { -1.0 } else { 1.0 },
1274        None => return false,
1275    };
1276
1277    // Parse date/time
1278    if date_str.len() >= 6 && time_str.len() >= 6 {
1279        let dd = &date_str[0..2];
1280        let mm = &date_str[2..4];
1281        let yy = &date_str[4..6];
1282        let yr: u32 = yy.parse().unwrap_or(0);
1283        let full_yr = if yr >= 70 { 1900 + yr } else { 2000 + yr };
1284        let time_part = if time_str.len() > 6 {
1285            &time_str[..6]
1286        } else {
1287            time_str
1288        };
1289        let dt = format!(
1290            "{:04}:{:02}:{:02} {}:{}:{}Z",
1291            full_yr,
1292            mm,
1293            dd,
1294            &time_part[0..2],
1295            &time_part[2..4],
1296            &time_part[4..6]
1297        );
1298        tags.push(mk_gps_dt(&dt));
1299    }
1300
1301    tags.push(mk_gps_lat(lat));
1302    tags.push(mk_gps_lon(lon));
1303
1304    if let Ok(spd) = spd_str.parse::<f64>() {
1305        tags.push(mk_gps_spd(spd * KNOTS_TO_KPH));
1306    }
1307    if let Ok(trk) = trk_str.parse::<f64>() {
1308        tags.push(mk_gps_trk(trk));
1309    }
1310
1311    true
1312}
1313
1314fn parse_nmea_gga(text: &str, tags: &mut Vec<Tag>) -> bool {
1315    let patterns = ["$GPGGA,", "$GNGGA,"];
1316    for pat in &patterns {
1317        if let Some(start) = text.find(pat) {
1318            let rest = &text[start + pat.len()..];
1319            let fields: Vec<&str> = rest.split(',').collect();
1320            if fields.len() < 10 {
1321                continue;
1322            }
1323
1324            let lat_str = fields[1];
1325            let lat_ref = fields[2];
1326            let lon_str = fields[3];
1327            let lon_ref = fields[4];
1328
1329            let lat = match parse_nmea_coord(lat_str) {
1330                Some(v) => v * if lat_ref == "S" { -1.0 } else { 1.0 },
1331                None => continue,
1332            };
1333            let lon = match parse_nmea_coord(lon_str) {
1334                Some(v) => v * if lon_ref == "W" { -1.0 } else { 1.0 },
1335                None => continue,
1336            };
1337
1338            tags.push(mk_gps_lat(lat));
1339            tags.push(mk_gps_lon(lon));
1340
1341            // Altitude at field 8
1342            if fields.len() > 8 {
1343                if let Ok(alt) = fields[8].parse::<f64>() {
1344                    tags.push(mk_gps_alt(alt));
1345                }
1346            }
1347            // Satellites at field 6
1348            if let Ok(sats) = fields[6].parse::<u32>() {
1349                tags.push(mk_stream(
1350                    "GPSSatellites",
1351                    "GPS Satellites",
1352                    Value::String(sats.to_string()),
1353                ));
1354            }
1355            return true;
1356        }
1357    }
1358    false
1359}
1360
1361fn parse_nmea_coord(s: &str) -> Option<f64> {
1362    // Format: DDMM.MMMM or DDDMM.MMMM
1363    if s.is_empty() {
1364        return None;
1365    }
1366    let val: f64 = s.parse().ok()?;
1367    let deg = (val / 100.0).floor();
1368    let min = val - deg * 100.0;
1369    Some(deg + min / 60.0)
1370}
1371
1372// ─── text track processor ───
1373
1374fn process_text(data: &[u8], tags: &mut Vec<Tag>) -> bool {
1375    let text = String::from_utf8_lossy(data);
1376    let text = text.trim();
1377    if text.is_empty() {
1378        return false;
1379    }
1380
1381    // Try NMEA
1382    if parse_nmea_rmc(text, tags) || parse_nmea_gga(text, tags) {
1383        return true;
1384    }
1385
1386    // DJI telemetry: "F/3.5, SS 1000, ISO 100, EV 0, GPS (8.6499, 53.1665, 18), ..."
1387    if text.contains("GPS (") || text.contains("GPS(") {
1388        return process_dji_text(text, tags);
1389    }
1390
1391    // Garmin PNDM format
1392    if data.len() >= 20 && (data.starts_with(b"PNDM") || (data.len() > 4 && &data[4..8.min(data.len())] == b"PNDM"))
1393    {
1394        return process_garmin_pndm(data, tags);
1395    }
1396
1397    false
1398}
1399
1400fn process_dji_text(text: &str, tags: &mut Vec<Tag>) -> bool {
1401    // GPS (lon, lat, alt)
1402    let gps_start = text.find("GPS (").or_else(|| text.find("GPS("));
1403    if let Some(idx) = gps_start {
1404        let rest = &text[idx..];
1405        if let Some(paren_start) = rest.find('(') {
1406            if let Some(paren_end) = rest.find(')') {
1407                let inner = &rest[paren_start + 1..paren_end];
1408                let parts: Vec<&str> = inner.split(',').collect();
1409                if parts.len() >= 2 {
1410                    if let (Ok(lon), Ok(lat)) = (
1411                        parts[0].trim().parse::<f64>(),
1412                        parts[1].trim().parse::<f64>(),
1413                    ) {
1414                        tags.push(mk_gps_lat(lat));
1415                        tags.push(mk_gps_lon(lon));
1416                        if parts.len() >= 3 {
1417                            if let Ok(alt) = parts[2].trim().parse::<f64>() {
1418                                tags.push(mk_gps_alt(alt));
1419                            }
1420                        }
1421                    }
1422                }
1423            }
1424        }
1425    }
1426
1427    // H.S speed
1428    if let Some(idx) = text.find("H.S ") {
1429        let rest = &text[idx + 4..];
1430        if let Some(end) = rest.find("m/s") {
1431            if let Ok(spd) = rest[..end].trim().parse::<f64>() {
1432                tags.push(mk_gps_spd(spd * MPS_TO_KPH));
1433            }
1434        }
1435    }
1436
1437    // ISO
1438    if let Some(idx) = text.find("ISO ") {
1439        let rest = &text[idx + 4..];
1440        let val: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1441        if !val.is_empty() {
1442            tags.push(mk_stream("ISO", "ISO", Value::String(val)));
1443        }
1444    }
1445
1446    true
1447}
1448
1449fn process_garmin_pndm(data: &[u8], tags: &mut Vec<Tag>) -> bool {
1450    let offset = if data.starts_with(b"PNDM") { 0 } else { 4 };
1451    if data.len() < offset + 20 {
1452        return false;
1453    }
1454    let lat = get_i32_be(data, offset + 12) as f64 * 180.0 / 0x80000000u32 as f64;
1455    let lon = get_i32_be(data, offset + 16) as f64 * 180.0 / 0x80000000u32 as f64;
1456    let spd = get_u16_be(data, offset + 8) as f64 * MPH_TO_KPH;
1457
1458    tags.push(mk_gps_lat(lat));
1459    tags.push(mk_gps_lon(lon));
1460    tags.push(mk_gps_spd(spd));
1461    true
1462}
1463
1464// ─── RVMI processor ───
1465
1466fn process_rvmi(data: &[u8], tags: &mut Vec<Tag>) -> bool {
1467    if data.len() < 20 {
1468        return false;
1469    }
1470    if &data[0..4] == b"gReV" {
1471        // GPS data
1472        let lat = get_i32_le(data, 4) as f64 / 1e6;
1473        let lon = get_i32_le(data, 8) as f64 / 1e6;
1474        let spd = get_i16_le(data, 16) as f64 / 10.0;
1475        let trk = get_u16_le(data, 18) as f64 * 2.0;
1476        tags.push(mk_gps_lat(lat));
1477        tags.push(mk_gps_lon(lon));
1478        tags.push(mk_gps_spd(spd));
1479        tags.push(mk_gps_trk(trk));
1480        return true;
1481    }
1482    if &data[0..4] == b"sReV" {
1483        // G-sensor data
1484        if data.len() >= 10 {
1485            let x = get_i16_le(data, 4) as f64 / 1000.0;
1486            let y = get_i16_le(data, 6) as f64 / 1000.0;
1487            let z = get_i16_le(data, 8) as f64 / 1000.0;
1488            tags.push(mk_stream(
1489                "GSensor",
1490                "G Sensor",
1491                Value::String(format!("{} {} {}", x, y, z)),
1492            ));
1493            return true;
1494        }
1495    }
1496    false
1497}
1498
1499// ─── Kenwood processor ───
1500
1501fn process_kenwood(data: &[u8], tags: &mut Vec<Tag>) -> bool {
1502    // Look for \xfe\xfe markers followed by GPS data
1503    let mut found = false;
1504    let mut pos = 0;
1505    while pos + 2 < data.len() {
1506        // Find \xfe\xfe
1507        if let Some(idx) = data[pos..].windows(2).position(|w| w == b"\xfe\xfe") {
1508            let start = pos + idx + 2;
1509            if start + 40 > data.len() {
1510                break;
1511            }
1512            let dat = &data[start..];
1513            // YYYYMMDDHHMMSS (14 bytes) + . + YYYYMMDDHHMMSS (14 bytes) + . + [NS]digits[EW]digits...
1514            if let Some(dt) = try_ascii_digits(dat, 14) {
1515                if dt.len() == 14 {
1516                    let time = format!(
1517                        "{}:{}:{} {}:{}:{}",
1518                        &dt[0..4],
1519                        &dt[4..6],
1520                        &dt[6..8],
1521                        &dt[8..10],
1522                        &dt[10..12],
1523                        &dt[12..14]
1524                    );
1525
1526                    // Skip past second datetime + separator
1527                    let after = &dat[15..]; // skip first 14 + separator
1528                    if after.len() < 20 {
1529                        pos = start + 14;
1530                        continue;
1531                    }
1532                    // Skip second date (14 digits + separator)
1533                    let after2 = if after.len() > 15 { &after[15..] } else { after };
1534
1535                    // [NS]digits[EW]digits
1536                    if !after2.is_empty() && is_ns(after2[0]) {
1537                        let lat_ref = after2[0];
1538                        // Find E or W
1539                        let mut ew_pos = 1;
1540                        while ew_pos < after2.len() && !is_ew(after2[ew_pos]) {
1541                            ew_pos += 1;
1542                        }
1543                        if ew_pos < after2.len() {
1544                            let lon_ref = after2[ew_pos];
1545                            let lat_digits = &after2[1..ew_pos];
1546                            // Find end of lon digits
1547                            let lon_start = ew_pos + 1;
1548                            let mut lon_end = lon_start;
1549                            while lon_end < after2.len() && after2[lon_end].is_ascii_digit() {
1550                                lon_end += 1;
1551                            }
1552                            let lon_digits = &after2[lon_start..lon_end];
1553
1554                            if let (Ok(lat_s), Ok(lon_s)) = (
1555                                std::str::from_utf8(lat_digits),
1556                                std::str::from_utf8(lon_digits),
1557                            ) {
1558                                if let (Ok(lat_raw), Ok(lon_raw)) =
1559                                    (lat_s.parse::<f64>(), lon_s.parse::<f64>())
1560                                {
1561                                    let lat = lat_raw / 1e4;
1562                                    let lon = lon_raw / 1e4;
1563                                    let (lat_dd, lon_dd) = convert_lat_lon(lat, lon);
1564
1565                                    tags.push(mk_gps_dt(&time));
1566                                    tags.push(mk_gps_lat(
1567                                        lat_dd * if lat_ref == b'S' { -1.0 } else { 1.0 },
1568                                    ));
1569                                    tags.push(mk_gps_lon(
1570                                        lon_dd * if lon_ref == b'W' { -1.0 } else { 1.0 },
1571                                    ));
1572                                    found = true;
1573
1574                                    // Try altitude and speed after lon
1575                                    if lon_end + 9 <= after2.len() {
1576                                        if let Ok(rest) =
1577                                            std::str::from_utf8(&after2[lon_end..lon_end + 9])
1578                                        {
1579                                            // +AAAA0SS (altitude+speed)
1580                                            if rest.starts_with('+') || rest.starts_with('-') {
1581                                                if let Ok(alt) = rest[0..5].parse::<f64>() {
1582                                                    tags.push(mk_gps_alt(alt));
1583                                                }
1584                                                if let Ok(spd) = rest[5..].parse::<f64>() {
1585                                                    tags.push(mk_gps_spd(spd));
1586                                                }
1587                                            }
1588                                        }
1589                                    }
1590                                }
1591                            }
1592                        }
1593                    }
1594                }
1595            }
1596            pos = start + 40;
1597        } else {
1598            break;
1599        }
1600    }
1601    found
1602}
1603
1604// ─── mdat scan for freeGPS ───
1605
1606fn scan_mdat_for_freegps(data: &[u8], tags: &mut Vec<Tag>, doc_count: &mut u32) {
1607    // Look for "\0..\0freeGPS " pattern in mdat region
1608    let pattern = b"freeGPS ";
1609    let mut pos = 0;
1610    let limit = data.len().min(20_000_000); // limit scan to first 20MB
1611
1612    while pos + 12 < limit {
1613        if let Some(idx) = data[pos..limit].windows(8).position(|w| w == pattern) {
1614            let abs_pos = pos + idx;
1615            // freeGPS header: 4 bytes before "freeGPS " is the atom size
1616            if abs_pos >= 4 {
1617                let atom_start = abs_pos - 4;
1618                let atom_size =
1619                    u32::from_be_bytes([data[atom_start], data[atom_start + 1], data[atom_start + 2], data[atom_start + 3]])
1620                        as usize;
1621                let atom_size = if atom_size < 12 { 12 } else { atom_size };
1622                let end = (atom_start + atom_size).min(data.len());
1623                let block = &data[atom_start..end];
1624
1625                let mut sample_tags = Vec::new();
1626                if process_freegps(block, &mut sample_tags) && !sample_tags.is_empty() {
1627                    *doc_count += 1;
1628                    for t in &mut sample_tags {
1629                        t.description = format!("{} (Doc{})", t.description, doc_count);
1630                    }
1631                    tags.extend(sample_tags);
1632                }
1633                pos = end;
1634            } else {
1635                pos = abs_pos + 8;
1636            }
1637        } else {
1638            break;
1639        }
1640    }
1641}
1642
1643// ─── helpers ───
1644
1645fn is_ns(b: u8) -> bool {
1646    b == b'N' || b == b'S'
1647}
1648fn is_ew(b: u8) -> bool {
1649    b == b'E' || b == b'W'
1650}
1651
1652/// Convert DDDMM.MMMM to decimal degrees
1653fn convert_lat_lon(lat: f64, lon: f64) -> (f64, f64) {
1654    let lat_deg = (lat / 100.0).floor();
1655    let lat_dd = lat_deg + (lat - lat_deg * 100.0) / 60.0;
1656    let lon_deg = (lon / 100.0).floor();
1657    let lon_dd = lon_deg + (lon - lon_deg * 100.0) / 60.0;
1658    (lat_dd, lon_dd)
1659}
1660
1661fn signed_u32(v: u32) -> i32 {
1662    if v < 0x80000000 {
1663        v as i32
1664    } else {
1665        v as i32 // wraps correctly in Rust
1666    }
1667}
1668
1669fn get_u16_be(data: &[u8], off: usize) -> u16 {
1670    u16::from_be_bytes([data[off], data[off + 1]])
1671}
1672
1673fn get_u16_le(data: &[u8], off: usize) -> u16 {
1674    u16::from_le_bytes([data[off], data[off + 1]])
1675}
1676
1677fn get_u32_be(data: &[u8], off: usize) -> u32 {
1678    u32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
1679}
1680
1681fn get_u32_le(data: &[u8], off: usize) -> u32 {
1682    u32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
1683}
1684
1685fn get_i32_be(data: &[u8], off: usize) -> i32 {
1686    i32::from_be_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
1687}
1688
1689fn get_i32_le(data: &[u8], off: usize) -> i32 {
1690    i32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
1691}
1692
1693fn get_i16_be(data: &[u8], off: usize) -> i16 {
1694    i16::from_be_bytes([data[off], data[off + 1]])
1695}
1696
1697fn get_i16_le(data: &[u8], off: usize) -> i16 {
1698    i16::from_le_bytes([data[off], data[off + 1]])
1699}
1700
1701fn get_f32_le(data: &[u8], off: usize) -> f32 {
1702    f32::from_le_bytes([data[off], data[off + 1], data[off + 2], data[off + 3]])
1703}
1704
1705fn get_f64_le(data: &[u8], off: usize) -> f64 {
1706    f64::from_le_bytes([
1707        data[off],
1708        data[off + 1],
1709        data[off + 2],
1710        data[off + 3],
1711        data[off + 4],
1712        data[off + 5],
1713        data[off + 6],
1714        data[off + 7],
1715    ])
1716}
1717
1718fn try_ascii_digits(data: &[u8], max_len: usize) -> Option<String> {
1719    let end = data.len().min(max_len);
1720    let slice = &data[..end];
1721    if slice.iter().all(|b| b.is_ascii_digit()) {
1722        Some(String::from_utf8_lossy(slice).to_string())
1723    } else {
1724        None
1725    }
1726}
1727
1728// ─── tag builders ───
1729
1730fn mk_stream(name: &str, description: &str, value: Value) -> Tag {
1731    let print_value = value.to_display_string();
1732    Tag {
1733        id: TagId::Text(name.to_string()),
1734        name: name.to_string(),
1735        description: description.to_string(),
1736        group: TagGroup {
1737            family0: "QuickTime".into(),
1738            family1: "QuickTime".into(),
1739            family2: "Location".into(),
1740        },
1741        raw_value: value,
1742        print_value,
1743        priority: 0,
1744    }
1745}
1746
1747fn mk_gps_dt(dt: &str) -> Tag {
1748    Tag {
1749        id: TagId::Text("GPSDateTime".into()),
1750        name: "GPSDateTime".into(),
1751        description: "GPS Date/Time".into(),
1752        group: TagGroup {
1753            family0: "QuickTime".into(),
1754            family1: "QuickTime".into(),
1755            family2: "Time".into(),
1756        },
1757        raw_value: Value::String(dt.to_string()),
1758        print_value: dt.to_string(),
1759        priority: 0,
1760    }
1761}
1762
1763fn mk_gps_lat(val: f64) -> Tag {
1764    let abs_val = val.abs();
1765    let d = abs_val.floor() as u32;
1766    let m_total = (abs_val - d as f64) * 60.0;
1767    let m = m_total.floor() as u32;
1768    let s = (m_total - m as f64) * 60.0;
1769    let ref_c = if val >= 0.0 { "N" } else { "S" };
1770    let print = format!("{} deg {}' {:.2}\" {}", d, m, s, ref_c);
1771    Tag {
1772        id: TagId::Text("GPSLatitude".into()),
1773        name: "GPSLatitude".into(),
1774        description: "GPS Latitude".into(),
1775        group: TagGroup {
1776            family0: "QuickTime".into(),
1777            family1: "QuickTime".into(),
1778            family2: "Location".into(),
1779        },
1780        raw_value: Value::F64(val),
1781        print_value: print,
1782        priority: 0,
1783    }
1784}
1785
1786fn mk_gps_lon(val: f64) -> Tag {
1787    let abs_val = val.abs();
1788    let d = abs_val.floor() as u32;
1789    let m_total = (abs_val - d as f64) * 60.0;
1790    let m = m_total.floor() as u32;
1791    let s = (m_total - m as f64) * 60.0;
1792    let ref_c = if val >= 0.0 { "E" } else { "W" };
1793    let print = format!("{} deg {}' {:.2}\" {}", d, m, s, ref_c);
1794    Tag {
1795        id: TagId::Text("GPSLongitude".into()),
1796        name: "GPSLongitude".into(),
1797        description: "GPS Longitude".into(),
1798        group: TagGroup {
1799            family0: "QuickTime".into(),
1800            family1: "QuickTime".into(),
1801            family2: "Location".into(),
1802        },
1803        raw_value: Value::F64(val),
1804        print_value: print,
1805        priority: 0,
1806    }
1807}
1808
1809fn mk_gps_alt(val: f64) -> Tag {
1810    Tag {
1811        id: TagId::Text("GPSAltitude".into()),
1812        name: "GPSAltitude".into(),
1813        description: "GPS Altitude".into(),
1814        group: TagGroup {
1815            family0: "QuickTime".into(),
1816            family1: "QuickTime".into(),
1817            family2: "Location".into(),
1818        },
1819        raw_value: Value::F64(val),
1820        print_value: format!("{:.4} m", val),
1821        priority: 0,
1822    }
1823}
1824
1825fn mk_gps_spd(val: f64) -> Tag {
1826    Tag {
1827        id: TagId::Text("GPSSpeed".into()),
1828        name: "GPSSpeed".into(),
1829        description: "GPS Speed".into(),
1830        group: TagGroup {
1831            family0: "QuickTime".into(),
1832            family1: "QuickTime".into(),
1833            family2: "Location".into(),
1834        },
1835        raw_value: Value::F64(val),
1836        print_value: format!("{:.4}", val),
1837        priority: 0,
1838    }
1839}
1840
1841fn mk_gps_trk(val: f64) -> Tag {
1842    Tag {
1843        id: TagId::Text("GPSTrack".into()),
1844        name: "GPSTrack".into(),
1845        description: "GPS Track".into(),
1846        group: TagGroup {
1847            family0: "QuickTime".into(),
1848            family1: "QuickTime".into(),
1849            family2: "Location".into(),
1850        },
1851        raw_value: Value::F64(val),
1852        print_value: format!("{:.4}", val),
1853        priority: 0,
1854    }
1855}