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,442 @@
1
+ """Sony metadata extraction.
2
+
3
+ Handles Sony cameras:
4
+ - Professional: FX6, FX3, FX9, Venice
5
+ - Alpha series: A7S, A7R, A1, etc.
6
+ - Consumer: ZV-1, ZV-E1, etc.
7
+ - AVCHD camcorders: HXR-NX5, HDR-CX series, etc.
8
+ - XDCAM: PMW-EX1, PMW-EX3, PMW-200, etc.
9
+
10
+ Detection methods:
11
+ - make tag: "Sony"
12
+ - major_brand: "XAVC"
13
+ - XML sidecar files (M01.XML pattern)
14
+ - Embedded XML in com.sony.bprl.mxf.nrtmetadata tag (XDCAM)
15
+ - AVCHD structure with embedded GPS in H.264 SEI
16
+
17
+ Sony XML sidecar files contain:
18
+ - Device info (manufacturer, modelName)
19
+ - GPS coordinates (ExifGPS group)
20
+ - Color space (CaptureGammaEquation, CaptureColorPrimaries)
21
+ - Lens info (FocalLength, FNumber, etc.)
22
+ """
23
+
24
+ import logging
25
+ import xml.etree.ElementTree as ET
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from media_engine.schemas import (
30
+ GPS,
31
+ ColorSpace,
32
+ DetectionMethod,
33
+ DeviceInfo,
34
+ LensInfo,
35
+ MediaDeviceType,
36
+ Metadata,
37
+ SpannedRecording,
38
+ )
39
+
40
+ from .avchd import get_recording_for_file
41
+ from .avchd_gps import extract_avchd_gps, extract_avchd_gps_track
42
+ from .base import SidecarMetadata, parse_dms_coordinate
43
+ from .registry import get_tags_lower, register_extractor
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ def _parse_xml_sidecar(video_path: str) -> SidecarMetadata | None:
49
+ """Parse Sony XML sidecar file for additional metadata.
50
+
51
+ Sony cameras create XML sidecar files with naming pattern:
52
+ - Video: 20251014_C0476.MP4
53
+ - XML: 20251014_C0476M01.XML
54
+ """
55
+ path = Path(video_path)
56
+
57
+ xml_patterns = [
58
+ path.with_suffix(".XML"),
59
+ path.parent / f"{path.stem}M01.XML",
60
+ path.parent / f"{path.stem}M01.xml",
61
+ ]
62
+
63
+ xml_path = None
64
+ for pattern in xml_patterns:
65
+ if pattern.exists():
66
+ xml_path = pattern
67
+ break
68
+
69
+ if not xml_path:
70
+ return None
71
+
72
+ try:
73
+ tree = ET.parse(xml_path)
74
+ root = tree.getroot()
75
+
76
+ ns = {"nrt": "urn:schemas-professionalDisc:nonRealTimeMeta:ver.2.20"}
77
+
78
+ device: DeviceInfo | None = None
79
+ gps: GPS | None = None
80
+ color_space: ColorSpace | None = None
81
+ lens: LensInfo | None = None
82
+
83
+ # Extract device info
84
+ device_elem = root.find(".//nrt:Device", ns) or root.find(".//{*}Device")
85
+ if device_elem is not None:
86
+ manufacturer = device_elem.get("manufacturer")
87
+ model_name = device_elem.get("modelName")
88
+
89
+ if manufacturer or model_name:
90
+ device = DeviceInfo(
91
+ make=manufacturer,
92
+ model=model_name,
93
+ software=None,
94
+ type=MediaDeviceType.CAMERA,
95
+ detection_method=DetectionMethod.XML_SIDECAR,
96
+ confidence=1.0,
97
+ )
98
+
99
+ # Extract GPS from ExifGPS or GPSinExif group (different Sony models use different names)
100
+ gps_group = root.find(".//{*}Group[@name='ExifGPS']")
101
+ if gps_group is None:
102
+ gps_group = root.find(".//{*}Group[@name='GPSinExif']")
103
+ if gps_group is not None:
104
+ gps_items: dict[str, str | None] = {}
105
+ for item in gps_group.findall(".//{*}Item"):
106
+ name = item.get("name")
107
+ if name is not None:
108
+ gps_items[name] = item.get("value")
109
+
110
+ if gps_items.get("Status") != "V":
111
+ lat_str = gps_items.get("Latitude")
112
+ lon_str = gps_items.get("Longitude")
113
+ lat_ref = gps_items.get("LatitudeRef")
114
+ lon_ref = gps_items.get("LongitudeRef")
115
+ alt_str = gps_items.get("Altitude")
116
+
117
+ if lat_str and lon_str:
118
+ lat = parse_dms_coordinate(lat_str, lat_ref)
119
+ lon = parse_dms_coordinate(lon_str, lon_ref)
120
+
121
+ if lat is not None and lon is not None:
122
+ try:
123
+ gps = GPS(
124
+ latitude=lat,
125
+ longitude=lon,
126
+ altitude=float(alt_str) if alt_str else None,
127
+ )
128
+ except ValueError:
129
+ pass
130
+
131
+ # Extract color space
132
+ color_items: dict[str, str | None] = {}
133
+ for group_name in ["CameraUnitMetadata", "VideoLayout", "AcquisitionRecord"]:
134
+ group = root.find(f".//*[@name='{group_name}']")
135
+ if group is not None:
136
+ for item in group.findall(".//{*}Item"):
137
+ name = item.get("name")
138
+ if name:
139
+ color_items[name] = item.get("value")
140
+
141
+ for item in root.findall(".//{*}Item"):
142
+ name = item.get("name")
143
+ if name and name in [
144
+ "CaptureGammaEquation",
145
+ "CaptureColorPrimaries",
146
+ "CodingEquations",
147
+ ]:
148
+ color_items[name] = item.get("value")
149
+
150
+ lut_file: str | None = None
151
+ for related in root.findall(".//{*}RelatedTo"):
152
+ if related.get("rel") == "LUT":
153
+ lut_file = related.get("file")
154
+ break
155
+
156
+ gamma = color_items.get("CaptureGammaEquation")
157
+ primaries = color_items.get("CaptureColorPrimaries")
158
+ coding = color_items.get("CodingEquations")
159
+
160
+ if gamma or primaries or coding or lut_file:
161
+ color_space = ColorSpace(
162
+ transfer=gamma,
163
+ primaries=primaries,
164
+ matrix=coding,
165
+ lut_file=lut_file,
166
+ detection_method=DetectionMethod.XML_SIDECAR,
167
+ )
168
+
169
+ # Extract lens info
170
+ lens_items: dict[str, str | None] = {}
171
+ for group_name in ["Camera", "Lens", "CameraUnitMetadata"]:
172
+ group = root.find(f".//*[@name='{group_name}']")
173
+ if group is not None:
174
+ for item in group.findall(".//{*}Item"):
175
+ name = item.get("name")
176
+ if name:
177
+ lens_items[name] = item.get("value")
178
+
179
+ for item in root.findall(".//{*}Item"):
180
+ name = item.get("name")
181
+ if name and name in [
182
+ "FocalLength",
183
+ "FocalLength35mm",
184
+ "FocalLengthIn35mmFilm",
185
+ "FNumber",
186
+ "Iris",
187
+ "FocusDistance",
188
+ ]:
189
+ lens_items[name] = item.get("value")
190
+
191
+ focal_length = lens_items.get("FocalLength")
192
+ focal_35mm = lens_items.get("FocalLength35mm") or lens_items.get("FocalLengthIn35mmFilm")
193
+ f_number = lens_items.get("FNumber")
194
+ iris = lens_items.get("Iris")
195
+ focus_dist = lens_items.get("FocusDistance")
196
+
197
+ if focal_length or focal_35mm or f_number or iris:
198
+ lens = LensInfo(
199
+ focal_length=float(focal_length) if focal_length else None,
200
+ focal_length_35mm=float(focal_35mm) if focal_35mm else None,
201
+ aperture=float(f_number) if f_number else None,
202
+ focus_distance=float(focus_dist) if focus_dist else None,
203
+ iris=iris,
204
+ detection_method=DetectionMethod.XML_SIDECAR,
205
+ )
206
+
207
+ if device or gps or color_space or lens:
208
+ return SidecarMetadata(device=device, gps=gps, color_space=color_space, lens=lens)
209
+ return None
210
+
211
+ except ET.ParseError as e:
212
+ logger.warning(f"Failed to parse Sony XML sidecar {xml_path}: {e}")
213
+ return None
214
+ except Exception as e:
215
+ logger.warning(f"Error reading Sony XML sidecar {xml_path}: {e}")
216
+ return None
217
+
218
+
219
+ def _parse_embedded_xml(probe_data: dict[str, Any]) -> SidecarMetadata | None:
220
+ """Parse embedded XML from Sony XDCAM com.sony.bprl.mxf.nrtmetadata tag.
221
+
222
+ XDCAM cameras (PMW-EX1, PMW-EX3, etc.) embed metadata as XML in the
223
+ format tags. Example:
224
+ <Device manufacturer="Sony" modelName="PMW-EX1" serialNo="0404626"/>
225
+ <Lens modelName="XT14X5.8"/>
226
+ """
227
+ format_tags = probe_data.get("format", {}).get("tags", {})
228
+
229
+ # Check for Sony XDCAM embedded metadata tag
230
+ nrt_metadata = format_tags.get("com.sony.bprl.mxf.nrtmetadata")
231
+ if not nrt_metadata:
232
+ return None
233
+
234
+ try:
235
+ # The metadata may be a fragment without root element, wrap it
236
+ xml_content = nrt_metadata.strip()
237
+ if not xml_content.startswith("<?xml"):
238
+ xml_content = f"<root>{xml_content}</root>"
239
+
240
+ root = ET.fromstring(xml_content)
241
+
242
+ device: DeviceInfo | None = None
243
+ lens: LensInfo | None = None
244
+
245
+ # Extract device info (use {*} wildcard for namespace handling)
246
+ device_elem = root.find(".//{*}Device")
247
+ if device_elem is not None:
248
+ manufacturer = device_elem.get("manufacturer")
249
+ model_name = device_elem.get("modelName")
250
+ serial_no = device_elem.get("serialNo")
251
+
252
+ if manufacturer or model_name:
253
+ device = DeviceInfo(
254
+ make=manufacturer,
255
+ model=model_name,
256
+ serial_number=serial_no,
257
+ type=MediaDeviceType.CAMERA,
258
+ detection_method=DetectionMethod.METADATA,
259
+ confidence=1.0,
260
+ )
261
+ logger.info(f"Extracted XDCAM device from embedded XML: {manufacturer} {model_name}")
262
+
263
+ # Extract lens info (use {*} wildcard for namespace handling)
264
+ lens_elem = root.find(".//{*}Lens")
265
+ if lens_elem is not None:
266
+ lens_model = lens_elem.get("modelName")
267
+ if lens_model:
268
+ lens = LensInfo(
269
+ model=lens_model,
270
+ detection_method=DetectionMethod.METADATA,
271
+ )
272
+ logger.info(f"Extracted lens from embedded XML: {lens_model}")
273
+
274
+ if device or lens:
275
+ return SidecarMetadata(device=device, lens=lens)
276
+
277
+ return None
278
+
279
+ except ET.ParseError as e:
280
+ logger.warning(f"Failed to parse embedded XDCAM XML: {e}")
281
+ return None
282
+ except Exception as e:
283
+ logger.warning(f"Error parsing embedded XDCAM XML: {e}")
284
+ return None
285
+
286
+
287
+ class SonyExtractor:
288
+ """Metadata extractor for Sony cameras."""
289
+
290
+ def detect(self, probe_data: dict[str, Any], file_path: str) -> bool:
291
+ """Detect if file is from a Sony camera."""
292
+ tags = get_tags_lower(probe_data)
293
+
294
+ # Check make tag
295
+ make = tags.get("make") or tags.get("manufacturer")
296
+ if make and "SONY" in make.upper():
297
+ return True
298
+
299
+ # Check major_brand for XAVC
300
+ major_brand = tags.get("major_brand", "")
301
+ if major_brand.upper() == "XAVC":
302
+ return True
303
+
304
+ # Check for embedded XDCAM metadata tag
305
+ format_tags = probe_data.get("format", {}).get("tags", {})
306
+ if "com.sony.bprl.mxf.nrtmetadata" in format_tags:
307
+ return True
308
+
309
+ # Check for AVCHD structure (common for Sony camcorders)
310
+ # Path patterns:
311
+ # - .../PRIVATE/AVCHD/BDMV/STREAM/*.MTS (consumer Sony)
312
+ # - .../AVCHD/BDMV/STREAM/*.MTS (NX-CAM and other pro Sony)
313
+ path = Path(file_path)
314
+ if path.suffix.upper() in (".MTS", ".M2TS"):
315
+ # Check if in AVCHD/BDMV/STREAM folder structure
316
+ parts = [p.upper() for p in path.parts]
317
+ if "AVCHD" in parts and "BDMV" in parts and "STREAM" in parts:
318
+ return True
319
+
320
+ # Check for Sony XML sidecar
321
+ xml_patterns = [
322
+ path.with_suffix(".XML"),
323
+ path.parent / f"{path.stem}M01.XML",
324
+ path.parent / f"{path.stem}M01.xml",
325
+ ]
326
+ for pattern in xml_patterns:
327
+ if pattern.exists():
328
+ # Verify it's a Sony XML by checking namespace
329
+ try:
330
+ tree = ET.parse(pattern)
331
+ root = tree.getroot()
332
+ # Check for Sony namespace or Device manufacturer
333
+ if "professionalDisc" in str(root.tag).lower():
334
+ return True
335
+ device = root.find(".//{*}Device")
336
+ if device is not None:
337
+ mfr = device.get("manufacturer", "")
338
+ if "Sony" in mfr:
339
+ return True
340
+ except Exception:
341
+ pass
342
+
343
+ return False
344
+
345
+ def extract(self, probe_data: dict[str, Any], file_path: str, base_metadata: Metadata) -> Metadata:
346
+ """Extract Sony-specific metadata."""
347
+ tags = get_tags_lower(probe_data)
348
+
349
+ # Get basic device info from tags
350
+ make = tags.get("make") or tags.get("manufacturer") or "Sony"
351
+ model = tags.get("model") or tags.get("model_name")
352
+
353
+ # Parse XML sidecar for detailed metadata
354
+ sidecar = _parse_xml_sidecar(file_path)
355
+
356
+ # Try embedded XML if no sidecar (XDCAM format)
357
+ embedded = None
358
+ if sidecar is None:
359
+ embedded = _parse_embedded_xml(probe_data)
360
+
361
+ # Build device info (prefer sidecar > embedded > basic tags)
362
+ if sidecar and sidecar.device:
363
+ device = sidecar.device
364
+ elif embedded and embedded.device:
365
+ device = embedded.device
366
+ else:
367
+ device = DeviceInfo(
368
+ make=make if make else "Sony",
369
+ model=model,
370
+ software=tags.get("software"),
371
+ type=MediaDeviceType.CAMERA,
372
+ detection_method=DetectionMethod.METADATA,
373
+ confidence=1.0,
374
+ )
375
+
376
+ # Merge metadata (prefer sidecar > embedded > base)
377
+ gps = sidecar.gps if sidecar and sidecar.gps else base_metadata.gps
378
+ color_space = sidecar.color_space if sidecar and sidecar.color_space else base_metadata.color_space
379
+
380
+ # Get lens info (prefer sidecar > embedded > base)
381
+ if sidecar and sidecar.lens:
382
+ lens = sidecar.lens
383
+ elif embedded and embedded.lens:
384
+ lens = embedded.lens
385
+ else:
386
+ lens = base_metadata.lens
387
+
388
+ # Try to extract GPS from AVCHD SEI if not already found
389
+ gps_track = None
390
+ if gps is None:
391
+ gps = extract_avchd_gps(file_path)
392
+ # Also extract full track if GPS was found
393
+ if gps is not None:
394
+ gps_track = extract_avchd_gps_track(file_path)
395
+
396
+ # Check for spanned recordings (AVCHD files split at 2GB)
397
+ spanned_recording = None
398
+ path = Path(file_path)
399
+ if path.suffix.upper() in (".MTS", ".M2TS"):
400
+ recording = get_recording_for_file(file_path)
401
+ if recording and recording.is_spanned:
402
+ # Find this file's position in the recording
403
+ file_resolved = str(path.resolve())
404
+ file_index = 0
405
+ sibling_files = []
406
+ for i, clip in enumerate(recording.clips):
407
+ clip_resolved = str(Path(clip.file_path).resolve())
408
+ if clip_resolved == file_resolved:
409
+ file_index = i
410
+ else:
411
+ sibling_files.append(Path(clip.file_path).name)
412
+
413
+ spanned_recording = SpannedRecording(
414
+ is_continuation=(file_index > 0),
415
+ sibling_files=sibling_files,
416
+ total_duration=recording.total_duration,
417
+ file_index=file_index,
418
+ )
419
+ logger.info(f"Detected spanned recording: file {file_index + 1} of {len(recording.clips)}, " f"total duration {recording.total_duration:.1f}s")
420
+
421
+ return Metadata(
422
+ duration=base_metadata.duration,
423
+ resolution=base_metadata.resolution,
424
+ codec=base_metadata.codec,
425
+ video_codec=base_metadata.video_codec,
426
+ audio=base_metadata.audio,
427
+ fps=base_metadata.fps,
428
+ bitrate=base_metadata.bitrate,
429
+ file_size=base_metadata.file_size,
430
+ timecode=base_metadata.timecode,
431
+ created_at=base_metadata.created_at,
432
+ device=device,
433
+ gps=gps,
434
+ gps_track=gps_track,
435
+ color_space=color_space,
436
+ lens=lens,
437
+ spanned_recording=spanned_recording,
438
+ )
439
+
440
+
441
+ # Register this extractor
442
+ register_extractor("sony", SonyExtractor())
@@ -0,0 +1,157 @@
1
+ """Tesla dashcam metadata extractor.
2
+
3
+ Tesla vehicles record dashcam footage from 4 cameras:
4
+ - front: Main forward-facing camera
5
+ - back: Rear camera
6
+ - left_repeater: Left side mirror camera
7
+ - right_repeater: Right side mirror camera
8
+
9
+ Files are saved in 1-minute segments with naming pattern:
10
+ YYYY-MM-DD_HH-MM-SS-camera.mp4
11
+
12
+ Sentry mode and dashcam events include:
13
+ - event.json: Contains timestamp, GPS (est_lat, est_lon), city, reason
14
+ - thumb.png: Thumbnail preview
15
+
16
+ Detection methods:
17
+ - Filename pattern matching
18
+ - event.json sidecar presence
19
+ """
20
+
21
+ import json
22
+ import logging
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from media_engine.schemas import (
28
+ GPS,
29
+ DetectionMethod,
30
+ DeviceInfo,
31
+ MediaDeviceType,
32
+ Metadata,
33
+ )
34
+
35
+ from .registry import register_extractor
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Tesla filename pattern: YYYY-MM-DD_HH-MM-SS-camera.mp4
40
+ TESLA_FILENAME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-(front|back|left_repeater|right_repeater)\.mp4$")
41
+
42
+
43
+ def _parse_event_json(video_path: str) -> GPS | None:
44
+ """Parse Tesla event.json sidecar for GPS coordinates.
45
+
46
+ The event.json file is in the parent folder of the video files
47
+ and contains estimated GPS coordinates.
48
+ """
49
+ path = Path(video_path)
50
+
51
+ # event.json is in the same directory as the video
52
+ event_json = path.parent / "event.json"
53
+
54
+ if not event_json.exists():
55
+ return None
56
+
57
+ try:
58
+ with open(event_json, encoding="utf-8") as f:
59
+ data = json.load(f)
60
+
61
+ lat_str = data.get("est_lat")
62
+ lon_str = data.get("est_lon")
63
+
64
+ if lat_str and lon_str:
65
+ lat = float(lat_str)
66
+ lon = float(lon_str)
67
+
68
+ if lat != 0 and lon != 0:
69
+ logger.info(f"Extracted GPS from Tesla event.json: {lat}, {lon}")
70
+ return GPS(latitude=lat, longitude=lon)
71
+
72
+ return None
73
+
74
+ except Exception as e:
75
+ logger.warning(f"Error reading Tesla event.json: {e}")
76
+ return None
77
+
78
+
79
+ def _detect_camera_position(filename: str) -> str | None:
80
+ """Detect which camera the file is from based on filename."""
81
+ name_lower = filename.lower()
82
+
83
+ if "-front" in name_lower:
84
+ return "front"
85
+ elif "-back" in name_lower:
86
+ return "rear"
87
+ elif "-left_repeater" in name_lower:
88
+ return "left"
89
+ elif "-right_repeater" in name_lower:
90
+ return "right"
91
+
92
+ return None
93
+
94
+
95
+ class TeslaExtractor:
96
+ """Metadata extractor for Tesla dashcam footage."""
97
+
98
+ def detect(self, probe_data: dict[str, Any], file_path: str) -> bool:
99
+ """Detect if file is from a Tesla dashcam."""
100
+ path = Path(file_path)
101
+
102
+ # Check filename pattern
103
+ if TESLA_FILENAME_PATTERN.match(path.name):
104
+ return True
105
+
106
+ # Check for event.json in same directory (Tesla sentry/dashcam event)
107
+ event_json = path.parent / "event.json"
108
+ if event_json.exists():
109
+ try:
110
+ with open(event_json, encoding="utf-8") as f:
111
+ data = json.load(f)
112
+ # Tesla event.json has specific keys
113
+ if "est_lat" in data or "reason" in data:
114
+ return True
115
+ except Exception:
116
+ pass
117
+
118
+ return False
119
+
120
+ def extract(
121
+ self,
122
+ probe_data: dict[str, Any],
123
+ file_path: str,
124
+ base_metadata: Metadata,
125
+ ) -> Metadata:
126
+ """Extract Tesla dashcam metadata."""
127
+ path = Path(file_path)
128
+
129
+ # Detect camera position
130
+ camera = _detect_camera_position(path.name)
131
+
132
+ # Build model string with camera position
133
+ model = "Dashcam"
134
+ if camera:
135
+ model = f"Dashcam ({camera})"
136
+
137
+ device = DeviceInfo(
138
+ make="Tesla",
139
+ model=model,
140
+ type=MediaDeviceType.DASHCAM,
141
+ detection_method=DetectionMethod.METADATA,
142
+ confidence=1.0,
143
+ )
144
+
145
+ # Extract GPS from event.json
146
+ gps = _parse_event_json(file_path)
147
+ if gps is None:
148
+ gps = base_metadata.gps
149
+
150
+ base_metadata.device = device
151
+ base_metadata.gps = gps
152
+
153
+ return base_metadata
154
+
155
+
156
+ # Register the extractor
157
+ register_extractor("tesla", TeslaExtractor())