deffcode 0.2.7__tar.gz → 0.2.8__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.8
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
@@ -113,7 +113,7 @@ Here are some key features that stand out:
113
113
  - Effortless [**Metadata Extraction**][extracting-video-metadata] from all streams available in the source.
114
114
  - Maintains the standard easy to learn [**OpenCV-Python**](https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html) coding syntax.
115
115
  - Out-of-the-box support for all prominent Computer Vision libraries.
116
- - Cross-platform, runs on Python 3.10+, and easy to install.
116
+ - Cross-platform, runs on Python 3.11+, and easy to install.
117
117
 
118
118
  <!--
119
119
  - [x] Lossless Transcoding support with [WriteGear](https://abhitronix.github.io/deffcode/latest/gears/writegear/introduction/). #TODO
@@ -235,6 +235,8 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
235
235
  - [CUDA-NVENC-accelerated Video Transcoding with WriteGear API][cuda-nvenc-accelerated-video-transcoding-with-writegear-api]
236
236
  - [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
237
  - [Multi-Input Source Configurations][multi-input-source-configurations]
238
+ - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
239
+ - [Decoding crossfade transition between multiple inputs][decoding-crossfade-transition-between-multiple-inputs]
238
240
 
239
241
  </details>
240
242
 
@@ -271,9 +273,8 @@ decoder = FFdecoder("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1
271
273
 
272
274
  # grab RGB24(default) 3D frames from decoder
273
275
  for frame in decoder.generateFrame():
274
-
275
276
  # lets print its shape
276
- print(frame.shape) # (1080, 1920, 3)
277
+ print(frame.shape) # (1080, 1920, 3)
277
278
 
278
279
  # terminate the decoder
279
280
  decoder.terminate()
@@ -288,7 +289,9 @@ The primary function of [**Sourcer API**][sourcer-api] is to **gather metadata i
288
289
  from deffcode import Sourcer
289
290
 
290
291
  # 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()
292
+ sourcer = Sourcer(
293
+ "https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4"
294
+ ).probe_stream()
292
295
 
293
296
  # print metadata as `json.dump`
294
297
  print(sourcer.retrieve_metadata(pretty_json=True))
@@ -354,18 +357,24 @@ It is something I am doing with my own free time. But so much more needs to be d
354
357
 
355
358
  Here is a Bibtex entry you can use to cite this project in a publication:
356
359
 
357
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.12689394.svg)](https://doi.org/10.5281/zenodo.12689394)
360
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19658542.svg)](https://doi.org/10.5281/zenodo.19658542)
358
361
 
359
362
  ```BibTeX
360
363
  @software{deffcode,
361
364
  author = {Abhishek Thakur},
362
- title = {abhiTronix/deffcode: v0.2.6},
363
- month = jul,
364
- year = 2024,
365
+ title = {abhiTronix/deffcode: v0.2.7},
366
+ month = apr,
367
+ year = 2026,
365
368
  publisher = {Zenodo},
366
- version = {v0.2.6},
367
- doi = {10.5281/zenodo.12689394},
368
- url = {https://doi.org/10.5281/zenodo.12689394},
369
+ version = {v0.2.7},
370
+ doi = {10.5281/zenodo.19658542},
371
+ url = {https://doi.org/10.5281/zenodo.19658542},
372
+ swhid = {swh:1:dir:85e8dfd087aed870deb4043c532faaa9b156d1e1
373
+ ;origin=https://doi.org/10.5281/zenodo.6976947;vis
374
+ it=swh:1:snp:af1b478890801c02d56b95f563b86761980bf
375
+ 82c;anchor=swh:1:rel:85f9af4452f8030317ec2012acc10
376
+ 29e8a945e2d;path=abhiTronix-deffcode-5162649
377
+ },
369
378
  }
370
379
  ```
371
380
 
@@ -478,6 +487,8 @@ Advanced Recipes
478
487
  [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
488
  [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
489
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
490
+ [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
491
+ [decoding-crossfade-transition-between-multiple-inputs]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-crossfade-transition-between-multiple-inputs
481
492
 
482
493
  <!--
483
494
  CI Apps URLs
@@ -73,7 +73,7 @@ Here are some key features that stand out:
73
73
  - Effortless [**Metadata Extraction**][extracting-video-metadata] from all streams available in the source.
74
74
  - Maintains the standard easy to learn [**OpenCV-Python**](https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html) coding syntax.
75
75
  - Out-of-the-box support for all prominent Computer Vision libraries.
76
- - Cross-platform, runs on Python 3.10+, and easy to install.
76
+ - Cross-platform, runs on Python 3.11+, and easy to install.
77
77
 
78
78
  <!--
79
79
  - [x] Lossless Transcoding support with [WriteGear](https://abhitronix.github.io/deffcode/latest/gears/writegear/introduction/). #TODO
@@ -195,6 +195,8 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
195
195
  - [CUDA-NVENC-accelerated Video Transcoding with WriteGear API][cuda-nvenc-accelerated-video-transcoding-with-writegear-api]
196
196
  - [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
197
  - [Multi-Input Source Configurations][multi-input-source-configurations]
198
+ - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
199
+ - [Decoding crossfade transition between multiple inputs][decoding-crossfade-transition-between-multiple-inputs]
198
200
 
199
201
  </details>
200
202
 
@@ -231,9 +233,8 @@ decoder = FFdecoder("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1
231
233
 
232
234
  # grab RGB24(default) 3D frames from decoder
233
235
  for frame in decoder.generateFrame():
234
-
235
236
  # lets print its shape
236
- print(frame.shape) # (1080, 1920, 3)
237
+ print(frame.shape) # (1080, 1920, 3)
237
238
 
238
239
  # terminate the decoder
239
240
  decoder.terminate()
@@ -248,7 +249,9 @@ The primary function of [**Sourcer API**][sourcer-api] is to **gather metadata i
248
249
  from deffcode import Sourcer
249
250
 
250
251
  # 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()
252
+ sourcer = Sourcer(
253
+ "https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4"
254
+ ).probe_stream()
252
255
 
253
256
  # print metadata as `json.dump`
254
257
  print(sourcer.retrieve_metadata(pretty_json=True))
@@ -314,18 +317,24 @@ It is something I am doing with my own free time. But so much more needs to be d
314
317
 
315
318
  Here is a Bibtex entry you can use to cite this project in a publication:
316
319
 
317
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.12689394.svg)](https://doi.org/10.5281/zenodo.12689394)
320
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19658542.svg)](https://doi.org/10.5281/zenodo.19658542)
318
321
 
319
322
  ```BibTeX
320
323
  @software{deffcode,
321
324
  author = {Abhishek Thakur},
322
- title = {abhiTronix/deffcode: v0.2.6},
323
- month = jul,
324
- year = 2024,
325
+ title = {abhiTronix/deffcode: v0.2.7},
326
+ month = apr,
327
+ year = 2026,
325
328
  publisher = {Zenodo},
326
- version = {v0.2.6},
327
- doi = {10.5281/zenodo.12689394},
328
- url = {https://doi.org/10.5281/zenodo.12689394},
329
+ version = {v0.2.7},
330
+ doi = {10.5281/zenodo.19658542},
331
+ url = {https://doi.org/10.5281/zenodo.19658542},
332
+ swhid = {swh:1:dir:85e8dfd087aed870deb4043c532faaa9b156d1e1
333
+ ;origin=https://doi.org/10.5281/zenodo.6976947;vis
334
+ it=swh:1:snp:af1b478890801c02d56b95f563b86761980bf
335
+ 82c;anchor=swh:1:rel:85f9af4452f8030317ec2012acc10
336
+ 29e8a945e2d;path=abhiTronix-deffcode-5162649
337
+ },
329
338
  }
330
339
  ```
331
340
 
@@ -438,6 +447,8 @@ Advanced Recipes
438
447
  [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
448
  [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
449
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
450
+ [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
451
+ [decoding-crossfade-transition-between-multiple-inputs]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-crossfade-transition-between-multiple-inputs
441
452
 
442
453
  <!--
443
454
  CI Apps URLs
@@ -30,7 +30,7 @@ import threading
30
30
  from collections import OrderedDict
31
31
  from collections.abc import Generator
32
32
  from types import TracebackType
33
- from typing import Any
33
+ from typing import Any, Self
34
34
 
35
35
  import numpy as np
36
36
 
@@ -226,6 +226,9 @@ class FFdecoder:
226
226
  if {"-vf", "-filter_complex"}.intersection(self.__extra_params.keys()):
227
227
  key = "-vf" if "-vf" in self.__extra_params else "-filter_complex"
228
228
  sourcer_params[key] = self.__extra_params[key]
229
+ # multi-input output streams are selected via `-map`, so probe with it too
230
+ if self.__is_multi and "-map" in self.__extra_params:
231
+ sourcer_params["-map"] = self.__extra_params["-map"]
229
232
 
230
233
  # define dict to store user-defined parameters
231
234
  self.__user_metadata = {}
@@ -364,7 +367,7 @@ class FFdecoder:
364
367
  # reset improper values
365
368
  self.__custom_resolution = None
366
369
 
367
- def formulate(self) -> FFdecoder:
370
+ def formulate(self) -> Self:
368
371
  """
369
372
  This method formulates all necessary FFmpeg pipeline arguments and executes it inside the FFmpeg `subprocess` pipe.
370
373
 
@@ -662,7 +665,9 @@ class FFdecoder:
662
665
  if "-frames:v" in input_params:
663
666
  self.__raw_frame_num = input_params["-frames:v"]
664
667
  elif (
665
- self.__sourcer_metadata["approx_video_nframes"] is not None
668
+ # composite output length is unrelated to the primary source's length
669
+ not self.__is_multi
670
+ and self.__sourcer_metadata["approx_video_nframes"] is not None
666
671
  and self.__sourcer_metadata["approx_video_nframes"] > 0
667
672
  ):
668
673
  self.__raw_frame_num = self.__sourcer_metadata["approx_video_nframes"]
@@ -956,7 +961,7 @@ class FFdecoder:
956
961
  )
957
962
  if _src_meta.get("source_demuxer"):
958
963
  cmd += ["-f", _src_meta["source_demuxer"]]
959
- cmd += ["-i", _src]
964
+ cmd += ["-i", _src_meta.get("source", _src)]
960
965
  else:
961
966
  cmd += self.__ffmpeg_prefixes
962
967
  cmd += input_parameters
@@ -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,83 @@ 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
+
658
681
  # execute command in subprocess
659
682
  process = sp.Popen(
660
683
  *args,
661
684
  stdout=sp.PIPE,
662
- stderr=sp.DEVNULL if not (retrieve_stderr) else sp.PIPE,
685
+ stderr=sp.PIPE if retrieve_stderr else sp.DEVNULL,
663
686
  **kwargs,
664
687
  )
665
- # communicate and poll process
666
- output, stderr = process.communicate()
688
+
689
+ # communicate and poll process with two-step timeout handling
690
+ timeout_occurred = False
691
+ try:
692
+ output, stderr = process.communicate(timeout=timeout)
693
+ except sp.TimeoutExpired:
694
+ timeout_occurred = True
695
+ logger.warning(
696
+ f"[Pipeline-Warning] :: Process exceeded timeout of {timeout}s. "
697
+ "Attempting graceful termination..."
698
+ )
699
+ # Step 1: polite SIGTERM, give process a chance to clean up
700
+ process.terminate()
701
+ try:
702
+ output, stderr = process.communicate(timeout=TERMINATE_TIMEOUT_SUBPROCESS)
703
+ except sp.TimeoutExpired:
704
+ # Step 2: process ignored SIGTERM, force kill
705
+ logger.error("[Pipeline-Error] :: Process unresponsive to SIGTERM. Hard killing...")
706
+ process.kill()
707
+ output, stderr = process.communicate()
708
+
667
709
  retcode = process.poll()
710
+
668
711
  # handle return code
669
- if retcode and not (retrieve_stderr):
670
- logger.error("[Pipeline-Error] :: {}".format(output.decode("utf-8")))
712
+ # Bypass CalledProcessError if caller wants stderr (some FFmpeg commands
713
+ # exit non-zero by design) or if we killed the process via our timeout.
714
+ if retcode and not retrieve_stderr and not timeout_occurred:
715
+ logger.error(
716
+ "[Pipeline-Error] :: {}".format(output.decode("utf-8") if output else "No output")
717
+ )
671
718
  cmd = kwargs.get("args")
672
719
  if cmd is None:
673
720
  cmd = args[0]
674
721
  error = sp.CalledProcessError(retcode, cmd)
675
722
  error.output = output
676
723
  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 []
724
+
725
+ # warn if process emitted nothing on either stream
726
+ if not (bool(output) or bool(stderr)):
727
+ logger.error(
728
+ "[Pipeline-Error] :: Pipeline failed to extract any data from command: {}!".format(
729
+ args[0] if args else []
730
+ )
681
731
  )
682
- )
683
- # return output otherwise
732
+
733
+ # return stderr when explicitly requested (and present), else stdout
684
734
  return stderr if retrieve_stderr and stderr else output
@@ -28,11 +28,12 @@ import os
28
28
  import platform
29
29
  import re
30
30
  import shutil
31
- from typing import Any
31
+ from typing import Any, Self
32
32
 
33
33
  import numpy as np
34
34
 
35
35
  from .ffhelper import (
36
+ MAX_TIMEOUT_SUBPROCESS,
36
37
  check_sp_output,
37
38
  extract_device_n_demuxer,
38
39
  get_supported_demuxers,
@@ -294,7 +295,7 @@ class Sourcer:
294
295
  # check whether metadata probed or not?
295
296
  self.__metadata_probed = False
296
297
 
297
- def probe_stream(self, default_stream_indexes: list[int] | tuple[int, int] = (0, 0)) -> Sourcer:
298
+ def probe_stream(self, default_stream_indexes: list[int] | tuple[int, int] = (0, 0)) -> Self:
298
299
  """
299
300
  This method Parses/Probes FFmpeg `subprocess` pipe's Standard Output for given input source and Populates the information in private class variables.
300
301
 
@@ -308,6 +309,40 @@ class Sourcer:
308
309
  and len(default_stream_indexes) == 2
309
310
  and all(isinstance(x, int) for x in default_stream_indexes)
310
311
  ), "Invalid default_stream_indexes value!"
312
+ # probe extra inputs first via independent single-source Sourcers, so
313
+ # their resolved source/demuxer (e.g. camera index -> device) can be
314
+ # reused by the combined filtergraph probe below.
315
+ extra_metadata = []
316
+ if self.__is_multi:
317
+ for idx in range(1, len(self.__source_list)):
318
+ _src = self.__source_list[idx]
319
+ _demux = self.__source_demuxer_list[idx]
320
+ _prefixes = self.__ffmpeg_prefixes_list[idx]
321
+ _params = self.__sourcer_params.copy()
322
+ _params["-ffprefixes"] = _prefixes
323
+ # output routing references all inputs; meaningless per-source
324
+ for _key in ("-vf", "-filter_complex", "-map"):
325
+ _params.pop(_key, None)
326
+ # Resolve to an absolute path: on Unix `self.__ffmpeg` may be
327
+ # the bare command "ffmpeg" found via PATH, which the nested
328
+ # `get_valid_ffmpeg_path()` would reject as "not a file".
329
+ _custom_ffmpeg = (
330
+ self.__ffmpeg
331
+ if self.__ffmpeg and os.path.isfile(self.__ffmpeg)
332
+ else (shutil.which(self.__ffmpeg) or "")
333
+ )
334
+ _s = Sourcer(
335
+ _src,
336
+ source_demuxer=_demux,
337
+ custom_ffmpeg=_custom_ffmpeg,
338
+ verbose=self.__verbose_logs,
339
+ **_params,
340
+ )
341
+ _s.probe_stream(default_stream_indexes)
342
+ extra_metadata.append(_s.retrieve_metadata(force_retrieve_missing=True))
343
+ self.__resolved_extra_inputs = [
344
+ (m[0]["source"], m[0].get("source_demuxer")) for m in extra_metadata
345
+ ]
311
346
  # validate source and extract metadata
312
347
  self.__ffsp_output = self.__validate_source(
313
348
  self.__source,
@@ -394,34 +429,7 @@ class Sourcer:
394
429
  # the guard inside retrieve_metadata (checks for non-empty
395
430
  # __multi_source_metadata) prevents `sources: []` self-pollution.
396
431
  self.__multi_source_metadata.append(self.retrieve_metadata(force_retrieve_missing=True))
397
- for idx in range(1, len(self.__source_list)):
398
- _src = self.__source_list[idx]
399
- _demux = self.__source_demuxer_list[idx]
400
- _prefixes = self.__ffmpeg_prefixes_list[idx]
401
- _params = self.__sourcer_params.copy()
402
- _params["-ffprefixes"] = _prefixes
403
- # spawn an independent single-source Sourcer per extra input;
404
- # this reuses the resolved ffmpeg path and isolates per-source
405
- # parsing state (which probe_stream otherwise clobbers).
406
- # Resolve to an absolute path: on Unix `self.__ffmpeg` may be
407
- # the bare command "ffmpeg" found via PATH, which the nested
408
- # `get_valid_ffmpeg_path()` would reject as "not a file".
409
- _custom_ffmpeg = (
410
- self.__ffmpeg
411
- if self.__ffmpeg and os.path.isfile(self.__ffmpeg)
412
- else (shutil.which(self.__ffmpeg) or "")
413
- )
414
- _s = Sourcer(
415
- _src,
416
- source_demuxer=_demux,
417
- custom_ffmpeg=_custom_ffmpeg,
418
- verbose=self.__verbose_logs,
419
- **_params,
420
- )
421
- _s.probe_stream(default_stream_indexes)
422
- self.__multi_source_metadata.append(
423
- _s.retrieve_metadata(force_retrieve_missing=True)
424
- )
432
+ self.__multi_source_metadata.extend(extra_metadata)
425
433
 
426
434
  # return reference to the instance object.
427
435
  return self
@@ -671,6 +679,20 @@ class Sourcer:
671
679
  + self.__ffmpeg_prefixes
672
680
  + (["-f", source_demuxer] if source_demuxer else [])
673
681
  + ["-i", source]
682
+ # multi-input filtergraphs reference every input (e.g. `[1:v]`),
683
+ # so the probe must ingest all of them to resolve output metadata.
684
+ + [
685
+ arg
686
+ for (src, demux), prefixes in zip(
687
+ self.__resolved_extra_inputs,
688
+ self.__ffmpeg_prefixes_list[1:],
689
+ strict=True,
690
+ )
691
+ for arg in ["-t", "0.0001"]
692
+ + prefixes
693
+ + (["-f", demux] if demux else [])
694
+ + ["-i", src]
695
+ ]
674
696
  + dict2Args(self.__sourcer_params)
675
697
  + ["-f", "null", "-"]
676
698
  )
@@ -687,13 +709,16 @@ class Sourcer:
687
709
  check_sp_output(
688
710
  meta_cmd,
689
711
  force_retrieve_stderr=True,
712
+ timeout=MAX_TIMEOUT_SUBPROCESS,
690
713
  )
691
714
  .decode("utf-8")
692
715
  .strip()
693
716
  )
694
717
  # separate input and output metadata (if available)
695
718
  if "Output #" in metadata:
696
- (metadata, self.__metadata_output) = metadata.split("Output #")
719
+ metadata, self.__metadata_output = metadata.split("Output #")
720
+ # primary `source_*` metadata must only describe input 0
721
+ metadata = re.split(r"^Input #1\b", metadata, maxsplit=1, flags=re.M)[0]
697
722
  # return metadata based on params
698
723
  return metadata
699
724
 
@@ -0,0 +1 @@
1
+ __version__: str = "0.2.8"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deffcode
3
- Version: 0.2.7
3
+ Version: 0.2.8
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
@@ -113,7 +113,7 @@ Here are some key features that stand out:
113
113
  - Effortless [**Metadata Extraction**][extracting-video-metadata] from all streams available in the source.
114
114
  - Maintains the standard easy to learn [**OpenCV-Python**](https://docs.opencv.org/4.x/d6/d00/tutorial_py_root.html) coding syntax.
115
115
  - Out-of-the-box support for all prominent Computer Vision libraries.
116
- - Cross-platform, runs on Python 3.10+, and easy to install.
116
+ - Cross-platform, runs on Python 3.11+, and easy to install.
117
117
 
118
118
  <!--
119
119
  - [x] Lossless Transcoding support with [WriteGear](https://abhitronix.github.io/deffcode/latest/gears/writegear/introduction/). #TODO
@@ -235,6 +235,8 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
235
235
  - [CUDA-NVENC-accelerated Video Transcoding with WriteGear API][cuda-nvenc-accelerated-video-transcoding-with-writegear-api]
236
236
  - [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
237
  - [Multi-Input Source Configurations][multi-input-source-configurations]
238
+ - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
239
+ - [Decoding crossfade transition between multiple inputs][decoding-crossfade-transition-between-multiple-inputs]
238
240
 
239
241
  </details>
240
242
 
@@ -271,9 +273,8 @@ decoder = FFdecoder("https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1
271
273
 
272
274
  # grab RGB24(default) 3D frames from decoder
273
275
  for frame in decoder.generateFrame():
274
-
275
276
  # lets print its shape
276
- print(frame.shape) # (1080, 1920, 3)
277
+ print(frame.shape) # (1080, 1920, 3)
277
278
 
278
279
  # terminate the decoder
279
280
  decoder.terminate()
@@ -288,7 +289,9 @@ The primary function of [**Sourcer API**][sourcer-api] is to **gather metadata i
288
289
  from deffcode import Sourcer
289
290
 
290
291
  # 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()
292
+ sourcer = Sourcer(
293
+ "https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4"
294
+ ).probe_stream()
292
295
 
293
296
  # print metadata as `json.dump`
294
297
  print(sourcer.retrieve_metadata(pretty_json=True))
@@ -354,18 +357,24 @@ It is something I am doing with my own free time. But so much more needs to be d
354
357
 
355
358
  Here is a Bibtex entry you can use to cite this project in a publication:
356
359
 
357
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.12689394.svg)](https://doi.org/10.5281/zenodo.12689394)
360
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19658542.svg)](https://doi.org/10.5281/zenodo.19658542)
358
361
 
359
362
  ```BibTeX
360
363
  @software{deffcode,
361
364
  author = {Abhishek Thakur},
362
- title = {abhiTronix/deffcode: v0.2.6},
363
- month = jul,
364
- year = 2024,
365
+ title = {abhiTronix/deffcode: v0.2.7},
366
+ month = apr,
367
+ year = 2026,
365
368
  publisher = {Zenodo},
366
- version = {v0.2.6},
367
- doi = {10.5281/zenodo.12689394},
368
- url = {https://doi.org/10.5281/zenodo.12689394},
369
+ version = {v0.2.7},
370
+ doi = {10.5281/zenodo.19658542},
371
+ url = {https://doi.org/10.5281/zenodo.19658542},
372
+ swhid = {swh:1:dir:85e8dfd087aed870deb4043c532faaa9b156d1e1
373
+ ;origin=https://doi.org/10.5281/zenodo.6976947;vis
374
+ it=swh:1:snp:af1b478890801c02d56b95f563b86761980bf
375
+ 82c;anchor=swh:1:rel:85f9af4452f8030317ec2012acc10
376
+ 29e8a945e2d;path=abhiTronix-deffcode-5162649
377
+ },
369
378
  }
370
379
  ```
371
380
 
@@ -478,6 +487,8 @@ Advanced Recipes
478
487
  [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
488
  [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
489
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
490
+ [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
491
+ [decoding-crossfade-transition-between-multiple-inputs]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#decoding-crossfade-transition-between-multiple-inputs
481
492
 
482
493
  <!--
483
494
  CI Apps URLs
@@ -7,7 +7,7 @@ name = "deffcode"
7
7
  description = "A cross-platform High-performance & Flexible Real-time Video Frames Decoder in Python."
8
8
  authors = [{name = "Abhishek Thakur", email = "abhi.una12@gmail.com"}]
9
9
  license = {text = "Apache License 2.0"}
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  keywords = [
12
12
  "FFmpeg",
13
13
  "Decoder",
@@ -30,10 +30,10 @@ classifiers = [
30
30
  "Intended Audience :: Science/Research",
31
31
  "Intended Audience :: Education",
32
32
  "License :: OSI Approved :: Apache Software License",
33
- "Programming Language :: Python :: 3.10",
34
33
  "Programming Language :: Python :: 3.11",
35
34
  "Programming Language :: Python :: 3.12",
36
35
  "Programming Language :: Python :: 3.13",
36
+ "Programming Language :: Python :: 3.14",
37
37
  ]
38
38
  dependencies = [
39
39
  "cython",
@@ -58,9 +58,14 @@ packages = ["deffcode"]
58
58
  [tool.setuptools.dynamic]
59
59
  version = {attr = "deffcode.version.__version__"}
60
60
 
61
+ [tool.poetry]
62
+ # Poetry can't read setuptools' dynamic version,
63
+ # keep in sync with deffcode/version.py
64
+ version = "0.2.8"
65
+
61
66
  [tool.ruff]
62
67
  line-length = 100
63
- target-version = "py310"
68
+ target-version = "py311"
64
69
  extend-exclude = [
65
70
  "build",
66
71
  "dist",
@@ -667,7 +667,7 @@ def test_FFdecoder_params(source: str | list[str], ffparams: dict[str, Any], res
667
667
  # prepare OpenCV parameters
668
668
  FOURCC = cv2.VideoWriter_fourcc("M", "J", "P", "G")
669
669
  FRAMERATE = metadata_dict["source_video_framerate"]
670
- FRAMESIZE = tuple(metadata_dict["source_video_resolution"])
670
+ FRAMESIZE = tuple(metadata_dict["output_frames_resolution"])
671
671
 
672
672
  # Define writer with parameters and suitable output filename for e.g. `output_foo.avi`
673
673
  writer = cv2.VideoWriter(f_name, FOURCC, FRAMERATE, FRAMESIZE)
@@ -848,3 +848,39 @@ def test_discard_n_filter_params(frame_format: str, ffparams: dict[str, Any], re
848
848
  finally:
849
849
  # terminate
850
850
  decoder is not None and decoder.terminate()
851
+
852
+
853
+ _grid = "[0:v][1:v][2:v][3:v]xstack=inputs=4:layout=0_0|w0_0|0_h0|w0_h0[out]"
854
+ test_data = [
855
+ (["320x240"] * 2, {"-filter_complex": "hstack=inputs=2"}, (240, 640), 50),
856
+ (
857
+ ["320x240", "160x120"],
858
+ {"-filter_complex": "[1:v]scale=80:-1[s];[0:v][s]overlay=W-w-10:H-h-10"},
859
+ (240, 320),
860
+ 50,
861
+ ),
862
+ (["320x240"] * 4, {"-filter_complex": _grid, "-map": "[out]"}, (480, 640), 50),
863
+ (
864
+ ["320x240"] * 2,
865
+ {"-filter_complex": "[0:v][1:v]xfade=duration=1:offset=1[v]", "-map": "[v]"},
866
+ (240, 320),
867
+ 75,
868
+ ),
869
+ (["320x240", "160x120"], {"-map": "1:v"}, (120, 160), 50),
870
+ ]
871
+
872
+
873
+ @pytest.mark.parametrize("sizes, ffparams, shape, n_frames", test_data)
874
+ def test_multi_input_composites(
875
+ sizes: list[str], ffparams: dict[str, Any], shape: tuple[int, int], n_frames: int
876
+ ) -> None:
877
+ """
878
+ Testing multi-input filtergraph recipes yield frames of the composite size and length
879
+ """
880
+ source = ["testsrc=size={}:rate=25:duration=2".format(s) for s in sizes]
881
+ with FFdecoder(
882
+ source, source_demuxer=["lavfi"] * len(sizes), frame_format="bgr24", **ffparams
883
+ ) as decoder:
884
+ frames = [f for f in decoder.generateFrame() if f is not None]
885
+ assert frames and frames[0].shape == (*shape, 3)
886
+ assert abs(len(frames) - n_frames) <= 1
@@ -145,9 +145,70 @@ def test_get_valid_ffmpeg_path(paths: str, ffmpeg_download_paths: str, results:
145
145
  @pytest.mark.xfail(raises=Exception)
146
146
  def test_check_sp_output() -> None:
147
147
  """
148
- Testing check_sp_output method
148
+ Testing check_sp_output method raises CalledProcessError on invalid flags.
149
149
  """
150
- check_sp_output(["ffmpeg", "-Vv"])
150
+ check_sp_output(["ffmpeg", "-Vv"], timeout=2.0)
151
+
152
+
153
+ def test_check_sp_output_happy_path() -> None:
154
+ """
155
+ Testing check_sp_output returns stdout bytes for a successful command.
156
+ """
157
+ output = check_sp_output([return_static_ffmpeg(), "-version"], timeout=5.0)
158
+ assert isinstance(output, bytes) and len(output) > 0, "Expected non-empty stdout"
159
+ assert b"ffmpeg version" in output, "Expected ffmpeg version banner in stdout"
160
+
161
+
162
+ def test_check_sp_output_force_retrieve_stderr_bypass() -> None:
163
+ """
164
+ Testing that `force_retrieve_stderr=True` suppresses CalledProcessError on
165
+ non-zero exit and returns stderr bytes. Mirrors how `extract_device_n_demuxer`
166
+ consumes `ffmpeg -list_devices` output (which exits non-zero by design).
167
+ """
168
+ # `-list_devices true -i dummy` exits non-zero on every platform
169
+ cmd = [
170
+ return_static_ffmpeg(),
171
+ "-hide_banner",
172
+ "-list_devices",
173
+ "-i",
174
+ "dummy",
175
+ ]
176
+ stderr = check_sp_output(cmd, force_retrieve_stderr=True, timeout=5.0)
177
+ assert isinstance(stderr, bytes), "Expected bytes return on stderr bypass"
178
+
179
+
180
+ def test_check_sp_output_graceful_timeout() -> None:
181
+ """
182
+ Testing two-step timeout handling: a long-running FFmpeg process must be
183
+ terminated within roughly `timeout + grace_period` seconds and must NOT
184
+ raise CalledProcessError (timeout kills are bypassed by design).
185
+ """
186
+ import time
187
+
188
+ # null source → null sink loop runs forever; ensures we hit the timeout
189
+ cmd = [
190
+ return_static_ffmpeg(),
191
+ "-hide_banner",
192
+ "-loglevel",
193
+ "error",
194
+ "-f",
195
+ "lavfi",
196
+ "-i",
197
+ "nullsrc",
198
+ "-t",
199
+ "60",
200
+ "-f",
201
+ "null",
202
+ "-",
203
+ ]
204
+ timeout = 1.0
205
+ start = time.monotonic()
206
+ output = check_sp_output(cmd, timeout=timeout)
207
+ elapsed = time.monotonic() - start
208
+ # must return bytes (possibly empty), no exception
209
+ assert isinstance(output, bytes), "Expected bytes return on graceful timeout"
210
+ # must complete within timeout + 2s grace + reasonable overhead
211
+ assert elapsed < timeout + 5.0, f"Process took {elapsed:.2f}s — graceful shutdown likely stuck"
151
212
 
152
213
 
153
214
  @pytest.mark.parametrize(
@@ -196,3 +196,28 @@ def test_probe_stream_n_retrieve_metadata(
196
196
  pytest.xfail("Test Still Passed!")
197
197
  else:
198
198
  pytest.fail(str(e))
199
+
200
+
201
+ def test_multi_input_primary_metadata() -> None:
202
+ """
203
+ Primary `source_*` metadata must describe input 0 only, even with a
204
+ nonzero default video stream index and a combined filtergraph probe.
205
+ """
206
+ metadata = (
207
+ Sourcer(
208
+ [
209
+ "testsrc=size=320x240:rate=25:duration=2",
210
+ "testsrc=size=160x120:rate=10:duration=2",
211
+ ],
212
+ source_demuxer="lavfi",
213
+ **{"-filter_complex": "[0:v][1:v]overlay"},
214
+ )
215
+ .probe_stream(default_stream_indexes=(1, 0))
216
+ .retrieve_metadata()
217
+ )
218
+ assert metadata["source_video_resolution"] == [320, 240]
219
+ assert metadata["source_video_framerate"] == 25.0
220
+ assert [s["source_video_resolution"] for s in metadata["sources"]] == [
221
+ [320, 240],
222
+ [160, 120],
223
+ ]
@@ -1 +0,0 @@
1
- __version__: str = "0.2.7"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes