deffcode 0.2.8__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.8
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
@@ -108,6 +108,7 @@ 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.
@@ -237,6 +238,9 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
237
238
  - [Multi-Input Source Configurations][multi-input-source-configurations]
238
239
  - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
239
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]
240
244
 
241
245
  </details>
242
246
 
@@ -357,24 +361,18 @@ It is something I am doing with my own free time. But so much more needs to be d
357
361
 
358
362
  Here is a Bibtex entry you can use to cite this project in a publication:
359
363
 
360
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19658542.svg)](https://doi.org/10.5281/zenodo.19658542)
364
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23446604.svg)](https://doi.org/10.5281/zenodo.23446604)
361
365
 
362
366
  ```BibTeX
363
367
  @software{deffcode,
364
368
  author = {Abhishek Thakur},
365
- title = {abhiTronix/deffcode: v0.2.7},
366
- month = apr,
369
+ title = {abhiTronix/deffcode: v0.2.8},
370
+ month = oct,
367
371
  year = 2026,
368
372
  publisher = {Zenodo},
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
- },
373
+ version = {v0.2.8},
374
+ doi = {10.5281/zenodo.23446604},
375
+ url = {https://doi.org/10.5281/zenodo.23446604},
378
376
  }
379
377
  ```
380
378
 
@@ -489,6 +487,9 @@ Advanced Recipes
489
487
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
490
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
491
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
492
493
 
493
494
  <!--
494
495
  CI Apps URLs
@@ -68,6 +68,7 @@ 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.
@@ -197,6 +198,9 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
197
198
  - [Multi-Input Source Configurations][multi-input-source-configurations]
198
199
  - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
199
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]
200
204
 
201
205
  </details>
202
206
 
@@ -317,24 +321,18 @@ It is something I am doing with my own free time. But so much more needs to be d
317
321
 
318
322
  Here is a Bibtex entry you can use to cite this project in a publication:
319
323
 
320
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19658542.svg)](https://doi.org/10.5281/zenodo.19658542)
324
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23446604.svg)](https://doi.org/10.5281/zenodo.23446604)
321
325
 
322
326
  ```BibTeX
323
327
  @software{deffcode,
324
328
  author = {Abhishek Thakur},
325
- title = {abhiTronix/deffcode: v0.2.7},
326
- month = apr,
329
+ title = {abhiTronix/deffcode: v0.2.8},
330
+ month = oct,
327
331
  year = 2026,
328
332
  publisher = {Zenodo},
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
- },
333
+ version = {v0.2.8},
334
+ doi = {10.5281/zenodo.23446604},
335
+ url = {https://doi.org/10.5281/zenodo.23446604},
338
336
  }
339
337
  ```
340
338
 
@@ -449,6 +447,9 @@ Advanced Recipes
449
447
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
450
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
451
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
452
453
 
453
454
  <!--
454
455
  CI Apps URLs
@@ -26,9 +26,11 @@ 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
35
  from typing import Any, Self
34
36
 
@@ -36,6 +38,7 @@ 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(
@@ -229,6 +241,17 @@ class FFdecoder:
229
241
  # multi-input output streams are selected via `-map`, so probe with it too
230
242
  if self.__is_multi and "-map" in self.__extra_params:
231
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
+ ]
232
255
 
233
256
  # define dict to store user-defined parameters
234
257
  self.__user_metadata = {}
@@ -280,6 +303,13 @@ class FFdecoder:
280
303
  "Enabling asynchronous `showinfo` per-frame metadata extraction."
281
304
  )
282
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
+
283
313
  # handle disabling window for ffmpeg subprocess on Windows OS
284
314
  # this patch prevents ffmpeg creation window from opening when
285
315
  # building exe files
@@ -783,16 +813,39 @@ class FFdecoder:
783
813
  while not self.__terminate_stream: # infinite raw frames
784
814
  frame = self.__fetchNextFrame()
785
815
  if frame is None:
786
- self.__terminate_stream = True
816
+ self.__handle_stream_end()
787
817
  break
788
818
  yield self.__attach_metadata(frame)
789
819
  else:
790
820
  for _ in range(self.__raw_frame_num): # finite raw frames
791
821
  frame = self.__fetchNextFrame()
792
822
  if frame is None:
793
- self.__terminate_stream = True
823
+ self.__handle_stream_end()
794
824
  break
795
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
+ )
796
849
 
797
850
  def __attach_metadata(self, frame: np.ndarray):
798
851
  """
@@ -830,6 +883,25 @@ class FFdecoder:
830
883
  """
831
884
  self.terminate()
832
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
+
833
905
  @property
834
906
  def metadata(self) -> str:
835
907
  """
@@ -908,8 +980,10 @@ class FFdecoder:
908
980
  else:
909
981
  # otherwise discard and log it
910
982
  logger.warning(
911
- "Manually assigned `{}` metadata property value is of invalid type. Discarding!"
912
- ).format(key)
983
+ "Manually assigned `{}` metadata property value is of invalid type. Discarding!".format(
984
+ key
985
+ )
986
+ )
913
987
  # delete invalid key
914
988
  del value[key]
915
989
  # There is no concept of a tuple in the JSON format.
@@ -974,11 +1048,12 @@ class FFdecoder:
974
1048
 
975
1049
  cmd += output_parameters
976
1050
  cmd += ["-f", "rawvideo", "-"]
977
- # When metadata extraction is enabled we must capture stderr regardless
978
- # of verbose mode so the background reader thread can parse showinfo
979
- # 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
980
1055
  # inherits parent stderr; silent discards it).
981
- if self.__extract_metadata:
1056
+ if self.__extract_metadata or self.__capture_stderr:
982
1057
  stderr_target = sp.PIPE
983
1058
  elif self.__verbose_logs:
984
1059
  stderr_target = None
@@ -1001,18 +1076,20 @@ class FFdecoder:
1001
1076
  ),
1002
1077
  )
1003
1078
 
1004
- # spin up the stderr reader thread that parses `showinfo` lines and
1005
- # feeds per-frame metadata dicts into the queue consumed by generateFrame()
1006
- if self.__extract_metadata:
1007
- 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()
1008
1084
  self.__stderr_stop.clear()
1009
1085
  self.__stderr_thread = threading.Thread(target=self.__read_stderr, daemon=True)
1010
1086
  self.__stderr_thread.start()
1011
1087
 
1012
1088
  def __read_stderr(self) -> None:
1013
1089
  """
1014
- Internal: background daemon that parses FFmpeg `showinfo` lines off
1015
- 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.
1016
1093
  Exits when FFmpeg closes stderr or when `__stderr_stop` is signalled.
1017
1094
  """
1018
1095
  assert self.__process is not None and self.__process.stderr is not None
@@ -1022,6 +1099,11 @@ class FFdecoder:
1022
1099
  if self.__stderr_stop.is_set():
1023
1100
  break
1024
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
1025
1107
  match = _SHOWINFO_REGEX.search(decoded)
1026
1108
  if not match:
1027
1109
  continue
@@ -1037,35 +1119,51 @@ class FFdecoder:
1037
1119
  pass
1038
1120
  finally:
1039
1121
  # sentinel so consumers unblock on EOF
1040
- self.__metadata_queue.put(None)
1122
+ self.__metadata_queue is not None and self.__metadata_queue.put(None)
1041
1123
 
1042
- def terminate(self) -> None:
1124
+ def terminate(self) -> int | None:
1043
1125
  """
1044
1126
  Safely terminates all processes.
1127
+
1128
+ **Returns:** FFmpeg process exit code (also available via `returncode`), or `None` if no process was launched.
1045
1129
  """
1046
1130
 
1047
1131
  # signal we are closing
1048
1132
  self.__verbose_logs and logger.debug("Terminating FFdecoder Pipeline...")
1133
+ stream_exhausted = self.__terminate_stream
1049
1134
  self.__terminate_stream = True
1050
1135
  self.__stderr_stop.set()
1051
1136
  # check if no process was initiated at first place
1052
- if self.__process is None or self.__process.poll() is not None:
1137
+ if self.__process is None:
1053
1138
  logger.info("Pipeline already terminated.")
1054
- return
1139
+ return self.__returncode
1055
1140
  # Attempt to close pipeline.
1056
1141
  # close `stdin` output
1057
1142
  self.__process.stdin and self.__process.stdin.close()
1058
1143
  # close `stdout` output
1059
1144
  self.__process.stdout and self.__process.stdout.close()
1060
- # close `stderr` so the background reader thread's blocking readline() unblocks
1061
- self.__process.stderr and self.__process.stderr.close()
1062
- # terminate/kill process if still processing
1063
- self.__process.poll() is None and self.__process.terminate()
1064
- # wait if not exiting
1065
- self.__process.wait()
1066
- # 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
1067
1162
  if self.__stderr_thread is not None and self.__stderr_thread.is_alive():
1068
1163
  self.__stderr_thread.join(timeout=2.0)
1069
1164
  self.__stderr_thread = None
1165
+ self.__process.stderr and self.__process.stderr.close()
1166
+ self.__returncode = self.__process.returncode
1070
1167
  self.__process = None
1071
1168
  logger.info("Pipeline terminated successfully.")
1169
+ return self.__returncode
@@ -677,6 +677,8 @@ def check_sp_output(*args: Any, **kwargs: Any) -> bytes:
677
677
  # handle additional params
678
678
  retrieve_stderr = kwargs.pop("force_retrieve_stderr", False)
679
679
  timeout = kwargs.pop("timeout", None)
680
+ # never inherit parent's stdin, avoids blocking on unexpected input reads
681
+ kwargs.setdefault("stdin", sp.DEVNULL)
680
682
 
681
683
  # execute command in subprocess
682
684
  process = sp.Popen(
@@ -321,7 +321,7 @@ class Sourcer:
321
321
  _params = self.__sourcer_params.copy()
322
322
  _params["-ffprefixes"] = _prefixes
323
323
  # output routing references all inputs; meaningless per-source
324
- for _key in ("-vf", "-filter_complex", "-map"):
324
+ for _key in ("-vf", "-filter_complex", "-map", "-clones"):
325
325
  _params.pop(_key, None)
326
326
  # Resolve to an absolute path: on Unix `self.__ffmpeg` may be
327
327
  # the bare command "ffmpeg" found via PATH, which the nested
@@ -0,0 +1 @@
1
+ __version__: str = "0.2.9"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deffcode
3
- Version: 0.2.8
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
@@ -108,6 +108,7 @@ 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.
@@ -237,6 +238,9 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][
237
238
  - [Multi-Input Source Configurations][multi-input-source-configurations]
238
239
  - [Decoding multiple inputs as 2x2 video grid _(mosaic)_][decoding-multiple-inputs-as-2x2-video-grid-mosaic]
239
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]
240
244
 
241
245
  </details>
242
246
 
@@ -357,24 +361,18 @@ It is something I am doing with my own free time. But so much more needs to be d
357
361
 
358
362
  Here is a Bibtex entry you can use to cite this project in a publication:
359
363
 
360
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19658542.svg)](https://doi.org/10.5281/zenodo.19658542)
364
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23446604.svg)](https://doi.org/10.5281/zenodo.23446604)
361
365
 
362
366
  ```BibTeX
363
367
  @software{deffcode,
364
368
  author = {Abhishek Thakur},
365
- title = {abhiTronix/deffcode: v0.2.7},
366
- month = apr,
369
+ title = {abhiTronix/deffcode: v0.2.8},
370
+ month = oct,
367
371
  year = 2026,
368
372
  publisher = {Zenodo},
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
- },
373
+ version = {v0.2.8},
374
+ doi = {10.5281/zenodo.23446604},
375
+ url = {https://doi.org/10.5281/zenodo.23446604},
378
376
  }
379
377
  ```
380
378
 
@@ -489,6 +487,9 @@ Advanced Recipes
489
487
  [multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations
490
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
491
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
492
493
 
493
494
  <!--
494
495
  CI Apps URLs
@@ -61,7 +61,7 @@ version = {attr = "deffcode.version.__version__"}
61
61
  [tool.poetry]
62
62
  # Poetry can't read setuptools' dynamic version,
63
63
  # keep in sync with deffcode/version.py
64
- version = "0.2.8"
64
+ version = "0.2.9"
65
65
 
66
66
  [tool.ruff]
67
67
  line-length = 100
@@ -450,6 +450,79 @@ def test_extract_luma_invalid_type() -> None:
450
450
  decoder is not None and decoder.terminate()
451
451
 
452
452
 
453
+ @pytest.mark.parametrize("verbose", [True, False])
454
+ def test_capture_stderr_on_failure(verbose: bool) -> None:
455
+ """
456
+ With `-capture_stderr`, an FFmpeg pipeline that dies (invalid filter) must end
457
+ the generator gracefully, expose its non-zero exit code via `terminate()`/`returncode`,
458
+ and keep its error output in `stderr_tail`.
459
+ """
460
+ decoder = FFdecoder(
461
+ return_testvideo_path(fmt="vo"),
462
+ custom_ffmpeg=return_static_ffmpeg(),
463
+ verbose=verbose,
464
+ **{"-capture_stderr": True, "-vf": "nonexistentfilter"},
465
+ ).formulate()
466
+ assert decoder.returncode is None, "Test failed - returncode set before termination"
467
+ assert list(decoder.generateFrame()) == [], "Test failed - expected no frames"
468
+ retcode = decoder.terminate()
469
+ assert retcode and retcode == decoder.returncode, f"Test failed - got exit code {retcode}"
470
+ assert any("nonexistentfilter" in line for line in decoder.stderr_tail), (
471
+ f"Test failed - error missing from stderr_tail: {decoder.stderr_tail}"
472
+ )
473
+ # repeated terminate must be idempotent
474
+ assert decoder.terminate() == retcode, "Test failed - returncode lost on re-terminate"
475
+
476
+
477
+ @pytest.mark.parametrize("capture", [True, "yes"])
478
+ def test_capture_stderr_success(capture: Any) -> None:
479
+ """
480
+ A cleanly finished pipeline must report exit code 0. Non-bool
481
+ `-capture_stderr` values are discarded, leaving `stderr_tail` empty.
482
+ """
483
+ decoder = FFdecoder(
484
+ return_testvideo_path(fmt="vo"),
485
+ custom_ffmpeg=return_static_ffmpeg(),
486
+ **{"-capture_stderr": capture, "-vframes": 5},
487
+ ).formulate()
488
+ assert len(list(decoder.generateFrame())) == 5, "Test failed - expected 5 frames"
489
+ assert decoder.terminate() == 0, "Test failed - expected exit code 0"
490
+ assert bool(decoder.stderr_tail) == (capture is True), (
491
+ f"Test failed - unexpected stderr_tail: {decoder.stderr_tail}"
492
+ )
493
+
494
+
495
+ def test_returncode_on_stream_exhausted() -> None:
496
+ """
497
+ Decoding a source to its end and terminating right away must report exit
498
+ code 0, not a SIGTERM-induced one while FFmpeg is still exiting.
499
+ """
500
+ decoder = FFdecoder(
501
+ return_testvideo_path(fmt="vo"), custom_ffmpeg=return_static_ffmpeg()
502
+ ).formulate()
503
+ # discard frames to consume fast, racing FFmpeg exit
504
+ assert sum(1 for _ in decoder.generateFrame()) > 0, "Test failed - expected frames"
505
+ assert decoder.terminate() == 0, "Test failed - expected exit code 0"
506
+
507
+
508
+ def test_metadata_invalid_type_discarded() -> None:
509
+ """
510
+ Assigning an invalid-typed value to a source metadata property must be
511
+ discarded with a warning, not raise `AttributeError`.
512
+ """
513
+ decoder = FFdecoder(
514
+ return_testvideo_path(fmt="vo"), custom_ffmpeg=return_static_ffmpeg()
515
+ ).formulate()
516
+ try:
517
+ original = json.loads(decoder.metadata)["source_video_pixfmt"]
518
+ decoder.metadata = {"source_video_pixfmt": 1234}
519
+ assert json.loads(decoder.metadata)["source_video_pixfmt"] == original, (
520
+ "Test failed - invalid-typed value was not discarded"
521
+ )
522
+ finally:
523
+ decoder.terminate()
524
+
525
+
453
526
  @pytest.mark.parametrize(
454
527
  "custom_params, checks",
455
528
  [
@@ -884,3 +957,140 @@ def test_multi_input_composites(
884
957
  frames = [f for f in decoder.generateFrame() if f is not None]
885
958
  assert frames and frames[0].shape == (*shape, 3)
886
959
  assert abs(len(frames) - n_frames) <= 1
960
+
961
+
962
+ def _side_path(name: str) -> str:
963
+ return os.path.join(tempfile.gettempdir(), "temp_write", name)
964
+
965
+
966
+ test_data = [
967
+ (
968
+ {"-extra_outputs": ["-y", "-map", "0", "-c", "copy", _side_path("side_copy.mkv")]},
969
+ [_side_path("side_copy.mkv")],
970
+ None,
971
+ ),
972
+ (
973
+ {
974
+ "-filter_complex": "[0:v]split=2[main][rec];[main]scale=320:-2[main]",
975
+ "-map": "[main]",
976
+ "-extra_outputs": [
977
+ "-y",
978
+ "-map",
979
+ "[rec]",
980
+ "-c:v",
981
+ "libx264",
982
+ _side_path("side_split.mp4"),
983
+ ],
984
+ },
985
+ [_side_path("side_split.mp4")],
986
+ 320,
987
+ ),
988
+ (
989
+ {
990
+ "-extra_outputs": [
991
+ "-y",
992
+ "-map",
993
+ "0:v",
994
+ "-c:v",
995
+ "libx264",
996
+ "-flags",
997
+ "+global_header",
998
+ "-f",
999
+ "tee",
1000
+ # tee treats `\` as escape char, so use forward slashes for Windows paths
1001
+ "{}|[f=mpegts]{}".format(
1002
+ _side_path("side_tee.mkv"), _side_path("side_tee.ts")
1003
+ ).replace("\\", "/"),
1004
+ ]
1005
+ },
1006
+ [_side_path("side_tee.mkv"), _side_path("side_tee.ts")],
1007
+ None,
1008
+ ),
1009
+ ]
1010
+
1011
+
1012
+ @pytest.mark.parametrize("ffparams, side_files, width", test_data)
1013
+ def test_extra_outputs(ffparams: dict[str, Any], side_files: list[str], width: int | None) -> None:
1014
+ """
1015
+ Testing `-extra_outputs` writes side outputs from the same pipeline while frames
1016
+ still reach Python intact (incl. `-filter_complex` labels consumed only by side outputs)
1017
+ """
1018
+ decoder = None
1019
+ source = return_testvideo_path(fmt="av")
1020
+ actual_count, actual_shape = actual_frame_count_n_frame_size(source)
1021
+ os.makedirs(_side_path(""), exist_ok=True)
1022
+ try:
1023
+ decoder = FFdecoder(
1024
+ source, frame_format="bgr24", custom_ffmpeg=return_static_ffmpeg(), **ffparams
1025
+ ).formulate()
1026
+ frames = [f for f in decoder.generateFrame() if f is not None]
1027
+ assert decoder.terminate() == 0, "Test failed - FFmpeg exited with an error"
1028
+ assert len(frames) == actual_count, f"Test failed - got {len(frames)} frames"
1029
+ # width proves output metadata came from the probe, not the source fallback
1030
+ assert frames[0].shape[1] == (width or actual_shape[1]), (
1031
+ f"Test failed - unexpected frame shape {frames[0].shape}"
1032
+ )
1033
+ for side in side_files:
1034
+ # count via FFmpeg, since OpenCV on macOS (AVFoundation) can't read `.mkv`; use
1035
+ # default FFmpeg, as the pinned 2022 static build dies silently probing `.ts` on CI
1036
+ side_decoder = FFdecoder(side).formulate()
1037
+ side_count = sum(f is not None for f in side_decoder.generateFrame())
1038
+ side_decoder.terminate()
1039
+ assert side_count > 0, f"Test failed - empty {side}"
1040
+ except Exception as e:
1041
+ pytest.fail(str(e))
1042
+ finally:
1043
+ decoder is not None and decoder.terminate()
1044
+ for side in side_files:
1045
+ remove_file_safe(side)
1046
+
1047
+
1048
+ def test_extra_outputs_early_terminate() -> None:
1049
+ """
1050
+ Terminating mid-stream must still leave a valid (finalized) side `.mp4`
1051
+ """
1052
+ decoder = None
1053
+ side = _side_path("side_early.mp4")
1054
+ os.makedirs(_side_path(""), exist_ok=True)
1055
+ try:
1056
+ decoder = FFdecoder(
1057
+ return_testvideo_path(fmt="av"),
1058
+ frame_format="bgr24",
1059
+ custom_ffmpeg=return_static_ffmpeg(),
1060
+ **{"-extra_outputs": ["-y", "-map", "0:v", "-c:v", "libx264", side]},
1061
+ ).formulate()
1062
+ for num, frame in enumerate(decoder.generateFrame()):
1063
+ if frame is None or num == 9:
1064
+ break
1065
+ decoder.terminate()
1066
+ assert actual_frame_count_n_frame_size(side)[0] >= 10, (
1067
+ "Test failed - side output not finalized"
1068
+ )
1069
+ except Exception as e:
1070
+ pytest.fail(str(e))
1071
+ finally:
1072
+ decoder is not None and decoder.terminate()
1073
+ remove_file_safe(side)
1074
+
1075
+
1076
+ @pytest.mark.parametrize("value", ["foo", [["-map", "0"]], {"a": 1}])
1077
+ def test_extra_outputs_invalid(value: Any) -> None:
1078
+ """
1079
+ Invalid `-extra_outputs` values must be discarded, leaving normal decoding untouched
1080
+ """
1081
+ decoder = None
1082
+ source = return_testvideo_path(fmt="vo")
1083
+ actual_count, actual_shape = actual_frame_count_n_frame_size(source)
1084
+ try:
1085
+ decoder = FFdecoder(
1086
+ source,
1087
+ frame_format="bgr24",
1088
+ custom_ffmpeg=return_static_ffmpeg(),
1089
+ **{"-extra_outputs": value},
1090
+ ).formulate()
1091
+ frames = [f for f in decoder.generateFrame() if f is not None]
1092
+ assert len(frames) == actual_count and frames[0].shape == actual_shape
1093
+ except Exception as e:
1094
+ pytest.fail(str(e))
1095
+ finally:
1096
+ decoder is not None and decoder.terminate()
@@ -159,6 +159,23 @@ def test_check_sp_output_happy_path() -> None:
159
159
  assert b"ffmpeg version" in output, "Expected ffmpeg version banner in stdout"
160
160
 
161
161
 
162
+ def test_check_sp_output_stdin_devnull(monkeypatch: pytest.MonkeyPatch) -> None:
163
+ """
164
+ Testing check_sp_output defaults `stdin` to DEVNULL, while honoring caller override.
165
+ """
166
+ seen = []
167
+ real_popen = ffhelper.sp.Popen
168
+
169
+ def spy(*args, **kwargs):
170
+ seen.append(kwargs.get("stdin"))
171
+ return real_popen(*args, **kwargs)
172
+
173
+ monkeypatch.setattr(ffhelper.sp, "Popen", spy)
174
+ check_sp_output([return_static_ffmpeg(), "-version"], timeout=5.0)
175
+ check_sp_output([return_static_ffmpeg(), "-version"], stdin=None, timeout=5.0)
176
+ assert seen == [ffhelper.sp.DEVNULL, None], f"Unexpected stdin values: {seen}"
177
+
178
+
162
179
  def test_check_sp_output_force_retrieve_stderr_bypass() -> None:
163
180
  """
164
181
  Testing that `force_retrieve_stderr=True` suppresses CalledProcessError on
@@ -1 +0,0 @@
1
- __version__: str = "0.2.8"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes