media-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. cli/clip.py +79 -0
  2. cli/faces.py +91 -0
  3. cli/metadata.py +68 -0
  4. cli/motion.py +77 -0
  5. cli/objects.py +94 -0
  6. cli/ocr.py +93 -0
  7. cli/scenes.py +57 -0
  8. cli/telemetry.py +65 -0
  9. cli/transcript.py +76 -0
  10. media_engine/__init__.py +7 -0
  11. media_engine/_version.py +34 -0
  12. media_engine/app.py +80 -0
  13. media_engine/batch/__init__.py +56 -0
  14. media_engine/batch/models.py +99 -0
  15. media_engine/batch/processor.py +1131 -0
  16. media_engine/batch/queue.py +232 -0
  17. media_engine/batch/state.py +30 -0
  18. media_engine/batch/timing.py +321 -0
  19. media_engine/cli.py +17 -0
  20. media_engine/config.py +674 -0
  21. media_engine/extractors/__init__.py +75 -0
  22. media_engine/extractors/clip.py +401 -0
  23. media_engine/extractors/faces.py +459 -0
  24. media_engine/extractors/frame_buffer.py +351 -0
  25. media_engine/extractors/frames.py +402 -0
  26. media_engine/extractors/metadata/__init__.py +127 -0
  27. media_engine/extractors/metadata/apple.py +169 -0
  28. media_engine/extractors/metadata/arri.py +118 -0
  29. media_engine/extractors/metadata/avchd.py +208 -0
  30. media_engine/extractors/metadata/avchd_gps.py +270 -0
  31. media_engine/extractors/metadata/base.py +688 -0
  32. media_engine/extractors/metadata/blackmagic.py +139 -0
  33. media_engine/extractors/metadata/camera_360.py +276 -0
  34. media_engine/extractors/metadata/canon.py +290 -0
  35. media_engine/extractors/metadata/dji.py +371 -0
  36. media_engine/extractors/metadata/dv.py +121 -0
  37. media_engine/extractors/metadata/ffmpeg.py +76 -0
  38. media_engine/extractors/metadata/generic.py +119 -0
  39. media_engine/extractors/metadata/gopro.py +256 -0
  40. media_engine/extractors/metadata/red.py +305 -0
  41. media_engine/extractors/metadata/registry.py +114 -0
  42. media_engine/extractors/metadata/sony.py +442 -0
  43. media_engine/extractors/metadata/tesla.py +157 -0
  44. media_engine/extractors/motion.py +765 -0
  45. media_engine/extractors/objects.py +245 -0
  46. media_engine/extractors/objects_qwen.py +754 -0
  47. media_engine/extractors/ocr.py +268 -0
  48. media_engine/extractors/scenes.py +82 -0
  49. media_engine/extractors/shot_type.py +217 -0
  50. media_engine/extractors/telemetry.py +262 -0
  51. media_engine/extractors/transcribe.py +579 -0
  52. media_engine/extractors/translate.py +121 -0
  53. media_engine/extractors/vad.py +263 -0
  54. media_engine/main.py +68 -0
  55. media_engine/py.typed +0 -0
  56. media_engine/routers/__init__.py +15 -0
  57. media_engine/routers/batch.py +78 -0
  58. media_engine/routers/health.py +93 -0
  59. media_engine/routers/models.py +211 -0
  60. media_engine/routers/settings.py +87 -0
  61. media_engine/routers/utils.py +135 -0
  62. media_engine/schemas.py +581 -0
  63. media_engine/utils/__init__.py +5 -0
  64. media_engine/utils/logging.py +54 -0
  65. media_engine/utils/memory.py +49 -0
  66. media_engine-0.1.0.dist-info/METADATA +276 -0
  67. media_engine-0.1.0.dist-info/RECORD +70 -0
  68. media_engine-0.1.0.dist-info/WHEEL +4 -0
  69. media_engine-0.1.0.dist-info/entry_points.txt +11 -0
  70. media_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,118 @@
1
+ """ARRI metadata extractor.
2
+
3
+ Detects ARRI cameras (ALEXA, ALEXA Mini, ALEXA 35, AMIRA) via:
4
+ - .ari extension (ARRIRAW)
5
+ - .arx extension (ARRIRAW HDE)
6
+ - .mxf with ARRI metadata
7
+
8
+ Note: Full ARRIRAW metadata requires ARRI Meta Extract tool (free download).
9
+ Without it, we detect the format but can't read detailed metadata.
10
+ """
11
+
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from media_engine.schemas import (
17
+ DetectionMethod,
18
+ DeviceInfo,
19
+ MediaDeviceType,
20
+ Metadata,
21
+ )
22
+
23
+ from .registry import register_extractor
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # ARRI camera models
28
+ ARRI_MODELS = {
29
+ "alexa35": "ALEXA 35",
30
+ "alexa 35": "ALEXA 35",
31
+ "alexamini": "ALEXA Mini",
32
+ "alexa mini": "ALEXA Mini",
33
+ "minilf": "ALEXA Mini LF",
34
+ "mini lf": "ALEXA Mini LF",
35
+ "alexalf": "ALEXA LF",
36
+ "alexa lf": "ALEXA LF",
37
+ "alexa65": "ALEXA 65",
38
+ "alexa 65": "ALEXA 65",
39
+ "amira": "AMIRA",
40
+ "alexa": "ALEXA",
41
+ }
42
+
43
+
44
+ class ArriExtractor:
45
+ """Extract metadata from ARRI cameras."""
46
+
47
+ def detect(self, probe_data: dict[str, Any], file_path: str) -> bool:
48
+ """Detect if this is an ARRI file."""
49
+ path = Path(file_path)
50
+
51
+ # ARRIRAW extensions
52
+ if path.suffix.lower() in (".ari", ".arx"):
53
+ return True
54
+
55
+ # Check for ARRI in metadata (for MXF/MOV files)
56
+ tags = probe_data.get("format", {}).get("tags", {})
57
+ for value in tags.values():
58
+ if "arri" in str(value).lower():
59
+ return True
60
+
61
+ # Check stream metadata
62
+ for stream in probe_data.get("streams", []):
63
+ stream_tags = stream.get("tags", {})
64
+ for value in stream_tags.values():
65
+ if "arri" in str(value).lower():
66
+ return True
67
+
68
+ return False
69
+
70
+ def extract(
71
+ self,
72
+ probe_data: dict[str, Any],
73
+ file_path: str,
74
+ base_metadata: Metadata,
75
+ ) -> Metadata:
76
+ """Extract ARRI-specific metadata."""
77
+ model = self._detect_model(probe_data, file_path)
78
+
79
+ device = DeviceInfo(
80
+ make="ARRI",
81
+ model=model,
82
+ type=MediaDeviceType.CINEMA_CAMERA,
83
+ detection_method=DetectionMethod.METADATA,
84
+ confidence=1.0,
85
+ )
86
+
87
+ base_metadata.device = device
88
+
89
+ # Note: For full ARRIRAW metadata, would need ARRI Meta Extract
90
+ # Log a hint for users
91
+ path = Path(file_path)
92
+ if path.suffix.lower() in (".ari", ".arx"):
93
+ logger.info("ARRIRAW detected. For full metadata, install ARRI Meta Extract.")
94
+
95
+ return base_metadata
96
+
97
+ def _detect_model(self, probe_data: dict[str, Any], file_path: str) -> str | None:
98
+ """Try to detect ARRI camera model."""
99
+ # Check all metadata for model hints
100
+ all_text = ""
101
+
102
+ tags = probe_data.get("format", {}).get("tags", {})
103
+ all_text += " ".join(str(v) for v in tags.values()).lower()
104
+
105
+ for stream in probe_data.get("streams", []):
106
+ stream_tags = stream.get("tags", {})
107
+ all_text += " ".join(str(v) for v in stream_tags.values()).lower()
108
+
109
+ # Search for known models
110
+ for model_key, model_name in ARRI_MODELS.items():
111
+ if model_key in all_text:
112
+ return model_name
113
+
114
+ return None
115
+
116
+
117
+ # Register the extractor
118
+ register_extractor("arri", ArriExtractor())
@@ -0,0 +1,208 @@
1
+ """AVCHD structure parsing for spanned recordings.
2
+
3
+ AVCHD cameras split long recordings at ~2GB boundaries (FAT32 limit).
4
+ This module detects which MTS files belong to the same recording by
5
+ analyzing timestamps - spanned clips have matching end/start times.
6
+
7
+ Structure:
8
+ AVCHD/
9
+ └── BDMV/
10
+ ├── CLIPINF/ # Clip info files (.CPI)
11
+ ├── PLAYLIST/ # Playlist files (.MPL)
12
+ ├── STREAM/ # Video files (.MTS)
13
+ └── INDEX.BDM # Index file
14
+ """
15
+
16
+ import logging
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class AVCHDClip:
26
+ """Information about a single AVCHD clip."""
27
+
28
+ file_path: str
29
+ clip_number: int
30
+ start_time: float # PTS start time in seconds
31
+ duration: float
32
+ file_size: int
33
+
34
+
35
+ @dataclass
36
+ class AVCHDRecording:
37
+ """A recording that may span multiple clips."""
38
+
39
+ clips: list[AVCHDClip]
40
+ total_duration: float
41
+
42
+ @property
43
+ def is_spanned(self) -> bool:
44
+ return len(self.clips) > 1
45
+
46
+ @property
47
+ def primary_file(self) -> str:
48
+ """The first file of the recording."""
49
+ return self.clips[0].file_path
50
+
51
+ @property
52
+ def all_files(self) -> list[str]:
53
+ """All files in this recording."""
54
+ return [c.file_path for c in self.clips]
55
+
56
+
57
+ def _get_clip_timing(file_path: str) -> tuple[float, float] | None:
58
+ """Get start time and duration from MTS file.
59
+
60
+ Returns:
61
+ Tuple of (start_time, duration) in seconds, or None if failed.
62
+ """
63
+ cmd = [
64
+ "ffprobe",
65
+ "-v",
66
+ "error",
67
+ "-show_entries",
68
+ "format=start_time,duration",
69
+ "-of",
70
+ "csv=p=0",
71
+ file_path,
72
+ ]
73
+ try:
74
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
75
+ if result.returncode != 0:
76
+ return None
77
+
78
+ parts = result.stdout.strip().split(",")
79
+ if len(parts) >= 2:
80
+ start_time = float(parts[0]) if parts[0] else 0
81
+ duration = float(parts[1]) if parts[1] else 0
82
+ return start_time, duration
83
+ except Exception as e:
84
+ logger.warning(f"Failed to get timing for {file_path}: {e}")
85
+
86
+ return None
87
+
88
+
89
+ def parse_avchd_structure(avchd_path: str) -> list[AVCHDRecording]:
90
+ """Parse AVCHD folder structure and identify spanned recordings.
91
+
92
+ Args:
93
+ avchd_path: Path to AVCHD folder or any file within it.
94
+
95
+ Returns:
96
+ List of recordings, each containing one or more clips.
97
+ """
98
+ path = Path(avchd_path)
99
+
100
+ # Find BDMV/STREAM folder
101
+ if path.is_file():
102
+ # Find AVCHD root from file path
103
+ for parent in path.parents:
104
+ stream_dir = parent / "BDMV" / "STREAM"
105
+ if stream_dir.exists():
106
+ break
107
+ # Check if we're in STREAM folder
108
+ if parent.name == "STREAM" and parent.parent.name == "BDMV":
109
+ stream_dir = parent
110
+ break
111
+ else:
112
+ return []
113
+ else:
114
+ stream_dir = path / "BDMV" / "STREAM"
115
+ if not stream_dir.exists():
116
+ return []
117
+
118
+ # Get all MTS files sorted by number
119
+ mts_files = sorted(stream_dir.glob("*.MTS"))
120
+ if not mts_files:
121
+ mts_files = sorted(stream_dir.glob("*.mts"))
122
+
123
+ if not mts_files:
124
+ return []
125
+
126
+ # Get timing for each clip
127
+ clips: list[AVCHDClip] = []
128
+ for mts_path in mts_files:
129
+ timing = _get_clip_timing(str(mts_path))
130
+ if timing is None:
131
+ continue
132
+
133
+ start_time, duration = timing
134
+ clip_num = int(mts_path.stem)
135
+
136
+ clips.append(
137
+ AVCHDClip(
138
+ file_path=str(mts_path),
139
+ clip_number=clip_num,
140
+ start_time=start_time,
141
+ duration=duration,
142
+ file_size=mts_path.stat().st_size,
143
+ )
144
+ )
145
+
146
+ if not clips:
147
+ return []
148
+
149
+ # Group clips into recordings based on timestamp continuity
150
+ # Spanned clips have start_time matching previous clip's end time
151
+ recordings: list[AVCHDRecording] = []
152
+ current_group: list[AVCHDClip] = [clips[0]]
153
+
154
+ for clip in clips[1:]:
155
+ prev_clip = current_group[-1]
156
+ prev_end_time = prev_clip.start_time + prev_clip.duration
157
+
158
+ # Check if this clip continues from previous (within 1 second tolerance)
159
+ if abs(clip.start_time - prev_end_time) < 1.0:
160
+ # This is a continuation (spanned recording)
161
+ current_group.append(clip)
162
+ else:
163
+ # New recording - save current group and start new one
164
+ total_dur = sum(c.duration for c in current_group)
165
+ recordings.append(AVCHDRecording(clips=current_group, total_duration=total_dur))
166
+ current_group = [clip]
167
+
168
+ # Don't forget the last group
169
+ if current_group:
170
+ total_dur = sum(c.duration for c in current_group)
171
+ recordings.append(AVCHDRecording(clips=current_group, total_duration=total_dur))
172
+
173
+ return recordings
174
+
175
+
176
+ def get_recording_for_file(file_path: str) -> AVCHDRecording | None:
177
+ """Get the recording that contains the given file.
178
+
179
+ Args:
180
+ file_path: Path to an MTS file.
181
+
182
+ Returns:
183
+ The AVCHDRecording containing this file, or None.
184
+ """
185
+ recordings = parse_avchd_structure(file_path)
186
+ file_path_resolved = str(Path(file_path).resolve())
187
+
188
+ for recording in recordings:
189
+ for clip in recording.clips:
190
+ if str(Path(clip.file_path).resolve()) == file_path_resolved:
191
+ return recording
192
+
193
+ return None
194
+
195
+
196
+ def is_spanned_continuation(file_path: str) -> bool:
197
+ """Check if this file is a continuation of a spanned recording.
198
+
199
+ Returns True if this file is NOT the first file of its recording.
200
+ """
201
+ recording = get_recording_for_file(file_path)
202
+ if recording is None or not recording.is_spanned:
203
+ return False
204
+
205
+ # Check if this is the first file
206
+ file_path_resolved = str(Path(file_path).resolve())
207
+ first_file = str(Path(recording.clips[0].file_path).resolve())
208
+ return file_path_resolved != first_file
@@ -0,0 +1,270 @@
1
+ """AVCHD GPS extraction from H.264 SEI MDPM data.
2
+
3
+ Sony AVCHD cameras (HXR-NX5, HDR-CX series, etc.) embed GPS data
4
+ in the H.264 video stream using MDPM (Modified Digital Video Pack
5
+ Metadata) within SEI NAL units.
6
+
7
+ The MDPM format is identified by UUID: 17ee8c60-f84d-11d9-8cd6-0800200c9a66
8
+ followed by "MDPM" marker, then tag-value pairs.
9
+
10
+ GPS tags (from ExifTool H264.pm):
11
+ - 0xB0: GPSVersionID
12
+ - 0xB1: GPSLatitudeRef ('N' or 'S')
13
+ - 0xB2-B4: GPSLatitude (degrees, minutes, seconds as rationals)
14
+ - 0xB5: GPSLongitudeRef ('E' or 'W')
15
+ - 0xB6-B8: GPSLongitude (degrees, minutes, seconds as rationals)
16
+ - 0xB9: GPSAltitudeRef (0=above sea level, 1=below)
17
+ - 0xBA: GPSAltitude
18
+ - 0xBB-BD: GPSTimeStamp (hours, minutes, seconds)
19
+ - 0xBE: GPSStatus ('A'=active, 'V'=void)
20
+ - 0xBF: GPSMeasureMode
21
+ - 0xC0: GPSDOP
22
+ - 0xC2: GPSSpeed
23
+ - 0xCA: GPSDateStamp
24
+
25
+ Each tag is 1 byte followed by 4 bytes of value data (typically rational).
26
+ """
27
+
28
+ import logging
29
+ from pathlib import Path
30
+
31
+ from media_engine.schemas import GPS, GPSTrack, GPSTrackPoint
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # MDPM UUID used by Sony for embedded metadata in H.264 SEI
36
+ MDPM_UUID = bytes.fromhex("17ee8c60f84d11d98cd60800200c9a66")
37
+
38
+
39
+ def _parse_rational(value_bytes: bytes) -> float:
40
+ """Parse 4-byte rational (2-byte numerator, 2-byte denominator)."""
41
+ num = (value_bytes[0] << 8) | value_bytes[1]
42
+ denom = (value_bytes[2] << 8) | value_bytes[3]
43
+ return num / denom if denom > 0 else float(num)
44
+
45
+
46
+ def _extract_gps_from_mdpm_block(mdpm_data: bytes) -> dict[str, float | str] | None:
47
+ """Extract GPS coordinates from a single MDPM block.
48
+
49
+ Returns dict with latitude, longitude, altitude, status or None if invalid.
50
+ """
51
+ # Find GPS section start (tag 0xB0)
52
+ try:
53
+ gps_start = mdpm_data.index(b"\xb0")
54
+ except ValueError:
55
+ return None
56
+
57
+ lat_ref: str | None = None
58
+ lat_deg: float = 0.0
59
+ lat_min: float = 0.0
60
+ lat_sec: float = 0.0
61
+ lon_ref: str | None = None
62
+ lon_deg: float = 0.0
63
+ lon_min: float = 0.0
64
+ lon_sec: float = 0.0
65
+ altitude: float | None = None
66
+ status: str = "A"
67
+
68
+ i = gps_start
69
+
70
+ while i < len(mdpm_data) - 5:
71
+ tag = mdpm_data[i]
72
+
73
+ # Stop if we've passed GPS section
74
+ if tag > 0xCA and tag < 0xE0:
75
+ break
76
+ if tag > 0xE6:
77
+ break
78
+
79
+ # Skip null bytes
80
+ if tag == 0x00:
81
+ i += 1
82
+ continue
83
+
84
+ value = mdpm_data[i + 1 : i + 5]
85
+
86
+ if tag == 0xB1 and value[0] in (ord("N"), ord("S")):
87
+ lat_ref = chr(value[0])
88
+ elif tag == 0xB2:
89
+ lat_deg = _parse_rational(value)
90
+ elif tag == 0xB3:
91
+ lat_min = _parse_rational(value)
92
+ elif tag == 0xB4:
93
+ lat_sec = _parse_rational(value)
94
+ elif tag == 0xB5 and value[0] in (ord("E"), ord("W")):
95
+ lon_ref = chr(value[0])
96
+ elif tag == 0xB6:
97
+ lon_deg = _parse_rational(value)
98
+ elif tag == 0xB7:
99
+ lon_min = _parse_rational(value)
100
+ elif tag == 0xB8:
101
+ lon_sec = _parse_rational(value)
102
+ elif tag == 0xBA:
103
+ altitude = _parse_rational(value)
104
+ elif tag == 0xBE and value[0] in (ord("A"), ord("V")):
105
+ status = chr(value[0])
106
+
107
+ i += 5
108
+
109
+ # Validate complete GPS reading
110
+ if lat_ref is None or lon_ref is None:
111
+ return None
112
+
113
+ # Convert to decimal degrees
114
+ lat = lat_deg + lat_min / 60 + lat_sec / 3600
115
+ if lat_ref == "S":
116
+ lat = -lat
117
+
118
+ lon = lon_deg + lon_min / 60 + lon_sec / 3600
119
+ if lon_ref == "W":
120
+ lon = -lon
121
+
122
+ result: dict[str, float | str] = {
123
+ "latitude": round(lat, 6),
124
+ "longitude": round(lon, 6),
125
+ "status": status,
126
+ }
127
+ if altitude is not None:
128
+ result["altitude"] = round(altitude, 1)
129
+
130
+ return result
131
+
132
+
133
+ def extract_avchd_gps(file_path: str) -> GPS | None:
134
+ """Extract GPS from AVCHD file embedded in H.264 SEI.
135
+
136
+ Sony AVCHD cameras embed GPS data in the H.264 video stream using
137
+ MDPM (Modified Digital Video Pack Metadata) within SEI NAL units.
138
+
139
+ Args:
140
+ file_path: Path to MTS/M2TS file
141
+
142
+ Returns:
143
+ GPS object with first valid GPS point, or None if no GPS found.
144
+ """
145
+ path = Path(file_path)
146
+
147
+ # Only process MTS/M2TS files (AVCHD)
148
+ if path.suffix.upper() not in (".MTS", ".M2TS"):
149
+ return None
150
+
151
+ try:
152
+ with open(file_path, "rb") as f:
153
+ # Detect packet size (188 for TS, 192 for MTS with timecode)
154
+ header = f.read(8)
155
+ f.seek(0)
156
+
157
+ if len(header) < 8:
158
+ return None
159
+
160
+ if header[4] == 0x47:
161
+ # 192-byte packets (4-byte timecode + 188-byte TS)
162
+ pass # packet_size = 192
163
+ elif header[0] == 0x47:
164
+ # Standard 188-byte TS packets
165
+ pass # packet_size = 188
166
+ else:
167
+ return None
168
+
169
+ # Read file to find MDPM blocks
170
+ data = f.read()
171
+
172
+ # Find first valid GPS point
173
+ pos = 0
174
+ while True:
175
+ pos = data.find(MDPM_UUID, pos)
176
+ if pos == -1:
177
+ break
178
+
179
+ # Skip UUID (16) + "MDPM" marker (4) = 20 bytes
180
+ mdpm_start = pos + 20
181
+ mdpm_data = data[mdpm_start : mdpm_start + 200]
182
+
183
+ gps_dict = _extract_gps_from_mdpm_block(mdpm_data)
184
+
185
+ if gps_dict and gps_dict.get("status") == "A":
186
+ lat = gps_dict["latitude"]
187
+ lon = gps_dict["longitude"]
188
+ alt = gps_dict.get("altitude")
189
+
190
+ # Type narrowing for pyright
191
+ if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
192
+ gps = GPS(
193
+ latitude=float(lat),
194
+ longitude=float(lon),
195
+ altitude=float(alt) if isinstance(alt, (int, float)) else None,
196
+ )
197
+ logger.info(f"Extracted GPS from AVCHD SEI: {lat:.6f}, {lon:.6f}")
198
+ return gps
199
+
200
+ pos += 1
201
+
202
+ return None
203
+
204
+ except Exception as e:
205
+ logger.warning(f"Failed to extract AVCHD GPS from {file_path}: {e}")
206
+ return None
207
+
208
+
209
+ def extract_avchd_gps_track(file_path: str, max_points: int = 10000) -> GPSTrack | None:
210
+ """Extract full GPS track from AVCHD file.
211
+
212
+ Args:
213
+ file_path: Path to MTS/M2TS file
214
+ max_points: Maximum number of GPS points to extract
215
+
216
+ Returns:
217
+ GPSTrack object with all GPS points, or None if no GPS found.
218
+ """
219
+ path = Path(file_path)
220
+
221
+ if path.suffix.upper() not in (".MTS", ".M2TS"):
222
+ return None
223
+
224
+ try:
225
+ with open(file_path, "rb") as f:
226
+ data = f.read()
227
+
228
+ gps_points: list[GPSTrackPoint] = []
229
+ last_lat: float | None = None
230
+ last_lon: float | None = None
231
+ pos = 0
232
+
233
+ while len(gps_points) < max_points:
234
+ pos = data.find(MDPM_UUID, pos)
235
+ if pos == -1:
236
+ break
237
+
238
+ mdpm_start = pos + 20
239
+ mdpm_data = data[mdpm_start : mdpm_start + 200]
240
+
241
+ gps_dict = _extract_gps_from_mdpm_block(mdpm_data)
242
+
243
+ if gps_dict and gps_dict.get("status") == "A":
244
+ lat = gps_dict["latitude"]
245
+ lon = gps_dict["longitude"]
246
+ alt = gps_dict.get("altitude")
247
+
248
+ if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
249
+ # Dedupe consecutive identical points
250
+ if lat != last_lat or lon != last_lon:
251
+ point = GPSTrackPoint(
252
+ latitude=float(lat),
253
+ longitude=float(lon),
254
+ altitude=(float(alt) if isinstance(alt, (int, float)) else None),
255
+ )
256
+ gps_points.append(point)
257
+ last_lat = float(lat)
258
+ last_lon = float(lon)
259
+
260
+ pos += 1
261
+
262
+ if gps_points:
263
+ logger.info(f"Extracted {len(gps_points)} GPS points from AVCHD SEI")
264
+ return GPSTrack(points=gps_points, source="avchd_sei")
265
+
266
+ return None
267
+
268
+ except Exception as e:
269
+ logger.warning(f"Failed to extract AVCHD GPS track from {file_path}: {e}")
270
+ return None