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