mapillary-tools 0.14.0a2__py3-none-any.whl → 0.14.1__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 (49) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +66 -262
  3. mapillary_tools/authenticate.py +54 -46
  4. mapillary_tools/blackvue_parser.py +79 -22
  5. mapillary_tools/commands/__main__.py +15 -16
  6. mapillary_tools/commands/upload.py +33 -4
  7. mapillary_tools/config.py +38 -17
  8. mapillary_tools/constants.py +127 -43
  9. mapillary_tools/exceptions.py +4 -0
  10. mapillary_tools/exif_read.py +2 -1
  11. mapillary_tools/exif_write.py +3 -1
  12. mapillary_tools/exiftool_read_video.py +52 -15
  13. mapillary_tools/exiftool_runner.py +4 -24
  14. mapillary_tools/ffmpeg.py +406 -232
  15. mapillary_tools/geo.py +16 -0
  16. mapillary_tools/geotag/__init__.py +0 -0
  17. mapillary_tools/geotag/base.py +8 -4
  18. mapillary_tools/geotag/factory.py +106 -89
  19. mapillary_tools/geotag/geotag_images_from_exiftool.py +27 -20
  20. mapillary_tools/geotag/geotag_images_from_gpx.py +7 -6
  21. mapillary_tools/geotag/geotag_images_from_video.py +35 -0
  22. mapillary_tools/geotag/geotag_videos_from_exiftool.py +61 -14
  23. mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
  24. mapillary_tools/geotag/options.py +25 -3
  25. mapillary_tools/geotag/utils.py +9 -12
  26. mapillary_tools/geotag/video_extractors/base.py +1 -1
  27. mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
  28. mapillary_tools/geotag/video_extractors/gpx.py +61 -70
  29. mapillary_tools/geotag/video_extractors/native.py +34 -31
  30. mapillary_tools/history.py +128 -8
  31. mapillary_tools/http.py +211 -0
  32. mapillary_tools/mp4/construct_mp4_parser.py +8 -2
  33. mapillary_tools/process_geotag_properties.py +47 -35
  34. mapillary_tools/process_sequence_properties.py +340 -325
  35. mapillary_tools/sample_video.py +8 -8
  36. mapillary_tools/serializer/description.py +587 -0
  37. mapillary_tools/serializer/gpx.py +132 -0
  38. mapillary_tools/types.py +44 -610
  39. mapillary_tools/upload.py +327 -352
  40. mapillary_tools/upload_api_v4.py +125 -72
  41. mapillary_tools/uploader.py +797 -216
  42. mapillary_tools/utils.py +57 -5
  43. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/METADATA +91 -34
  44. mapillary_tools-0.14.1.dist-info/RECORD +76 -0
  45. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/WHEEL +1 -1
  46. mapillary_tools-0.14.0a2.dist-info/RECORD +0 -72
  47. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/entry_points.txt +0 -0
  48. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/licenses/LICENSE +0 -0
  49. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.1.dist-info}/top_level.txt +0 -0
mapillary_tools/ffmpeg.py CHANGED
@@ -13,8 +13,7 @@ import typing as T
13
13
  from pathlib import Path
14
14
 
15
15
  LOG = logging.getLogger(__name__)
16
- FRAME_EXT = ".jpg"
17
- NA_STREAM_IDX = "NA"
16
+ _MAX_STDERR_LENGTH = 2048
18
17
 
19
18
 
20
19
  class StreamTag(T.TypedDict):
@@ -31,6 +30,9 @@ class Stream(T.TypedDict):
31
30
  index: int
32
31
  tags: StreamTag
33
32
  width: int
33
+ r_frame_rate: str
34
+ avg_frame_rate: str
35
+ nb_frames: str
34
36
 
35
37
 
36
38
  class ProbeOutput(T.TypedDict):
@@ -41,9 +43,6 @@ class FFmpegNotFoundError(Exception):
41
43
  pass
42
44
 
43
45
 
44
- _MAX_STDERR_LENGTH = 2048
45
-
46
-
47
46
  def _truncate_begin(s: str) -> str:
48
47
  if _MAX_STDERR_LENGTH < len(s):
49
48
  return "..." + s[-_MAX_STDERR_LENGTH:]
@@ -74,6 +73,8 @@ class FFmpegCalledProcessError(Exception):
74
73
 
75
74
 
76
75
  class FFMPEG:
76
+ FRAME_EXT = ".jpg"
77
+
77
78
  def __init__(
78
79
  self,
79
80
  ffmpeg_path: str = "ffmpeg",
@@ -81,71 +82,33 @@ class FFMPEG:
81
82
  stderr: int | None = None,
82
83
  ) -> None:
83
84
  """
84
- ffmpeg_path: path to ffmpeg binary
85
- ffprobe_path: path to ffprobe binary
86
- stderr: param passed to subprocess.run to control whether to capture stderr
85
+ Initialize FFMPEG wrapper with paths to ffmpeg and ffprobe binaries.
86
+
87
+ Args:
88
+ ffmpeg_path: Path to ffmpeg binary executable
89
+ ffprobe_path: Path to ffprobe binary executable
90
+ stderr: Parameter passed to subprocess.run to control stderr capture.
91
+ Use subprocess.PIPE to capture stderr, None to inherit from parent
87
92
  """
88
93
  self.ffmpeg_path = ffmpeg_path
89
94
  self.ffprobe_path = ffprobe_path
90
95
  self.stderr = stderr
91
96
 
92
- def _run_ffprobe_json(self, cmd: list[str]) -> dict:
93
- full_cmd: list[str] = [self.ffprobe_path, "-print_format", "json", *cmd]
94
- LOG.info(f"Extracting video information: {' '.join(full_cmd)}")
95
- try:
96
- completed = subprocess.run(
97
- full_cmd,
98
- check=True,
99
- stdout=subprocess.PIPE,
100
- stderr=self.stderr,
101
- )
102
- except FileNotFoundError:
103
- raise FFmpegNotFoundError(
104
- f'The ffprobe command "{self.ffprobe_path}" not found'
105
- )
106
- except subprocess.CalledProcessError as ex:
107
- raise FFmpegCalledProcessError(ex) from ex
108
-
109
- try:
110
- stdout = completed.stdout.decode("utf-8")
111
- except UnicodeDecodeError:
112
- raise RuntimeError(
113
- f"Error decoding ffprobe output as unicode: {_truncate_end(str(completed.stdout))}"
114
- )
115
-
116
- try:
117
- output = json.loads(stdout)
118
- except json.JSONDecodeError:
119
- raise RuntimeError(
120
- f"Error JSON decoding ffprobe output: {_truncate_end(stdout)}"
121
- )
97
+ def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
98
+ """
99
+ Probe video file to extract format and stream information using ffprobe.
122
100
 
123
- # This check is for macOS:
124
- # ffprobe -hide_banner -print_format json not_exists
125
- # you will get exit code == 0 with the following stdout and stderr:
126
- # {
127
- # }
128
- # not_exists: No such file or directory
129
- if not output:
130
- raise RuntimeError(
131
- f"Empty JSON ffprobe output with STDERR: {_truncate_begin(str(completed.stderr))}"
132
- )
101
+ Args:
102
+ video_path: Path to the video file to probe
133
103
 
134
- return output
104
+ Returns:
105
+ Dictionary containing streams and format information from ffprobe
135
106
 
136
- def _run_ffmpeg(self, cmd: list[str]) -> None:
137
- full_cmd: list[str] = [self.ffmpeg_path, *cmd]
138
- LOG.info(f"Extracting frames: {' '.join(full_cmd)}")
139
- try:
140
- subprocess.run(full_cmd, check=True, stderr=self.stderr)
141
- except FileNotFoundError:
142
- raise FFmpegNotFoundError(
143
- f'The ffmpeg command "{self.ffmpeg_path}" not found'
144
- )
145
- except subprocess.CalledProcessError as ex:
146
- raise FFmpegCalledProcessError(ex) from ex
147
-
148
- def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
107
+ Raises:
108
+ FFmpegNotFoundError: If ffprobe binary is not found
109
+ FFmpegCalledProcessError: If ffprobe command fails
110
+ RuntimeError: If output cannot be decoded or parsed as JSON
111
+ """
149
112
  cmd: list[str] = [
150
113
  "-hide_banner",
151
114
  "-show_format",
@@ -154,49 +117,79 @@ class FFMPEG:
154
117
  ]
155
118
  return T.cast(ProbeOutput, self._run_ffprobe_json(cmd))
156
119
 
157
- def extract_frames(
120
+ def extract_frames_by_interval(
158
121
  self,
159
122
  video_path: Path,
160
123
  sample_dir: Path,
161
124
  sample_interval: float,
162
- stream_idx: int | None = None,
125
+ stream_specifier: int | str = "v",
163
126
  ) -> None:
164
127
  """
165
- Extract frames by the sample interval from the specified video stream.
166
-
167
- stream_idx: the stream_index specifier to a **video stream**. If it's None, defaults to "v". See http://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
128
+ Extract frames from video at regular time intervals using fps filter.
129
+
130
+ Args:
131
+ video_path: Path to input video file
132
+ sample_dir: Directory where extracted frame images will be saved
133
+ sample_interval: Time interval between extracted frames in seconds
134
+ stream_specifier: Stream specifier to target specific stream(s).
135
+ Can be an integer (stream index) or "v" (all video streams)
136
+ See https://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
137
+
138
+ Raises:
139
+ FFmpegNotFoundError: If ffmpeg binary is not found
140
+ FFmpegCalledProcessError: If ffmpeg command fails
168
141
  """
142
+ self._validate_stream_specifier(stream_specifier)
143
+
169
144
  sample_prefix = sample_dir.joinpath(video_path.stem)
170
- if stream_idx is not None:
171
- stream_selector = ["-map", f"0:{stream_idx}"]
172
- ouput_template = f"{sample_prefix}_{stream_idx}_%06d{FRAME_EXT}"
173
- stream_specifier = f"{stream_idx}"
174
- else:
175
- stream_selector = []
176
- ouput_template = f"{sample_prefix}_{NA_STREAM_IDX}_%06d{FRAME_EXT}"
177
- stream_specifier = "v"
145
+ stream_selector = ["-map", f"0:{stream_specifier}"]
146
+ output_template = f"{sample_prefix}_{stream_specifier}_%06d{self.FRAME_EXT}"
178
147
 
179
148
  cmd: list[str] = [
180
- # global options should be specified first
181
- *["-hide_banner", "-nostdin"],
182
- # input 0
149
+ # Global options should be specified first
150
+ *["-hide_banner"],
151
+ # Input 0
183
152
  *["-i", str(video_path)],
184
- # select stream
153
+ # Select stream
185
154
  *stream_selector,
186
- # filter videos
155
+ # Filter videos
187
156
  *["-vf", f"fps=1/{sample_interval}"],
188
- # video quality level (or the alias -q:v)
189
- *[f"-qscale:{stream_specifier}", "2"],
157
+ # Video quality level (or the alias -q:v)
190
158
  # -q:v=1 is the best quality but larger image sizes
191
159
  # see https://stackoverflow.com/a/10234065
192
160
  # *["-qscale:v", "1", "-qmin", "1"],
193
- # output
194
- ouput_template,
161
+ *["-qscale:v", "2"],
162
+ # Output
163
+ output_template,
195
164
  ]
196
165
 
197
- self._run_ffmpeg(cmd)
166
+ self.run_ffmpeg_non_interactive(cmd)
167
+
168
+ @classmethod
169
+ def generate_binary_search(cls, sorted_frame_indices: list[int]) -> str:
170
+ """
171
+ Generate a binary search expression for ffmpeg select filter.
172
+
173
+ Creates an optimized filter expression that uses binary search logic
174
+ to efficiently select specific frame numbers from a video stream.
175
+
176
+ Args:
177
+ sorted_frame_indices: List of frame numbers to select, must be sorted in ascending order
178
+
179
+ Returns:
180
+ FFmpeg filter expression string using binary search logic
181
+
182
+ Examples:
183
+ >>> FFMPEG.generate_binary_search([])
184
+ '0'
185
+ >>> FFMPEG.generate_binary_search([1])
186
+ 'eq(n\\\\,1)'
187
+ >>> FFMPEG.generate_binary_search([1, 2])
188
+ 'if(lt(n\\\\,2)\\\\,eq(n\\\\,1)\\\\,eq(n\\\\,2))'
189
+ >>> FFMPEG.generate_binary_search([1, 2, 3])
190
+ 'if(lt(n\\\\,2)\\\\,eq(n\\\\,1)\\\\,if(lt(n\\\\,3)\\\\,eq(n\\\\,2)\\\\,eq(n\\\\,3)))'
191
+ """
198
192
 
199
- def generate_binary_search(self, sorted_frame_indices: list[int]) -> str:
200
193
  length = len(sorted_frame_indices)
201
194
 
202
195
  if length == 0:
@@ -205,39 +198,50 @@ class FFMPEG:
205
198
  if length == 1:
206
199
  return f"eq(n\\,{sorted_frame_indices[0]})"
207
200
 
208
- middle = length // 2
209
- return f"if(lt(n\\,{sorted_frame_indices[middle]})\\,{self.generate_binary_search(sorted_frame_indices[:middle])}\\,{self.generate_binary_search(sorted_frame_indices[middle:])})"
201
+ middle_idx = length // 2
202
+ left = cls.generate_binary_search(sorted_frame_indices[:middle_idx])
203
+ right = cls.generate_binary_search(sorted_frame_indices[middle_idx:])
204
+
205
+ return f"if(lt(n\\,{sorted_frame_indices[middle_idx]})\\,{left}\\,{right})"
210
206
 
211
207
  def extract_specified_frames(
212
208
  self,
213
209
  video_path: Path,
214
210
  sample_dir: Path,
215
211
  frame_indices: set[int],
216
- stream_idx: int | None = None,
212
+ stream_specifier: int | str = "v",
217
213
  ) -> None:
218
214
  """
219
- Extract specified frames from the specified video stream.
220
-
221
- stream_idx: the stream_index specifier to a **video stream**. If it's None, defaults to "v". See http://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
215
+ Extract specific frames from video by frame number using select filter.
216
+
217
+ Uses a binary search filter expression to efficiently select only the
218
+ specified frame numbers from the video stream.
219
+
220
+ Args:
221
+ video_path: Path to input video file
222
+ sample_dir: Directory where extracted frame images will be saved
223
+ frame_indices: Set of specific frame numbers to extract (0-based)
224
+ stream_specifier: Stream specifier to target specific stream(s).
225
+ Can be an integer (stream index) or "v" (all video streams)
226
+ See https://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
227
+
228
+ Raises:
229
+ FFmpegNotFoundError: If ffmpeg binary is not found
230
+ FFmpegCalledProcessError: If ffmpeg command fails
231
+
232
+ Note:
233
+ Frame indices are 0-based but ffmpeg output files are numbered starting from 1.
234
+ Creates temporary filter script file on Windows to avoid command line length limits.
222
235
  """
223
236
 
237
+ self._validate_stream_specifier(stream_specifier)
238
+
224
239
  if not frame_indices:
225
240
  return
226
241
 
227
242
  sample_prefix = sample_dir.joinpath(video_path.stem)
228
- if stream_idx is not None:
229
- stream_selector = ["-map", f"0:{stream_idx}"]
230
- ouput_template = f"{sample_prefix}_{stream_idx}_%06d{FRAME_EXT}"
231
- stream_specifier = f"{stream_idx}"
232
- else:
233
- stream_selector = []
234
- ouput_template = f"{sample_prefix}_{NA_STREAM_IDX}_%06d{FRAME_EXT}"
235
- stream_specifier = "v"
236
-
237
- # Write the select filter to a temp file because:
238
- # The select filter could be large and
239
- # the maximum command line length for the CreateProcess function is 32767 characters
240
- # https://devblogs.microsoft.com/oldnewthing/20031210-00/?p=41553
243
+ stream_selector = ["-map", f"0:{stream_specifier}"]
244
+ output_template = f"{sample_prefix}_{stream_specifier}_%06d{self.FRAME_EXT}"
241
245
 
242
246
  eqs = self.generate_binary_search(sorted(frame_indices))
243
247
 
@@ -247,6 +251,10 @@ class FFMPEG:
247
251
  else:
248
252
  delete = True
249
253
 
254
+ # Write the select filter to a temp file because:
255
+ # The select filter could be large and
256
+ # the maximum command line length for the CreateProcess function is 32767 characters
257
+ # https://devblogs.microsoft.com/oldnewthing/20031210-00/?p=41553
250
258
  with tempfile.NamedTemporaryFile(mode="w+", delete=delete) as select_file:
251
259
  try:
252
260
  select_file.write(f"select={eqs}")
@@ -255,13 +263,13 @@ class FFMPEG:
255
263
  if not delete:
256
264
  select_file.close()
257
265
  cmd: list[str] = [
258
- # global options should be specified first
259
- *["-hide_banner", "-nostdin"],
260
- # input 0
266
+ # Global options should be specified first
267
+ *["-hide_banner"],
268
+ # Input 0
261
269
  *["-i", str(video_path)],
262
- # select stream
270
+ # Select stream
263
271
  *stream_selector,
264
- # filter videos
272
+ # Filter videos
265
273
  *[
266
274
  *["-filter_script:v", select_file.name],
267
275
  # Each frame is passed with its timestamp from the demuxer to the muxer
@@ -269,8 +277,8 @@ class FFMPEG:
269
277
  # vsync is deprecated by fps_mode,
270
278
  # but fps_mode is not avaliable on some older versions ;(
271
279
  # *[f"-fps_mode:{stream_specifier}", "passthrough"],
272
- # Set the number of video frames to output
273
- *[f"-frames:{stream_specifier}", str(len(frame_indices))],
280
+ # Set the number of video frames to output (this is an optimization to let ffmpeg stop early)
281
+ *["-frames:v", str(len(frame_indices))],
274
282
  # Disabled because it doesn't always name the sample images as expected
275
283
  # For example "select(n\,1)" we expected the first sample to be IMG_001.JPG
276
284
  # but it could be IMG_005.JPG
@@ -278,15 +286,15 @@ class FFMPEG:
278
286
  # If set to 1, expand the filename with pts from pkt->pts. Default value is 0.
279
287
  # *["-frame_pts", "1"],
280
288
  ],
281
- # video quality level (or the alias -q:v)
282
- *[f"-qscale:{stream_specifier}", "2"],
289
+ # Video quality level (or the alias -q:v)
283
290
  # -q:v=1 is the best quality but larger image sizes
284
291
  # see https://stackoverflow.com/a/10234065
285
292
  # *["-qscale:v", "1", "-qmin", "1"],
293
+ *["-qscale:v", "2"],
286
294
  # output
287
- ouput_template,
295
+ output_template,
288
296
  ]
289
- self._run_ffmpeg(cmd)
297
+ self.run_ffmpeg_non_interactive(cmd)
290
298
  finally:
291
299
  if not delete:
292
300
  try:
@@ -294,45 +302,286 @@ class FFMPEG:
294
302
  except FileNotFoundError:
295
303
  pass
296
304
 
305
+ @classmethod
306
+ def sort_selected_samples(
307
+ cls,
308
+ sample_dir: Path,
309
+ video_path: Path,
310
+ selected_stream_specifiers: list[int | str] | None = None,
311
+ ) -> list[tuple[int, list[Path | None]]]:
312
+ """
313
+ Group extracted frame samples by frame index across multiple streams.
314
+
315
+ Groups frames so that the Nth group contains all frames from the selected
316
+ streams at frame index N, allowing synchronized access to multi-stream frames.
317
+
318
+ Args:
319
+ sample_dir: Directory containing extracted frame files
320
+ video_path: Original video file path (used to match frame filenames)
321
+ selected_stream_specifiers: List of stream specifiers to include in output.
322
+ Can contain integers (stream indices) or "v" (all video streams).
323
+ If None, defaults to ["v"]
324
+
325
+ Returns:
326
+ List of tuples where each tuple contains:
327
+ - frame_idx (int): The frame index
328
+ - sample_paths (list[Path | None]): Paths to frame files from each selected stream,
329
+ or None if no frame exists for that stream at this index
330
+
331
+ Note:
332
+ Output is sorted by frame index in ascending order.
333
+ """
334
+ if selected_stream_specifiers is None:
335
+ selected_stream_specifiers = ["v"]
336
+
337
+ for stream_specifier in selected_stream_specifiers:
338
+ cls._validate_stream_specifier(stream_specifier)
339
+
340
+ stream_samples: dict[int, list[tuple[str, Path]]] = {}
341
+ for stream_specifier, frame_idx, sample_path in cls.iterate_samples(
342
+ sample_dir, video_path
343
+ ):
344
+ stream_samples.setdefault(frame_idx, []).append(
345
+ (str(stream_specifier), sample_path)
346
+ )
347
+
348
+ selected: list[tuple[int, list[Path | None]]] = []
349
+ for frame_idx in sorted(stream_samples.keys()):
350
+ indexed_by_specifier = {
351
+ specifier: sample_path
352
+ for specifier, sample_path in stream_samples[frame_idx]
353
+ }
354
+ selected_sample_paths = [
355
+ indexed_by_specifier.get(str(specifier))
356
+ for specifier in selected_stream_specifiers
357
+ ]
358
+ selected.append((frame_idx, selected_sample_paths))
359
+ return selected
360
+
361
+ @classmethod
362
+ def iterate_samples(
363
+ cls, sample_dir: Path, video_path: Path
364
+ ) -> T.Generator[tuple[str, int, Path], None, None]:
365
+ """
366
+ Iterate over all extracted frame samples in a directory.
367
+
368
+ Searches for frame files matching the expected naming pattern and yields
369
+ information about each frame including stream specifier, frame index, and file path.
370
+
371
+ Args:
372
+ sample_dir: Directory containing extracted frame files
373
+ video_path: Original video file path (used to match frame filenames)
374
+
375
+ Yields:
376
+ Tuple containing:
377
+ - stream_specifier (str): Stream specifier (number or "v")
378
+ - frame_idx (int): Frame index (0-based or 1-based depending on extraction method)
379
+ - sample_path (Path): Path to the frame image file
380
+
381
+ Note:
382
+ Expected filename pattern: {video_stem}_{stream_specifier}_{frame_idx:06d}.jpg
383
+ where stream_specifier can be a number or "v" for video streams.
384
+ """
385
+ sample_basename_pattern = re.compile(
386
+ rf"""
387
+ ^{re.escape(video_path.stem)} # Match the video stem
388
+ _(?P<stream_specifier>\d+|v) # Stream specifier can be a number or "v"
389
+ _(?P<frame_idx>\d+)$ # Frame index, can be 0-padded
390
+ """,
391
+ re.X,
392
+ )
393
+ for sample_path in sample_dir.iterdir():
394
+ result = cls._extract_stream_frame_idx(
395
+ sample_path.name, sample_basename_pattern
396
+ )
397
+ if result is not None:
398
+ stream_specifier, frame_idx = result
399
+ yield (stream_specifier, frame_idx, sample_path)
400
+
401
+ def run_ffmpeg_non_interactive(self, cmd: list[str]) -> None:
402
+ """
403
+ Execute ffmpeg command in non-interactive mode.
404
+
405
+ Runs ffmpeg with the given command arguments, automatically adding
406
+ the -nostdin flag to prevent interactive prompts.
407
+
408
+ Args:
409
+ cmd: List of command line arguments to pass to ffmpeg
410
+
411
+ Raises:
412
+ FFmpegNotFoundError: If ffmpeg binary is not found
413
+ FFmpegCalledProcessError: If ffmpeg command fails
414
+ """
415
+ full_cmd: list[str] = [self.ffmpeg_path, "-nostdin", *cmd]
416
+ LOG.info(f"Running ffmpeg: {' '.join(full_cmd)}")
417
+ try:
418
+ subprocess.run(full_cmd, check=True, stderr=self.stderr)
419
+ except FileNotFoundError:
420
+ raise FFmpegNotFoundError(
421
+ f'The ffmpeg command "{self.ffmpeg_path}" not found'
422
+ )
423
+ except subprocess.CalledProcessError as ex:
424
+ raise FFmpegCalledProcessError(ex) from ex
425
+
426
+ @classmethod
427
+ def _extract_stream_frame_idx(
428
+ cls, sample_basename: str, pattern: T.Pattern[str]
429
+ ) -> tuple[str, int] | None:
430
+ """
431
+ Extract stream specifier and frame index from sample basename
432
+
433
+ Returns:
434
+ If returning None, it means the basename does not match the pattern
435
+
436
+ Examples:
437
+ * basename GX010001_v_000000.jpg will extract ("v", 0)
438
+ * basename GX010001_1_000002.jpg will extract ("1", 2)
439
+ """
440
+ image_no_ext, ext = os.path.splitext(sample_basename)
441
+ if ext.lower() != cls.FRAME_EXT.lower():
442
+ return None
443
+
444
+ match = pattern.match(image_no_ext)
445
+ if not match:
446
+ return None
447
+
448
+ stream_specifier = match.group("stream_specifier")
449
+
450
+ # Convert 0-padded numbers to int
451
+ # e.g. 000000 -> 0
452
+ # e.g. 000001 -> 1
453
+ frame_idx_str = match.group("frame_idx")
454
+ frame_idx_str = frame_idx_str.lstrip("0") or "0"
455
+
456
+ try:
457
+ frame_idx = int(frame_idx_str)
458
+ except ValueError:
459
+ return None
460
+
461
+ return stream_specifier, frame_idx
462
+
463
+ def _run_ffprobe_json(self, cmd: list[str]) -> dict:
464
+ full_cmd: list[str] = [self.ffprobe_path, "-print_format", "json", *cmd]
465
+ LOG.info(f"Extracting video information: {' '.join(full_cmd)}")
466
+ try:
467
+ completed = subprocess.run(
468
+ full_cmd, check=True, stdout=subprocess.PIPE, stderr=self.stderr
469
+ )
470
+ except FileNotFoundError:
471
+ raise FFmpegNotFoundError(
472
+ f'The ffprobe command "{self.ffprobe_path}" not found'
473
+ )
474
+ except subprocess.CalledProcessError as ex:
475
+ raise FFmpegCalledProcessError(ex) from ex
476
+
477
+ try:
478
+ stdout = completed.stdout.decode("utf-8")
479
+ except UnicodeDecodeError:
480
+ raise RuntimeError(
481
+ f"Error decoding ffprobe output as unicode: {_truncate_end(str(completed.stdout))}"
482
+ )
483
+
484
+ try:
485
+ output = json.loads(stdout)
486
+ except json.JSONDecodeError:
487
+ raise RuntimeError(
488
+ f"Error JSON decoding ffprobe output: {_truncate_end(stdout)}"
489
+ )
490
+
491
+ # This check is for macOS:
492
+ # ffprobe -hide_banner -print_format json not_exists
493
+ # you will get exit code == 0 with the following stdout and stderr:
494
+ # {
495
+ # }
496
+ # not_exists: No such file or directory
497
+ if not output:
498
+ raise RuntimeError(
499
+ f"Empty JSON ffprobe output with STDERR: {_truncate_begin(str(completed.stderr))}"
500
+ )
501
+
502
+ return output
503
+
504
+ @classmethod
505
+ def _validate_stream_specifier(cls, stream_specifier: int | str) -> None:
506
+ if isinstance(stream_specifier, str):
507
+ if stream_specifier in ["v"]:
508
+ pass
509
+ else:
510
+ try:
511
+ int(stream_specifier)
512
+ except ValueError:
513
+ raise ValueError(f"Invalid stream specifier: {stream_specifier}")
514
+
297
515
 
298
516
  class Probe:
299
- probe: ProbeOutput
517
+ probe_output: ProbeOutput
518
+
519
+ def __init__(self, probe_output: ProbeOutput) -> None:
520
+ """
521
+ Initialize Probe with ffprobe output data.
300
522
 
301
- def __init__(self, probe: ProbeOutput) -> None:
302
- self.probe = probe
523
+ Args:
524
+ probe_output: Dictionary containing streams and format information from ffprobe
525
+ """
526
+ self.probe_output = probe_output
303
527
 
304
528
  def probe_video_start_time(self) -> datetime.datetime | None:
305
529
  """
306
- Find video start time of the given video.
307
- It searches video creation time and duration in video streams first and then the other streams.
308
- Once found, return stream creation time - stream duration as the video start time.
530
+ Determine the start time of the video by analyzing stream metadata.
531
+
532
+ Searches for creation time and duration information in video streams first,
533
+ then falls back to other stream types. Calculates start time as:
534
+ creation_time - duration
535
+
536
+ Returns:
537
+ Video start time as datetime object, or None if cannot be determined
538
+
539
+ Note:
540
+ Prioritizes video streams with highest resolution when multiple exist.
309
541
  """
310
- streams = self.probe.get("streams", [])
542
+ streams = self.probe_output.get("streams", [])
311
543
 
312
- # search start time from video streams
544
+ # Search start time from video streams
313
545
  video_streams = self.probe_video_streams()
314
546
  video_streams.sort(
315
547
  key=lambda s: s.get("width", 0) * s.get("height", 0), reverse=True
316
548
  )
317
549
  for stream in video_streams:
318
- start_time = extract_stream_start_time(stream)
550
+ start_time = self.extract_stream_start_time(stream)
319
551
  if start_time is not None:
320
552
  return start_time
321
553
 
322
- # search start time from the other streams
554
+ # Search start time from the other streams
323
555
  for stream in streams:
324
556
  if stream.get("codec_type") != "video":
325
- start_time = extract_stream_start_time(stream)
557
+ start_time = self.extract_stream_start_time(stream)
326
558
  if start_time is not None:
327
559
  return start_time
328
560
 
329
561
  return None
330
562
 
331
563
  def probe_video_streams(self) -> list[Stream]:
332
- streams = self.probe.get("streams", [])
564
+ """
565
+ Extract all video streams from the probe output.
566
+
567
+ Returns:
568
+ List of video stream dictionaries containing metadata like codec,
569
+ dimensions, frame rate, etc.
570
+ """
571
+ streams = self.probe_output.get("streams", [])
333
572
  return [stream for stream in streams if stream.get("codec_type") == "video"]
334
573
 
335
574
  def probe_video_with_max_resolution(self) -> Stream | None:
575
+ """
576
+ Find the video stream with the highest resolution.
577
+
578
+ Sorts all video streams by width × height and returns the one with
579
+ the largest resolution.
580
+
581
+ Returns:
582
+ Stream dictionary for the highest resolution video stream,
583
+ or None if no video streams exist
584
+ """
336
585
  video_streams = self.probe_video_streams()
337
586
  video_streams.sort(
338
587
  key=lambda s: s.get("width", 0) * s.get("height", 0), reverse=True
@@ -341,112 +590,37 @@ class Probe:
341
590
  return None
342
591
  return video_streams[0]
343
592
 
593
+ @classmethod
594
+ def extract_stream_start_time(cls, stream: Stream) -> datetime.datetime | None:
595
+ """
596
+ Calculate the start time of a specific stream.
344
597
 
345
- def extract_stream_start_time(stream: Stream) -> datetime.datetime | None:
346
- """
347
- Find the start time of the given stream.
348
- Start time is the creation time of the stream minus the duration of the stream.
349
- """
350
- duration_str = stream.get("duration")
351
- LOG.debug("Extracted video duration: %s", duration_str)
352
- if duration_str is None:
353
- return None
354
- duration = float(duration_str)
598
+ Determines start time by subtracting stream duration from creation time:
599
+ start_time = creation_time - duration
355
600
 
356
- creation_time_str = stream.get("tags", {}).get("creation_time")
357
- LOG.debug("Extracted video creation time: %s", creation_time_str)
358
- if creation_time_str is None:
359
- return None
360
- try:
361
- creation_time = datetime.datetime.fromisoformat(creation_time_str)
362
- except ValueError:
363
- creation_time = datetime.datetime.strptime(
364
- creation_time_str, "%Y-%m-%dT%H:%M:%S.%f%z"
365
- )
366
- return creation_time - datetime.timedelta(seconds=duration)
367
-
368
-
369
- def _extract_stream_frame_idx(
370
- sample_basename: str,
371
- sample_basename_pattern: T.Pattern[str],
372
- ) -> tuple[int | None, int] | None:
373
- """
374
- extract stream id and frame index from sample basename
375
- e.g. basename GX010001_NA_000000.jpg will extract (None, 0)
376
- e.g. basename GX010001_1_000002.jpg will extract (1, 2)
377
- If returning None, it means the basename does not match the pattern
378
- """
379
- image_no_ext, ext = os.path.splitext(sample_basename)
380
- if ext.lower() != FRAME_EXT.lower():
381
- return None
601
+ Args:
602
+ stream: Stream dictionary containing metadata including tags and duration
382
603
 
383
- match = sample_basename_pattern.match(image_no_ext)
384
- if not match:
385
- return None
604
+ Returns:
605
+ Stream start time as datetime object, or None if required metadata is missing
386
606
 
387
- g1 = match.group("stream_idx")
388
- try:
389
- if g1 == NA_STREAM_IDX:
390
- stream_idx = None
391
- else:
392
- stream_idx = int(g1)
393
- except ValueError:
394
- return None
395
-
396
- # convert 0-padded numbers to int
397
- # e.g. 000000 -> 0
398
- # e.g. 000001 -> 1
399
- g2 = match.group("frame_idx")
400
- g2 = g2.lstrip("0") or "0"
401
-
402
- try:
403
- frame_idx = int(g2)
404
- except ValueError:
405
- return None
607
+ Note:
608
+ Handles multiple datetime formats including ISO format and custom patterns.
609
+ """
610
+ duration_str = stream.get("duration")
611
+ LOG.debug("Extracted video duration: %s", duration_str)
612
+ if duration_str is None:
613
+ return None
614
+ duration = float(duration_str)
406
615
 
407
- return stream_idx, frame_idx
408
-
409
-
410
- def iterate_samples(
411
- sample_dir: Path, video_path: Path
412
- ) -> T.Generator[tuple[int | None, int, Path], None, None]:
413
- """
414
- Search all samples in the sample_dir,
415
- and return a generator of the tuple: (stream ID, frame index, sample path).
416
- The frame index could be 0-based or 1-based depending on how it's sampled.
417
- """
418
- sample_basename_pattern = re.compile(
419
- rf"^{re.escape(video_path.stem)}_(?P<stream_idx>\d+|{re.escape(NA_STREAM_IDX)})_(?P<frame_idx>\d+)$"
420
- )
421
- for sample_path in sample_dir.iterdir():
422
- stream_frame_idx = _extract_stream_frame_idx(
423
- sample_path.name,
424
- sample_basename_pattern,
425
- )
426
- if stream_frame_idx is not None:
427
- stream_idx, frame_idx = stream_frame_idx
428
- yield (stream_idx, frame_idx, sample_path)
429
-
430
-
431
- def sort_selected_samples(
432
- sample_dir: Path, video_path: Path, selected_stream_indices: list[int | None]
433
- ) -> list[tuple[int, list[Path | None]]]:
434
- """
435
- Group frames by frame index, so that
436
- the Nth group contains all the frames from the selected streams at frame index N.
437
- """
438
- stream_samples: dict[int, list[tuple[int | None, Path]]] = {}
439
- for stream_idx, frame_idx, sample_path in iterate_samples(sample_dir, video_path):
440
- stream_samples.setdefault(frame_idx, []).append((stream_idx, sample_path))
441
-
442
- selected: list[tuple[int, list[Path | None]]] = []
443
- for frame_idx in sorted(stream_samples.keys()):
444
- indexed = {
445
- stream_idx: sample_path
446
- for stream_idx, sample_path in stream_samples[frame_idx]
447
- }
448
- selected_sample_paths = [
449
- indexed.get(stream_idx) for stream_idx in selected_stream_indices
450
- ]
451
- selected.append((frame_idx, selected_sample_paths))
452
- return selected
616
+ creation_time_str = stream.get("tags", {}).get("creation_time")
617
+ LOG.debug("Extracted video creation time: %s", creation_time_str)
618
+ if creation_time_str is None:
619
+ return None
620
+ try:
621
+ creation_time = datetime.datetime.fromisoformat(creation_time_str)
622
+ except ValueError:
623
+ creation_time = datetime.datetime.strptime(
624
+ creation_time_str, "%Y-%m-%dT%H:%M:%S.%f%z"
625
+ )
626
+ return creation_time - datetime.timedelta(seconds=duration)