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