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.
- cli/clip.py +79 -0
- cli/faces.py +91 -0
- cli/metadata.py +68 -0
- cli/motion.py +77 -0
- cli/objects.py +94 -0
- cli/ocr.py +93 -0
- cli/scenes.py +57 -0
- cli/telemetry.py +65 -0
- cli/transcript.py +76 -0
- media_engine/__init__.py +7 -0
- media_engine/_version.py +34 -0
- media_engine/app.py +80 -0
- media_engine/batch/__init__.py +56 -0
- media_engine/batch/models.py +99 -0
- media_engine/batch/processor.py +1131 -0
- media_engine/batch/queue.py +232 -0
- media_engine/batch/state.py +30 -0
- media_engine/batch/timing.py +321 -0
- media_engine/cli.py +17 -0
- media_engine/config.py +674 -0
- media_engine/extractors/__init__.py +75 -0
- media_engine/extractors/clip.py +401 -0
- media_engine/extractors/faces.py +459 -0
- media_engine/extractors/frame_buffer.py +351 -0
- media_engine/extractors/frames.py +402 -0
- media_engine/extractors/metadata/__init__.py +127 -0
- media_engine/extractors/metadata/apple.py +169 -0
- media_engine/extractors/metadata/arri.py +118 -0
- media_engine/extractors/metadata/avchd.py +208 -0
- media_engine/extractors/metadata/avchd_gps.py +270 -0
- media_engine/extractors/metadata/base.py +688 -0
- media_engine/extractors/metadata/blackmagic.py +139 -0
- media_engine/extractors/metadata/camera_360.py +276 -0
- media_engine/extractors/metadata/canon.py +290 -0
- media_engine/extractors/metadata/dji.py +371 -0
- media_engine/extractors/metadata/dv.py +121 -0
- media_engine/extractors/metadata/ffmpeg.py +76 -0
- media_engine/extractors/metadata/generic.py +119 -0
- media_engine/extractors/metadata/gopro.py +256 -0
- media_engine/extractors/metadata/red.py +305 -0
- media_engine/extractors/metadata/registry.py +114 -0
- media_engine/extractors/metadata/sony.py +442 -0
- media_engine/extractors/metadata/tesla.py +157 -0
- media_engine/extractors/motion.py +765 -0
- media_engine/extractors/objects.py +245 -0
- media_engine/extractors/objects_qwen.py +754 -0
- media_engine/extractors/ocr.py +268 -0
- media_engine/extractors/scenes.py +82 -0
- media_engine/extractors/shot_type.py +217 -0
- media_engine/extractors/telemetry.py +262 -0
- media_engine/extractors/transcribe.py +579 -0
- media_engine/extractors/translate.py +121 -0
- media_engine/extractors/vad.py +263 -0
- media_engine/main.py +68 -0
- media_engine/py.typed +0 -0
- media_engine/routers/__init__.py +15 -0
- media_engine/routers/batch.py +78 -0
- media_engine/routers/health.py +93 -0
- media_engine/routers/models.py +211 -0
- media_engine/routers/settings.py +87 -0
- media_engine/routers/utils.py +135 -0
- media_engine/schemas.py +581 -0
- media_engine/utils/__init__.py +5 -0
- media_engine/utils/logging.py +54 -0
- media_engine/utils/memory.py +49 -0
- media_engine-0.1.0.dist-info/METADATA +276 -0
- media_engine-0.1.0.dist-info/RECORD +70 -0
- media_engine-0.1.0.dist-info/WHEEL +4 -0
- media_engine-0.1.0.dist-info/entry_points.txt +11 -0
- 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())
|