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,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
|