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