deffcode 0.2.7__py3-none-any.whl → 0.2.9__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.
deffcode/ffdecoder.py CHANGED
@@ -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
deffcode/ffhelper.py CHANGED
@@ -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
deffcode/sourcer.py CHANGED
@@ -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", "-clones"):
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
 
deffcode/version.py CHANGED
@@ -1 +1 @@
1
- __version__: str = "0.2.7"
1
+ __version__: str = "0.2.9"
@@ -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
@@ -0,0 +1,11 @@
1
+ deffcode/__init__.py,sha256=BQO_1Yv0E9PZCPtJ8-1z3X0VLP6VgBbdV5RahJU_RIk,286
2
+ deffcode/ffdecoder.py,sha256=4ywrmP076ZGdrZdcIrFP336HXEAyWT2NKmRiJOo7-5Q,55259
3
+ deffcode/ffhelper.py,sha256=Kms1CiVsyB3CgQvLdScGSuL5ml0eRqxm6xrTd-0g6fE,29032
4
+ deffcode/sourcer.py,sha256=xys3OH4AO0RgdaIj-bYc76rP0F3ZZJSocxGuM0OG2N0,43628
5
+ deffcode/utils.py,sha256=bIR1Hu_icLs3Xe7njHR9YE09MYUbvUfhOUMSqSHh730,4729
6
+ deffcode/version.py,sha256=cHThOwcEXKpIPW-ZC1HETHv-MyCCWj4sJISXFDdM8u4,27
7
+ deffcode-0.2.9.dist-info/licenses/LICENSE,sha256=pF-0xwtgiRjzW5P97fyEOCIqe7LV5Sd5cjIC4fBDwVQ,12259
8
+ deffcode-0.2.9.dist-info/METADATA,sha256=WMCK5mRr3eEfDt5wuvh7iG4gOfJlfKNeVttRqf73dIM,35196
9
+ deffcode-0.2.9.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ deffcode-0.2.9.dist-info/top_level.txt,sha256=1C-kyZgIKFviemaQMmhP0VKR3wkzEZcrf1o5oUckvHI,9
11
+ deffcode-0.2.9.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,11 +0,0 @@
1
- deffcode/__init__.py,sha256=BQO_1Yv0E9PZCPtJ8-1z3X0VLP6VgBbdV5RahJU_RIk,286
2
- deffcode/ffdecoder.py,sha256=BXCGHDg56lZg08UEX16jGZNaREIweIjnOt3wAn50aGk,50278
3
- deffcode/ffhelper.py,sha256=AP9OJoKrHFonAFfiv19QCl5G_LIuTUzRjQ7yT0sTLUo,26409
4
- deffcode/sourcer.py,sha256=V5TxxSvT1kEWm6e2x2w7Nrgi40Bg9IoKyTuyNsY0Y6Y,42405
5
- deffcode/utils.py,sha256=bIR1Hu_icLs3Xe7njHR9YE09MYUbvUfhOUMSqSHh730,4729
6
- deffcode/version.py,sha256=dxcyVFoGmkMidzPt7T448p1Z-y0g7gXUtzvD5b-1rgg,27
7
- deffcode-0.2.7.dist-info/licenses/LICENSE,sha256=pF-0xwtgiRjzW5P97fyEOCIqe7LV5Sd5cjIC4fBDwVQ,12259
8
- deffcode-0.2.7.dist-info/METADATA,sha256=syRnP0R-4DZnflnfVaZg64wbb-CA9WdEif2sw6v5XJI,33812
9
- deffcode-0.2.7.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
- deffcode-0.2.7.dist-info/top_level.txt,sha256=1C-kyZgIKFviemaQMmhP0VKR3wkzEZcrf1o5oUckvHI,9
11
- deffcode-0.2.7.dist-info/RECORD,,