deffcode 0.2.7__tar.gz → 0.2.9__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deffcode
3
- Version: 0.2.7
3
+ Version: 0.2.9
4
4
  Summary: A cross-platform High-performance & Flexible Real-time Video Frames Decoder in Python.
5
5
  Author-email: Abhishek Thakur <abhi.una12@gmail.com>
6
6
  License: Apache License 2.0
@@ -22,11 +22,11 @@ Classifier: Intended Audience :: Developers
22
22
  Classifier: Intended Audience :: Science/Research
23
23
  Classifier: Intended Audience :: Education
24
24
  Classifier: License :: OSI Approved :: Apache Software License
25
- Classifier: Programming Language :: Python :: 3.10
26
25
  Classifier: Programming Language :: Python :: 3.11
27
26
  Classifier: Programming Language :: Python :: 3.12
28
27
  Classifier: Programming Language :: Python :: 3.13
29
- Requires-Python: >=3.10
28
+ Classifier: Programming Language :: Python :: 3.14
29
+ Requires-Python: >=3.11
30
30
  Description-Content-Type: text/markdown
31
31
  License-File: LICENSE
32
32
  Requires-Dist: cython
@@ -108,12 +108,13 @@ Here are some key features that stand out:
108
108
  - Hands down the easiest [**Index based Camera Device Capturing**][decoding-camera-devices-using-indexes], similar to OpenCV.
109
109
  - Easy to code **Real-time [Simple][transcoding-live-simple-filtergraphs] & [Complex][transcoding-live-complex-filtergraphs] Filtergraphs**. _(Yes, You read it correctly "Real-time"!)_
110
110
  - Native **[Multi-Input Source Configurations][multi-input-source-configurations]** support for decoding complex topologies.
111
+ - Native **[Multi-Outputs Configurations][multi-outputs-configurations]** support for recording/streaming while decoding, in a single FFmpeg pipeline.
111
112
  - Lightning fast dedicated **GPU-Accelerated Video [Decoding][hardware-accelerated-video-decoding] & [Transcoding][hardware-accelerated-video-transcoding]**.
112
113
  - Enables precise FFmpeg [**Key-frame Seeking**][extracting-key-frames-as-png-image] with pinpoint accuracy.
113
114
  - Effortless [**Metadata Extraction**][extracting-video-metadata] from all streams available in the source.
114
115
  - Maintains the standard easy to learn [**OpenCV-Python**](https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html) coding syntax.
115
116
  - Out-of-the-box support for all prominent Computer Vision libraries.
116
- - Cross-platform, runs on Python 3.10+, and easy to install.
117
+ - Cross-platform, runs on Python 3.11+, and easy to install.
117
118
 
118
119
  <!--
119
120
  - [x] Lossless Transcoding support with [WriteGear](https://abhitronix.github.io/deffcode/latest/gears/writegear/introduction/). #TODO
@@ -235,6 +236,11 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
235
236
  - [CUDA-NVENC-accelerated Video Transcoding with WriteGear API][cuda-nvenc-accelerated-video-transcoding-with-writegear-api]
236
237
  - [CUDA-NVENC-accelerated End-to-end Lossless Video Transcoding with WriteGear API][cuda-nvenc-accelerated-end-to-end-lossless-video-transcoding-with-writegear-api]
237
238
  - [Multi-Input Source Configurations][multi-input-source-configurations]
239
+ - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
240
+ - [Decoding crossfade transition between multiple inputs][decoding-crossfade-transition-between-multiple-inputs]
241
+ - [Multi-Outputs Configurations][multi-outputs-configurations]
242
+ - [Recording while decoding][recording-while-decoding]
243
+ - [Encode once, duplicate to file and stream][encode-once-duplicate-to-file-and-stream]
238
244
 
239
245
  </details>
240
246
 
@@ -271,9 +277,8 @@ decoder = FFdecoder("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1
271
277
 
272
278
  # grab RGB24(default) 3D frames from decoder
273
279
  for frame in decoder.generateFrame():
274
-
275
280
  # lets print its shape
276
- print(frame.shape) # (1080, 1920, 3)
281
+ print(frame.shape) # (1080, 1920, 3)
277
282
 
278
283
  # terminate the decoder
279
284
  decoder.terminate()
@@ -288,7 +293,9 @@ The primary function of [**Sourcer API**][sourcer-api] is to **gather metadata i
288
293
  from deffcode import Sourcer
289
294
 
290
295
  # initialize and formulate the decoder using suitable source
291
- sourcer = Sourcer("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4").probe_stream()
296
+ sourcer = Sourcer(
297
+ "https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4"
298
+ ).probe_stream()
292
299
 
293
300
  # print metadata as `json.dump`
294
301
  print(sourcer.retrieve_metadata(pretty_json=True))
@@ -354,18 +361,18 @@ It is something I am doing with my own free time. But so much more needs to be d
354
361
 
355
362
  Here is a Bibtex entry you can use to cite this project in a publication:
356
363
 
357
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.12689394.svg)](https://doi.org/10.5281/zenodo.12689394)
364
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23446604.svg)](https://doi.org/10.5281/zenodo.23446604)
358
365
 
359
366
  ```BibTeX
360
367
  @software{deffcode,
361
368
  author = {Abhishek Thakur},
362
- title = {abhiTronix/deffcode: v0.2.6},
363
- month = jul,
364
- year = 2024,
369
+ title = {abhiTronix/deffcode: v0.2.8},
370
+ month = oct,
371
+ year = 2026,
365
372
  publisher = {Zenodo},
366
- version = {v0.2.6},
367
- doi = {10.5281/zenodo.12689394},
368
- url = {https://doi.org/10.5281/zenodo.12689394},
373
+ version = {v0.2.8},
374
+ doi = {10.5281/zenodo.23446604},
375
+ url = {https://doi.org/10.5281/zenodo.23446604},
369
376
  }
370
377
  ```
371
378
 
@@ -478,6 +485,11 @@ Advanced Recipes
478
485
  [smart-keyframe-only-decoding-for-heavy-ai-inference]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/extract-frame-metadata/#smart-keyframe-only-decoding-for-heavy-ai-inference
479
486
  [variable-frame-rate-vfr-synchronization-via-pts_time]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/extract-frame-metadata/#variable-frame-rate-vfr-synchronization-via-pts_time
480
487
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
488
+ [decoding-multiple-inputs-as-2x2-video-grid-mosaic]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-multiple-inputs-as-2x2-video-grid-mosaic
489
+ [decoding-crossfade-transition-between-multiple-inputs]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-crossfade-transition-between-multiple-inputs
490
+ [multi-outputs-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#multi-outputs-configurations
491
+ [recording-while-decoding]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#recording-while-decoding
492
+ [encode-once-duplicate-to-file-and-stream]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#encode-once-duplicate-to-file-and-stream
481
493
 
482
494
  <!--
483
495
  CI Apps URLs
@@ -68,12 +68,13 @@ Here are some key features that stand out:
68
68
  - Hands down the easiest [**Index based Camera Device Capturing**][decoding-camera-devices-using-indexes], similar to OpenCV.
69
69
  - Easy to code **Real-time [Simple][transcoding-live-simple-filtergraphs] & [Complex][transcoding-live-complex-filtergraphs] Filtergraphs**. _(Yes, You read it correctly "Real-time"!)_
70
70
  - Native **[Multi-Input Source Configurations][multi-input-source-configurations]** support for decoding complex topologies.
71
+ - Native **[Multi-Outputs Configurations][multi-outputs-configurations]** support for recording/streaming while decoding, in a single FFmpeg pipeline.
71
72
  - Lightning fast dedicated **GPU-Accelerated Video [Decoding][hardware-accelerated-video-decoding] & [Transcoding][hardware-accelerated-video-transcoding]**.
72
73
  - Enables precise FFmpeg [**Key-frame Seeking**][extracting-key-frames-as-png-image] with pinpoint accuracy.
73
74
  - Effortless [**Metadata Extraction**][extracting-video-metadata] from all streams available in the source.
74
75
  - Maintains the standard easy to learn [**OpenCV-Python**](https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html) coding syntax.
75
76
  - Out-of-the-box support for all prominent Computer Vision libraries.
76
- - Cross-platform, runs on Python 3.10+, and easy to install.
77
+ - Cross-platform, runs on Python 3.11+, and easy to install.
77
78
 
78
79
  <!--
79
80
  - [x] Lossless Transcoding support with [WriteGear](https://abhitronix.github.io/deffcode/latest/gears/writegear/introduction/). #TODO
@@ -195,6 +196,11 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
195
196
  - [CUDA-NVENC-accelerated Video Transcoding with WriteGear API][cuda-nvenc-accelerated-video-transcoding-with-writegear-api]
196
197
  - [CUDA-NVENC-accelerated End-to-end Lossless Video Transcoding with WriteGear API][cuda-nvenc-accelerated-end-to-end-lossless-video-transcoding-with-writegear-api]
197
198
  - [Multi-Input Source Configurations][multi-input-source-configurations]
199
+ - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
200
+ - [Decoding crossfade transition between multiple inputs][decoding-crossfade-transition-between-multiple-inputs]
201
+ - [Multi-Outputs Configurations][multi-outputs-configurations]
202
+ - [Recording while decoding][recording-while-decoding]
203
+ - [Encode once, duplicate to file and stream][encode-once-duplicate-to-file-and-stream]
198
204
 
199
205
  </details>
200
206
 
@@ -231,9 +237,8 @@ decoder = FFdecoder("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1
231
237
 
232
238
  # grab RGB24(default) 3D frames from decoder
233
239
  for frame in decoder.generateFrame():
234
-
235
240
  # lets print its shape
236
- print(frame.shape) # (1080, 1920, 3)
241
+ print(frame.shape) # (1080, 1920, 3)
237
242
 
238
243
  # terminate the decoder
239
244
  decoder.terminate()
@@ -248,7 +253,9 @@ The primary function of [**Sourcer API**][sourcer-api] is to **gather metadata i
248
253
  from deffcode import Sourcer
249
254
 
250
255
  # initialize and formulate the decoder using suitable source
251
- sourcer = Sourcer("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4").probe_stream()
256
+ sourcer = Sourcer(
257
+ "https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4"
258
+ ).probe_stream()
252
259
 
253
260
  # print metadata as `json.dump`
254
261
  print(sourcer.retrieve_metadata(pretty_json=True))
@@ -314,18 +321,18 @@ It is something I am doing with my own free time. But so much more needs to be d
314
321
 
315
322
  Here is a Bibtex entry you can use to cite this project in a publication:
316
323
 
317
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.12689394.svg)](https://doi.org/10.5281/zenodo.12689394)
324
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23446604.svg)](https://doi.org/10.5281/zenodo.23446604)
318
325
 
319
326
  ```BibTeX
320
327
  @software{deffcode,
321
328
  author = {Abhishek Thakur},
322
- title = {abhiTronix/deffcode: v0.2.6},
323
- month = jul,
324
- year = 2024,
329
+ title = {abhiTronix/deffcode: v0.2.8},
330
+ month = oct,
331
+ year = 2026,
325
332
  publisher = {Zenodo},
326
- version = {v0.2.6},
327
- doi = {10.5281/zenodo.12689394},
328
- url = {https://doi.org/10.5281/zenodo.12689394},
333
+ version = {v0.2.8},
334
+ doi = {10.5281/zenodo.23446604},
335
+ url = {https://doi.org/10.5281/zenodo.23446604},
329
336
  }
330
337
  ```
331
338
 
@@ -438,6 +445,11 @@ Advanced Recipes
438
445
  [smart-keyframe-only-decoding-for-heavy-ai-inference]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/extract-frame-metadata/#smart-keyframe-only-decoding-for-heavy-ai-inference
439
446
  [variable-frame-rate-vfr-synchronization-via-pts_time]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/extract-frame-metadata/#variable-frame-rate-vfr-synchronization-via-pts_time
440
447
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
448
+ [decoding-multiple-inputs-as-2x2-video-grid-mosaic]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-multiple-inputs-as-2x2-video-grid-mosaic
449
+ [decoding-crossfade-transition-between-multiple-inputs]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-crossfade-transition-between-multiple-inputs
450
+ [multi-outputs-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#multi-outputs-configurations
451
+ [recording-while-decoding]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#recording-while-decoding
452
+ [encode-once-duplicate-to-file-and-stream]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_output/#encode-once-duplicate-to-file-and-stream
441
453
 
442
454
  <!--
443
455
  CI Apps URLs
@@ -26,16 +26,19 @@ import platform
26
26
  import queue
27
27
  import re
28
28
  import subprocess as sp
29
+ import sys
29
30
  import threading
30
- from collections import OrderedDict
31
+ from collections import OrderedDict, deque
31
32
  from collections.abc import Generator
33
+ from itertools import pairwise
32
34
  from types import TracebackType
33
- from typing import Any
35
+ from typing import Any, Self
34
36
 
35
37
  import numpy as np
36
38
 
37
39
  # import utils packages
38
40
  from .ffhelper import (
41
+ TERMINATE_TIMEOUT_SUBPROCESS,
39
42
  get_supported_pixfmts,
40
43
  get_supported_vdecoders,
41
44
  )
@@ -207,6 +210,15 @@ class FFdecoder:
207
210
  self.__ffmpeg_prefixes = []
208
211
  sourcer_params["-ffprefixes"] = self.__ffmpeg_prefixes
209
212
 
213
+ # handles user-defined additional FFmpeg outputs, appended after the rawvideo pipe output (must be a flat list)
214
+ self.__extra_outputs = self.__extra_params.pop("-extra_outputs", [])
215
+ if not isinstance(self.__extra_outputs, list) or any(
216
+ isinstance(x, (list, tuple, dict)) for x in self.__extra_outputs
217
+ ):
218
+ logger.warning("Discarding invalid `-extra_outputs` value! It must be a flat list.")
219
+ self.__extra_outputs = []
220
+ self.__extra_outputs = [str(x) for x in self.__extra_outputs]
221
+
210
222
  # pass parameter(if specified) to Sourcer API, specifying where to save the downloaded FFmpeg Static
211
223
  # assets on Windows(if specified)
212
224
  sourcer_params["-ffmpeg_download_path"] = self.__extra_params.pop(
@@ -226,6 +238,20 @@ class FFdecoder:
226
238
  if {"-vf", "-filter_complex"}.intersection(self.__extra_params.keys()):
227
239
  key = "-vf" if "-vf" in self.__extra_params else "-filter_complex"
228
240
  sourcer_params[key] = self.__extra_params[key]
241
+ # multi-input output streams are selected via `-map`, so probe with it too
242
+ if self.__is_multi and "-map" in self.__extra_params:
243
+ sourcer_params["-map"] = self.__extra_params["-map"]
244
+ # filtergraph labels consumed only by `-extra_outputs` must also be consumed while
245
+ # probing, otherwise FFmpeg aborts with "unconnected output"; map all into the probe sink.
246
+ if self.__extra_outputs and "-filter_complex" in sourcer_params:
247
+ if "-map" in self.__extra_params:
248
+ sourcer_params["-map"] = self.__extra_params["-map"]
249
+ sourcer_params["-clones"] = [
250
+ arg
251
+ for key, val in pairwise(self.__extra_outputs)
252
+ if key == "-map" and val.startswith("[")
253
+ for arg in (key, val)
254
+ ]
229
255
 
230
256
  # define dict to store user-defined parameters
231
257
  self.__user_metadata = {}
@@ -277,6 +303,13 @@ class FFdecoder:
277
303
  "Enabling asynchronous `showinfo` per-frame metadata extraction."
278
304
  )
279
305
 
306
+ # handle capturing FFmpeg stderr tail for diagnosing pipeline failures
307
+ self.__capture_stderr = self.__extra_params.pop("-capture_stderr", False)
308
+ if not isinstance(self.__capture_stderr, bool):
309
+ self.__capture_stderr = False
310
+ self.__stderr_tail: deque[str] = deque(maxlen=50)
311
+ self.__returncode: int | None = None
312
+
280
313
  # handle disabling window for ffmpeg subprocess on Windows OS
281
314
  # this patch prevents ffmpeg creation window from opening when
282
315
  # building exe files
@@ -364,7 +397,7 @@ class FFdecoder:
364
397
  # reset improper values
365
398
  self.__custom_resolution = None
366
399
 
367
- def formulate(self) -> FFdecoder:
400
+ def formulate(self) -> Self:
368
401
  """
369
402
  This method formulates all necessary FFmpeg pipeline arguments and executes it inside the FFmpeg `subprocess` pipe.
370
403
 
@@ -662,7 +695,9 @@ class FFdecoder:
662
695
  if "-frames:v" in input_params:
663
696
  self.__raw_frame_num = input_params["-frames:v"]
664
697
  elif (
665
- self.__sourcer_metadata["approx_video_nframes"] is not None
698
+ # composite output length is unrelated to the primary source's length
699
+ not self.__is_multi
700
+ and self.__sourcer_metadata["approx_video_nframes"] is not None
666
701
  and self.__sourcer_metadata["approx_video_nframes"] > 0
667
702
  ):
668
703
  self.__raw_frame_num = self.__sourcer_metadata["approx_video_nframes"]
@@ -778,16 +813,39 @@ class FFdecoder:
778
813
  while not self.__terminate_stream: # infinite raw frames
779
814
  frame = self.__fetchNextFrame()
780
815
  if frame is None:
781
- self.__terminate_stream = True
816
+ self.__handle_stream_end()
782
817
  break
783
818
  yield self.__attach_metadata(frame)
784
819
  else:
785
820
  for _ in range(self.__raw_frame_num): # finite raw frames
786
821
  frame = self.__fetchNextFrame()
787
822
  if frame is None:
788
- self.__terminate_stream = True
823
+ self.__handle_stream_end()
789
824
  break
790
825
  yield self.__attach_metadata(frame)
826
+ # generator exhausted, FFmpeg may still be flushing/exiting cleanly
827
+ self.__terminate_stream = True
828
+
829
+ def __handle_stream_end(self) -> None:
830
+ """
831
+ Internal: marks stream as ended on a short read and, when `-capture_stderr`
832
+ is enabled and FFmpeg exited with a non-zero code, logs the `stderr_tail`.
833
+ """
834
+ self.__terminate_stream = True
835
+ if not self.__capture_stderr or self.__process is None:
836
+ return
837
+ try:
838
+ retcode = self.__process.wait(timeout=TERMINATE_TIMEOUT_SUBPROCESS)
839
+ except sp.TimeoutExpired:
840
+ return
841
+ if retcode:
842
+ # let the reader thread drain remaining stderr lines first
843
+ self.__stderr_thread and self.__stderr_thread.join(timeout=TERMINATE_TIMEOUT_SUBPROCESS)
844
+ logger.error(
845
+ "FFmpeg exited with code {}. Last stderr lines:\n{}".format(
846
+ retcode, "\n".join(self.__stderr_tail)
847
+ )
848
+ )
791
849
 
792
850
  def __attach_metadata(self, frame: np.ndarray):
793
851
  """
@@ -825,6 +883,25 @@ class FFdecoder:
825
883
  """
826
884
  self.terminate()
827
885
 
886
+ @property
887
+ def returncode(self) -> int | None:
888
+ """
889
+ A property object that holds FFmpeg process exit code, set by `terminate()`.
890
+
891
+ **Returns:** FFmpeg exit code, or `None` if pipeline has not been terminated yet.
892
+ """
893
+ return self.__returncode
894
+
895
+ @property
896
+ def stderr_tail(self) -> list[str]:
897
+ """
898
+ A property object that holds the last 50 lines of FFmpeg's stderr output.
899
+ Only populated when `-capture_stderr` (or `-extract_metadata`) is enabled.
900
+
901
+ **Returns:** List of stderr lines (oldest first).
902
+ """
903
+ return list(self.__stderr_tail)
904
+
828
905
  @property
829
906
  def metadata(self) -> str:
830
907
  """
@@ -903,8 +980,10 @@ class FFdecoder:
903
980
  else:
904
981
  # otherwise discard and log it
905
982
  logger.warning(
906
- "Manually assigned `{}` metadata property value is of invalid type. Discarding!"
907
- ).format(key)
983
+ "Manually assigned `{}` metadata property value is of invalid type. Discarding!".format(
984
+ key
985
+ )
986
+ )
908
987
  # delete invalid key
909
988
  del value[key]
910
989
  # There is no concept of a tuple in the JSON format.
@@ -956,7 +1035,7 @@ class FFdecoder:
956
1035
  )
957
1036
  if _src_meta.get("source_demuxer"):
958
1037
  cmd += ["-f", _src_meta["source_demuxer"]]
959
- cmd += ["-i", _src]
1038
+ cmd += ["-i", _src_meta.get("source", _src)]
960
1039
  else:
961
1040
  cmd += self.__ffmpeg_prefixes
962
1041
  cmd += input_parameters
@@ -969,11 +1048,12 @@ class FFdecoder:
969
1048
 
970
1049
  cmd += output_parameters
971
1050
  cmd += ["-f", "rawvideo", "-"]
972
- # When metadata extraction is enabled we must capture stderr regardless
973
- # of verbose mode so the background reader thread can parse showinfo
974
- # lines. Without PIPE the reader would have nothing to read (verbose
1051
+ cmd += self.__extra_outputs
1052
+ # When metadata extraction or stderr capture is enabled we must pipe stderr
1053
+ # regardless of verbose mode so the background reader thread can consume
1054
+ # it. Without PIPE the reader would have nothing to read (verbose
975
1055
  # inherits parent stderr; silent discards it).
976
- if self.__extract_metadata:
1056
+ if self.__extract_metadata or self.__capture_stderr:
977
1057
  stderr_target = sp.PIPE
978
1058
  elif self.__verbose_logs:
979
1059
  stderr_target = None
@@ -996,18 +1076,20 @@ class FFdecoder:
996
1076
  ),
997
1077
  )
998
1078
 
999
- # spin up the stderr reader thread that parses `showinfo` lines and
1000
- # feeds per-frame metadata dicts into the queue consumed by generateFrame()
1001
- if self.__extract_metadata:
1002
- self.__metadata_queue = queue.Queue()
1079
+ # spin up the stderr reader thread that records stderr tail, and parses
1080
+ # `showinfo` lines into the metadata queue consumed by generateFrame()
1081
+ if self.__extract_metadata or self.__capture_stderr:
1082
+ if self.__extract_metadata:
1083
+ self.__metadata_queue = queue.Queue()
1003
1084
  self.__stderr_stop.clear()
1004
1085
  self.__stderr_thread = threading.Thread(target=self.__read_stderr, daemon=True)
1005
1086
  self.__stderr_thread.start()
1006
1087
 
1007
1088
  def __read_stderr(self) -> None:
1008
1089
  """
1009
- Internal: background daemon that parses FFmpeg `showinfo` lines off
1010
- stderr and pushes per-frame metadata dicts onto `__metadata_queue`.
1090
+ Internal: background daemon that appends FFmpeg stderr lines to `__stderr_tail`
1091
+ (echoing them when verbose), and parses `showinfo` lines into per-frame metadata
1092
+ dicts on `__metadata_queue` when `-extract_metadata` is enabled.
1011
1093
  Exits when FFmpeg closes stderr or when `__stderr_stop` is signalled.
1012
1094
  """
1013
1095
  assert self.__process is not None and self.__process.stderr is not None
@@ -1017,6 +1099,11 @@ class FFdecoder:
1017
1099
  if self.__stderr_stop.is_set():
1018
1100
  break
1019
1101
  decoded = line.decode("utf-8", errors="ignore")
1102
+ self.__stderr_tail.append(decoded.rstrip())
1103
+ # echo so verbose console output stays unchanged
1104
+ self.__verbose_logs and sys.stderr.write(decoded)
1105
+ if not self.__extract_metadata:
1106
+ continue
1020
1107
  match = _SHOWINFO_REGEX.search(decoded)
1021
1108
  if not match:
1022
1109
  continue
@@ -1032,35 +1119,51 @@ class FFdecoder:
1032
1119
  pass
1033
1120
  finally:
1034
1121
  # sentinel so consumers unblock on EOF
1035
- self.__metadata_queue.put(None)
1122
+ self.__metadata_queue is not None and self.__metadata_queue.put(None)
1036
1123
 
1037
- def terminate(self) -> None:
1124
+ def terminate(self) -> int | None:
1038
1125
  """
1039
1126
  Safely terminates all processes.
1127
+
1128
+ **Returns:** FFmpeg process exit code (also available via `returncode`), or `None` if no process was launched.
1040
1129
  """
1041
1130
 
1042
1131
  # signal we are closing
1043
1132
  self.__verbose_logs and logger.debug("Terminating FFdecoder Pipeline...")
1133
+ stream_exhausted = self.__terminate_stream
1044
1134
  self.__terminate_stream = True
1045
1135
  self.__stderr_stop.set()
1046
1136
  # check if no process was initiated at first place
1047
- if self.__process is None or self.__process.poll() is not None:
1137
+ if self.__process is None:
1048
1138
  logger.info("Pipeline already terminated.")
1049
- return
1139
+ return self.__returncode
1050
1140
  # Attempt to close pipeline.
1051
1141
  # close `stdin` output
1052
1142
  self.__process.stdin and self.__process.stdin.close()
1053
1143
  # close `stdout` output
1054
1144
  self.__process.stdout and self.__process.stdout.close()
1055
- # close `stderr` so the background reader thread's blocking readline() unblocks
1056
- self.__process.stderr and self.__process.stderr.close()
1057
- # terminate/kill process if still processing
1058
- self.__process.poll() is None and self.__process.terminate()
1059
- # wait if not exiting
1060
- self.__process.wait()
1061
- # join the stderr reader thread so it does not outlive the pipeline
1145
+ # let FFmpeg finalize `-extra_outputs` (trailers/moov) on EPIPE, or exit cleanly
1146
+ # after stream end, before forcing termination
1147
+ if self.__extra_outputs or stream_exhausted:
1148
+ try:
1149
+ self.__process.wait(TERMINATE_TIMEOUT_SUBPROCESS)
1150
+ except sp.TimeoutExpired:
1151
+ pass
1152
+ # terminate process if still processing, escalate to kill if unresponsive
1153
+ if self.__process.poll() is None:
1154
+ self.__process.terminate()
1155
+ try:
1156
+ self.__process.wait(TERMINATE_TIMEOUT_SUBPROCESS)
1157
+ except sp.TimeoutExpired:
1158
+ self.__process.kill()
1159
+ self.__process.wait()
1160
+ # join the stderr reader thread (it hits EOF once FFmpeg exits) so it does not outlive
1161
+ # the pipeline, then close `stderr`; closing earlier blocks on the reader's buffer lock
1062
1162
  if self.__stderr_thread is not None and self.__stderr_thread.is_alive():
1063
1163
  self.__stderr_thread.join(timeout=2.0)
1064
1164
  self.__stderr_thread = None
1165
+ self.__process.stderr and self.__process.stderr.close()
1166
+ self.__returncode = self.__process.returncode
1065
1167
  self.__process = None
1066
1168
  logger.info("Pipeline terminated successfully.")
1169
+ return self.__returncode
@@ -42,8 +42,14 @@ logger.propagate = False
42
42
  logger.addHandler(logger_handler())
43
43
  logger.setLevel(logging.DEBUG)
44
44
 
45
+ # set default timeout for subprocesses
46
+ MAX_TIMEOUT_SUBPROCESS: float = float(os.getenv("MAX_TIMEOUT_SUBPROCESS", 10.0))
47
+ # set grace period (seconds) between SIGTERM and SIGKILL when terminating
48
+ # a timed-out subprocess. Allows FFmpeg to flush buffers and release hardware
49
+ # resources before being force-killed.
50
+ TERMINATE_TIMEOUT_SUBPROCESS: float = float(os.getenv("TERMINATE_TIMEOUT_SUBPROCESS", 2.0))
45
51
  # set default timer for download requests
46
- DEFAULT_TIMEOUT = 3
52
+ DEFAULT_TIMEOUT_REQUESTS: float = float(os.getenv("DEFAULT_TIMEOUT_REQUESTS", 3.0))
47
53
 
48
54
 
49
55
  class TimeoutHTTPAdapter(HTTPAdapter):
@@ -52,7 +58,7 @@ class TimeoutHTTPAdapter(HTTPAdapter):
52
58
  """
53
59
 
54
60
  def __init__(self, *args: Any, **kwargs: Any) -> None:
55
- self.timeout: float = DEFAULT_TIMEOUT
61
+ self.timeout: float = DEFAULT_TIMEOUT_REQUESTS
56
62
  if "timeout" in kwargs:
57
63
  self.timeout = kwargs["timeout"]
58
64
  del kwargs["timeout"]
@@ -210,7 +216,9 @@ def download_ffmpeg_binaries(path: str, os_windows: bool = False, os_bit: str =
210
216
  status_forcelist=[429, 500, 502, 503, 504],
211
217
  )
212
218
  # Mount it for https usage
213
- adapter = TimeoutHTTPAdapter(timeout=2.0, max_retries=retries)
219
+ adapter = TimeoutHTTPAdapter(
220
+ timeout=MAX_TIMEOUT_SUBPROCESS, max_retries=retries
221
+ )
214
222
  http.mount("https://", adapter)
215
223
  response = http.get(file_url, stream=True)
216
224
  response.raise_for_status()
@@ -410,6 +418,7 @@ def extract_device_n_demuxer(
410
418
  metadata = check_sp_output(
411
419
  [path, *default_ffcommand.split(" ")],
412
420
  force_retrieve_stderr=True,
421
+ timeout=MAX_TIMEOUT_SUBPROCESS,
413
422
  )
414
423
  # clean and split metadata
415
424
  splitted = [x.decode("utf-8").strip() for x in metadata.split(b"\n")]
@@ -469,6 +478,7 @@ def extract_device_n_demuxer(
469
478
  # search in path properties
470
479
  metadata_path = check_sp_output(
471
480
  ["v4l2-ctl", "--device={}".format(path), "--all"],
481
+ timeout=MAX_TIMEOUT_SUBPROCESS,
472
482
  )
473
483
  # decode path metadata
474
484
  decoded_path = metadata_path.decode("utf-8").strip()
@@ -488,6 +498,7 @@ def extract_device_n_demuxer(
488
498
  metadata = check_sp_output(
489
499
  [path, *default_ffcommand.split(" ")],
490
500
  force_retrieve_stderr=True,
501
+ timeout=MAX_TIMEOUT_SUBPROCESS,
491
502
  )
492
503
  # clean and split metadata
493
504
  splitted = [x.decode("utf-8").strip() for x in metadata.split(b"\n")]
@@ -641,44 +652,85 @@ def check_sp_output(*args: Any, **kwargs: Any) -> bytes:
641
652
  """
642
653
  ## check_sp_output
643
654
 
644
- Returns FFmpeg `stdout` output from subprocess module.
655
+ Executes a subprocess command and returns its `stdout` (or `stderr` when
656
+ requested). On timeout, performs a two-step graceful shutdown — sends
657
+ `SIGTERM` first to allow FFmpeg to flush buffers and release hardware
658
+ resources (decoders, capture devices), then escalates to `SIGKILL` if the
659
+ process fails to exit within a short grace period.
645
660
 
646
661
  Parameters:
647
662
  args (based on input): Non Keyword Arguments
648
663
  kwargs (based on input): Keyword Arguments
664
+ force_retrieve_stderr (bool): If True, returns stderr. Also
665
+ suppresses `CalledProcessError` on non-zero exit, since some
666
+ FFmpeg diagnostic commands (e.g. `-list_devices`) emit useful
667
+ output on stderr while exiting non-zero by design.
668
+ timeout (float): Seconds to wait before terminating the process.
649
669
 
650
- **Returns:** A string value.
670
+ **Returns:** A bytes value.
651
671
  """
652
672
  # workaround for python bug: https://bugs.python.org/issue37380
653
673
  if platform.system() == "Windows":
654
674
  # see comment https://bugs.python.org/msg370334
655
675
  sp._cleanup = lambda: None
676
+
656
677
  # handle additional params
657
678
  retrieve_stderr = kwargs.pop("force_retrieve_stderr", False)
679
+ timeout = kwargs.pop("timeout", None)
680
+ # never inherit parent's stdin, avoids blocking on unexpected input reads
681
+ kwargs.setdefault("stdin", sp.DEVNULL)
682
+
658
683
  # execute command in subprocess
659
684
  process = sp.Popen(
660
685
  *args,
661
686
  stdout=sp.PIPE,
662
- stderr=sp.DEVNULL if not (retrieve_stderr) else sp.PIPE,
687
+ stderr=sp.PIPE if retrieve_stderr else sp.DEVNULL,
663
688
  **kwargs,
664
689
  )
665
- # communicate and poll process
666
- output, stderr = process.communicate()
690
+
691
+ # communicate and poll process with two-step timeout handling
692
+ timeout_occurred = False
693
+ try:
694
+ output, stderr = process.communicate(timeout=timeout)
695
+ except sp.TimeoutExpired:
696
+ timeout_occurred = True
697
+ logger.warning(
698
+ f"[Pipeline-Warning] :: Process exceeded timeout of {timeout}s. "
699
+ "Attempting graceful termination..."
700
+ )
701
+ # Step 1: polite SIGTERM, give process a chance to clean up
702
+ process.terminate()
703
+ try:
704
+ output, stderr = process.communicate(timeout=TERMINATE_TIMEOUT_SUBPROCESS)
705
+ except sp.TimeoutExpired:
706
+ # Step 2: process ignored SIGTERM, force kill
707
+ logger.error("[Pipeline-Error] :: Process unresponsive to SIGTERM. Hard killing...")
708
+ process.kill()
709
+ output, stderr = process.communicate()
710
+
667
711
  retcode = process.poll()
712
+
668
713
  # handle return code
669
- if retcode and not (retrieve_stderr):
670
- logger.error("[Pipeline-Error] :: {}".format(output.decode("utf-8")))
714
+ # Bypass CalledProcessError if caller wants stderr (some FFmpeg commands
715
+ # exit non-zero by design) or if we killed the process via our timeout.
716
+ if retcode and not retrieve_stderr and not timeout_occurred:
717
+ logger.error(
718
+ "[Pipeline-Error] :: {}".format(output.decode("utf-8") if output else "No output")
719
+ )
671
720
  cmd = kwargs.get("args")
672
721
  if cmd is None:
673
722
  cmd = args[0]
674
723
  error = sp.CalledProcessError(retcode, cmd)
675
724
  error.output = output
676
725
  raise error
677
- # raise error if no output
678
- bool(output) or bool(stderr) or logger.error(
679
- "[Pipeline-Error] :: Pipeline failed to exact any data from command: {}!".format(
680
- args[0] if args else []
726
+
727
+ # warn if process emitted nothing on either stream
728
+ if not (bool(output) or bool(stderr)):
729
+ logger.error(
730
+ "[Pipeline-Error] :: Pipeline failed to extract any data from command: {}!".format(
731
+ args[0] if args else []
732
+ )
681
733
  )
682
- )
683
- # return output otherwise
734
+
735
+ # return stderr when explicitly requested (and present), else stdout
684
736
  return stderr if retrieve_stderr and stderr else output