From 4791f225b9652c41fa7518c63ed2ca0db585929b Mon Sep 17 00:00:00 2001 From: Abhishek Thakur Date: Tue, 9 Jul 2024 00:04:55 +0530 Subject: [PATCH 01/57] =?UTF-8?q?=F0=9F=94=96=20Maintenance:=20Bumped=20ve?= =?UTF-8?q?rsion=20to=20`0.2.7`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deffcode/version.py b/deffcode/version.py index 01ef1207..6cd38b74 100644 --- a/deffcode/version.py +++ b/deffcode/version.py @@ -1 +1 @@ -__version__ = "0.2.6" +__version__ = "0.2.7" From f70ad84fdc095fc7b50d150c76884fb3054aaa7c Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 13:04:16 +0530 Subject: [PATCH 02/57] =?UTF-8?q?=F0=9F=93=9D=20Docs:=20expand=20CPU=20war?= =?UTF-8?q?ning=20and=20fix=20WriteGear=20output=20param=20in=20transcode?= =?UTF-8?q?=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/recipes/basic/transcode-live-frames.md | 38 ++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/recipes/basic/transcode-live-frames.md b/docs/recipes/basic/transcode-live-frames.md index 14d61f22..caedac01 100644 --- a/docs/recipes/basic/transcode-live-frames.md +++ b/docs/recipes/basic/transcode-live-frames.md @@ -288,7 +288,35 @@ In this example we will decode different pixel formats video frames from a given ## Transcoding lossless video using WriteGear API -!!! danger "==WriteGear's Compression Mode support for FFdecoder API is currently in beta so you can expect much higher than usual CPU utilization!==" +!!! danger "High CPU Usage when chaining FFdecoder with WriteGear" + + When chaining FFdecoder with WriteGear, both FFmpeg processes _(decoding + encoding)_ run **as fast as your hardware allows** with no artificial pacing between them. This causes the pipeline to max out your CPU to process the video in the shortest time possible, which may be undesirable. + + You can mitigate this in two ways depending on your use case: + + === "Throttle to Real-Time Speed" + + Pass the `-re` flag via FFdecoder's `-ffprefixes` parameter to force FFmpeg to read the input at its native framerate. This naturally paces the pipeline to real-time speed and **drastically reduces CPU usage**: + + ```python + # force input to be read at native framerate + decoder = FFdecoder("foo.mp4", frame_format="bgr24", **{"-ffprefixes": ["-re"]}).formulate() + ``` + + === "Limit FFmpeg Threads" + + Pass `-threads` to both FFdecoder and WriteGear to cap the number of CPU threads each FFmpeg process may use. This leaves headroom for other system tasks: + + ```python + # limit decoder to 2 threads + decoder = FFdecoder("foo.mp4", frame_format="bgr24", **{"-threads": 2}).formulate() + + # limit encoder to 2 threads + writer = WriteGear(output="output_foo.mp4", **{"-input_framerate": fps, "-threads": 2}) + ``` + + !!! tip "Hardware Acceleration" + If your machine has a dedicated GPU, you can offload encoding to the GPU entirely — for example by passing `"-vcodec": "h264_nvenc"` to WriteGear _(NVIDIA)_ — shifting the heavy lifting off the CPU. ???+ quote "Lossless transcoding with FFdecoder and WriteGear API" @@ -325,7 +353,7 @@ In this example we will decode different pixel formats video frames from a given # Define writer with default parameters and suitable # output filename for e.g. `output_foo.mp4` - writer = WriteGear(output_filename="output_foo.mp4", **output_params) + writer = WriteGear(output="output_foo.mp4", **output_params) # grab the BGR24 frame from the decoder for frame in decoder.generateFrame(): @@ -367,7 +395,7 @@ In this example we will decode different pixel formats video frames from a given # Define writer with default parameters and suitable # output filename for e.g. `output_foo.mp4` - writer = WriteGear(output_filename="output_foo.mp4", **output_params) + writer = WriteGear(output="output_foo.mp4", **output_params) # grab the BGR24 frame from the decoder for frame in decoder.generateFrame(): @@ -409,7 +437,7 @@ In this example we will decode different pixel formats video frames from a given # Define writer with default parameters and suitable # output filename for e.g. `output_foo_gray.mp4` - writer = WriteGear(output_filename="output_foo_gray.mp4", **output_params) + writer = WriteGear(output="output_foo_gray.mp4", **output_params) # grab the GRAYSCALE frame from the decoder for frame in decoder.generateFrame(): @@ -457,7 +485,7 @@ In this example we will decode different pixel formats video frames from a given # Define writer with default parameters and suitable # output filename for e.g. `output_foo_yuv.mp4` - writer = WriteGear(output_filename="output_foo_yuv.mp4", logging=True, **output_params) + writer = WriteGear(output="output_foo_yuv.mp4", logging=True, **output_params) # grab the YUV420 frame from the decoder for frame in decoder.generateFrame(): From f74b8c06d52a921e64861dbf7949e53f46d51e25 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 14:44:52 +0530 Subject: [PATCH 03/57] =?UTF-8?q?=F0=9F=93=9D=20Docs:=20improve=20clarity?= =?UTF-8?q?=20and=20formatting=20in=20transcode-live-frames=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/recipes/basic/transcode-live-frames.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/recipes/basic/transcode-live-frames.md b/docs/recipes/basic/transcode-live-frames.md index caedac01..f57c5320 100644 --- a/docs/recipes/basic/transcode-live-frames.md +++ b/docs/recipes/basic/transcode-live-frames.md @@ -79,11 +79,11 @@ We'll discuss transcoding using both these libraries briefly in the following re   -## Transcoding video using OpenCV VideoWriter API +## Transcoding Video using OpenCV VideoWriter API -!!! quote "OpenCV's' [`VideoWriter()`](https://docs.opencv.org/3.4/dd/d9e/classcv_1_1VideoWriter.html#ad59c61d8881ba2b2da22cff5487465b5) class can be used directly with DeFFcode's FFdecoder API to encode video frames into a multimedia video file but it lacks the ability to control output quality, bitrate, compression, and other important features which are only available with VidGear's WriteGear API." +OpenCV's [`VideoWriter()`](https://docs.opencv.org/3.4/dd/d9e/classcv_1_1VideoWriter.html#ad59c61d8881ba2b2da22cff5487465b5) class can be used directly with DeFFcode's FFdecoder API to encode video frames into a multimedia file. However, it lacks fine-grained control over output quality, bitrate, compression, and other advanced parameters—features that are readily available with VidGear's WriteGear API. -In this example we will decode different pixel formats video frames from a given Video file _(say `foo.mp4`)_ in FFdecoder API, and encode them using OpenCV Library's `VideoWriter()` method in real-time. +In this example, we will decode video frames with different pixel formats from a given video file *(e.g., `foo.mp4`)* using the FFdecoder API, and then encode them in real time using OpenCV's `VideoWriter()` method.. !!! info "OpenCV's `VideoWriter()` class requires a valid Output filename _(e.g. output_foo.avi)_, [FourCC](https://www.fourcc.org/fourcc.php) code, framerate, and resolution as input." @@ -317,14 +317,12 @@ In this example we will decode different pixel formats video frames from a given !!! tip "Hardware Acceleration" If your machine has a dedicated GPU, you can offload encoding to the GPU entirely — for example by passing `"-vcodec": "h264_nvenc"` to WriteGear _(NVIDIA)_ — shifting the heavy lifting off the CPU. - -???+ quote "Lossless transcoding with FFdecoder and WriteGear API" - VidGear's [**WriteGear API**](https://abhitronix.github.io/vidgear/latest/gears/writegear/introduction/) implements a complete, flexible, and robust wrapper around FFmpeg in [compression mode](https://abhitronix.github.io/vidgear/latest/gears/writegear/compression/overview/) for encoding real-time video frames to a lossless compressed multimedia output file(s)/stream(s). +**VidGear's [WriteGear API](https://abhitronix.github.io/vidgear/latest/gears/writegear/introduction/)** provides a flexible and robust wrapper over FFmpeg (compression mode) for encoding real-time video frames into lossless multimedia files or streams. - DeFFcode's FFdecoder API in conjunction with WriteGear API creates a high-level **High-performance Lossless FFmpeg Transcoding _(Decoding + Encoding)_ Pipeline :fire:** that is able to exploit almost any FFmpeg parameter for achieving anything imaginable with multimedia video data all while allow us to manipulate the real-time video frames with immense flexibility. +Combined with **DeFFcode's FFdecoder API**, it enables a high-level **lossless FFmpeg transcoding pipeline (decoding + encoding)** with full control over FFmpeg parameters and real-time frame manipulation. -In this example we will decode different pixel formats video frames from a given Video file _(say `foo.mp4`)_ in FFdecoder API, and encode them into lossless video file with controlled framerate using WriteGear API in real-time. +In this example, we will decode video frames with different pixel formats from a given video file *(e.g., `foo.mp4`)* using the FFdecoder API, and then encode them into a lossless video file with a controlled framerate using the WriteGear API in real time. !!! info "Additional Parameters in WriteGear API" From 8082936d80290c5c263051ae8e4538966643dec8 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 14:45:44 +0530 Subject: [PATCH 04/57] =?UTF-8?q?=F0=9F=91=B7CI:=20upgrade=20actions=20and?= =?UTF-8?q?=20replace=20legacy=20mkdocstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs_deployer.yml | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docs_deployer.yml b/.github/workflows/docs_deployer.yml index cfd896ca..8373c5be 100644 --- a/.github/workflows/docs_deployer.yml +++ b/.github/workflows/docs_deployer.yml @@ -34,10 +34,10 @@ jobs: if: github.event_name == 'release' && github.event.action == 'published' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - name: install_deffcode @@ -48,8 +48,7 @@ jobs: - name: install_docs_deps run: | pip install -U mkdocs mkdocs-material mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin - pip install -U mkdocs-exclude mike mkdocstrings mkdocstrings-python-legacy - pip install jinja2==3.0.* + pip install -U mkdocs-exclude mike mkdocstrings mkdocstrings-python if: success() - name: git configure run: | @@ -82,10 +81,10 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/master' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - name: install_deffcode @@ -96,8 +95,7 @@ jobs: - name: install_docs_deps run: | pip install -U mkdocs mkdocs-material mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin - pip install -U mkdocs-exclude mike mkdocstrings mkdocstrings-python-legacy - pip install jinja2==3.0.* + pip install -U mkdocs-exclude mike mkdocstrings mkdocstrings-python if: success() - name: git configure run: | @@ -131,11 +129,11 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/dev' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 with: fetch-depth: 0 - run: git checkout dev - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} - name: install_deffcode @@ -146,8 +144,7 @@ jobs: - name: install_docs_deps run: | pip install -U mkdocs mkdocs-material mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin - pip install -U mkdocs-exclude mike mkdocstrings mkdocstrings-python-legacy - pip install jinja2==3.0.* + pip install -U mkdocs-exclude mike mkdocstrings mkdocstrings-python if: success() - name: git configure run: | From b2fd7b7b9da6c73333a74c980a9122bd3ef03f28 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 14:46:54 +0530 Subject: [PATCH 05/57] =?UTF-8?q?=F0=9F=90=9B=20Sourcer:=20fix=20param=20n?= =?UTF-8?q?ame=20in=20retrieve=5Fmetadata=20docstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/sourcer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deffcode/sourcer.py b/deffcode/sourcer.py index 32d9d07d..c4ff2527 100644 --- a/deffcode/sourcer.py +++ b/deffcode/sourcer.py @@ -331,7 +331,7 @@ def retrieve_metadata(self, pretty_json=False, force_retrieve_missing=False): Parameters: pretty_json (bool): whether to return metadata as JSON string(if `True`) or Dictionary(if `False`) type? - force_retrieve_output (bool): whether to also return metadata missing in current Pipeline. This method returns `(metadata, metadata_missing)` tuple if `force_retrieve_output=True` instead of `metadata`. + force_retrieve_missing (bool): whether to also return metadata missing in current Pipeline. This method returns `(metadata, metadata_missing)` tuple if `force_retrieve_missing=True` instead of `metadata`. **Returns:** `metadata` or `(metadata, metadata_missing)`, formatted as JSON string or python dictionary. """ From 5fe0243e5e0ab723e578d80d96e6be7b83bd3e90 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 14:47:30 +0530 Subject: [PATCH 06/57] =?UTF-8?q?=F0=9F=90=9B=20Docs:=20fix=20asset=20path?= =?UTF-8?q?s=20and=20typos=20in=20recipe/reference=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/recipes/advanced/decode-live-virtual-sources.md | 12 ++++++------ docs/recipes/advanced/transcode-art-filtergraphs.md | 10 +++++----- docs/recipes/advanced/transcode-hw-acceleration.md | 4 ++-- .../advanced/transcode-live-frames-complexgraphs.md | 4 ++-- .../basic/transcode-live-frames-simplegraphs.md | 12 ++++++------ docs/reference/ffdecoder/params.md | 4 ++-- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/recipes/advanced/decode-live-virtual-sources.md b/docs/recipes/advanced/decode-live-virtual-sources.md index cce37c3d..b2e5d250 100644 --- a/docs/recipes/advanced/decode-live-virtual-sources.md +++ b/docs/recipes/advanced/decode-live-virtual-sources.md @@ -22,7 +22,7 @@ limitations under the License. > Instead of using prerecorded video files as streams, DeFFcode's FFdecoder API with the help of powerful [`lavfi`](http://underpop.online.fr/f/ffmpeg/help/lavfi.htm.gz) _(**Libavfilter** input virtual device)_ source that reads data from the open output pads of a libavfilter filtergraph, is also capable of creating virtual video frames out of thin air in real-time, which you might want to use as input for testing, compositing, and merging with other streams to obtain desired output on-the-fly. -We'll discuss the recipies for generating Live Fake Sources briefly below: +We'll discuss the recipes for generating Live Fake Sources briefly below:   @@ -65,7 +65,7 @@ We'll discuss the recipies for generating Live Fake Sources briefly below: > The [`sierpinski`](https://ffmpeg.org/ffmpeg-filters.html#toc-sierpinski) graph generates a Sierpinski carpet/triangle fractal, and randomly pan around by a single pixel each frame.
- ![sierpinski pattern](../../../assets/gifs/sierpinski.gif){ width="500" } + ![sierpinski pattern](../../assets/gifs/sierpinski.gif){ width="500" }
Sierpinski carpet fractal
@@ -122,7 +122,7 @@ decoder.terminate() > The [`testsrc`](https://ffmpeg.org/ffmpeg-filters.html#toc-allrgb_002c-allyuv_002c-color_002c-colorchart_002c-colorspectrum_002c-haldclutsrc_002c-nullsrc_002c-pal75bars_002c-pal100bars_002c-rgbtestsrc_002c-smptebars_002c-smptehdbars_002c-testsrc_002c-testsrc2_002c-yuvtestsrc) graph generates a test video pattern showing a color pattern, a scrolling gradient, and a timestamp. This is useful for testing purposes.
- ![testsrc pattern](../../../assets/gifs/testsrc.gif){ width="500" } + ![testsrc pattern](../../assets/gifs/testsrc.gif){ width="500" }
Test Source pattern
@@ -181,7 +181,7 @@ decoder.terminate() > The [`gradients`](https://ffmpeg.org/ffmpeg-filters.html#toc-gradients) graph (as name suggests) generates several random gradients.
- ![gradients test pattern](../../../assets/gifs/gradients.gif){ width="500" } + ![gradients test pattern](../../assets/gifs/gradients.gif){ width="500" }
Gradients pattern with real-time text output
@@ -249,7 +249,7 @@ decoder.terminate() > The [`mandelbrot`](https://ffmpeg.org/ffmpeg-filters.html#toc-mandelbrot) graph generate a [**Mandelbrot set fractal**](https://en.wikipedia.org/wiki/Mandelbrot_set), that progressively zoom towards a specfic point.
- ![mandelbrot test pattern](../../../assets/gifs/mandelbrot_vectorscope_waveforms.gif){ width="500" } + ![mandelbrot test pattern](../../assets/gifs/mandelbrot_vectorscope_waveforms.gif){ width="500" }
Mandelbrot pattern with a Vectorscope & two Waveforms
@@ -316,7 +316,7 @@ decoder.terminate() > The [`life`](https://ffmpeg.org/ffmpeg-filters.html#toc-life) graph generates a life pattern based on a generalization of John Conway’s life game. The sourced input represents a life grid, each pixel represents a cell which can be in one of two possible states, alive or dead. Every cell interacts with its eight neighbours, which are the cells that are horizontally, vertically, or diagonally adjacent. At each interaction the grid evolves according to the adopted rule, which specifies the number of neighbor alive cells which will make a cell stay alive or born.
- ![life pattern](../../../assets/gifs/life.gif){ width="500" } + ![life pattern](../../assets/gifs/life.gif){ width="500" }
Game of Life Visualization
diff --git a/docs/recipes/advanced/transcode-art-filtergraphs.md b/docs/recipes/advanced/transcode-art-filtergraphs.md index d7232eae..047e2b6d 100644 --- a/docs/recipes/advanced/transcode-art-filtergraphs.md +++ b/docs/recipes/advanced/transcode-art-filtergraphs.md @@ -28,7 +28,7 @@ limitations under the License. They can be processed by simply inserting an additional step between decoding and encoding of video frames: - ![Simple filtergraphs](../../../assets/images/simplefiltergraphs.png){ loading=lazy } + ![Simple filtergraphs](../../assets/images/simplefiltergraphs.png){ loading=lazy } Simple filtergraphs are configured with the per-stream `-filter` option _(with `-vf` for video)_. @@ -84,7 +84,7 @@ We'll discuss the Transcoding Video Art with Filtergraphs in the following recip > Based on the QCTools bitplane visualization, this video art has numerical values ranging between `-1`(no change) and `10`(noisiest) for the `Y` _(luminance)_, `U` and `V` _(chroma or color difference)_ planes, yielding cool and different results for different values.
- ![Bitplane Visualization](../../../assets/gifs/bitplane_visualization.gif) + ![Bitplane Visualization](../../assets/gifs/bitplane_visualization.gif)
YUV Bitplane Visualization
@@ -155,7 +155,7 @@ writer.close() > This video art uses FFmpeg's [`pseudocolor`](https://ffmpeg.org/ffmpeg-filters.html#toc-pseudocolor) filter to create a **Jetcolor effect** which is high contrast, high brightness, and high saturation colormap that ranges from blue to red, and passes through the colors cyan, yellow, and orange. The jet colormap is associated with an astrophysical fluid jet simulation from the National Center for Supercomputer Applications.
- ![Jetcolor effect](../../../assets/gifs/jetcolor_effect.gif) + ![Jetcolor effect](../../assets/gifs/jetcolor_effect.gif)
Jetcolor effect
@@ -232,7 +232,7 @@ writer.close() > This video art using FFmpeg’s [`lagfun`](https://ffmpeg.org/ffmpeg-filters.html#toc-lagfun) filter to create a video echo/ghost/trailing effect.
- ![Ghosting effect](../../../assets/gifs/ghosting_effect.gif) + ![Ghosting effect](../../assets/gifs/ghosting_effect.gif)
Ghosting effect
@@ -300,7 +300,7 @@ writer.close() > This video art uses FFmpeg’s `overlay`, `smartblur` and stacks of `dilation` filters to intentionally Pixelate your video in artistically cool looking ways such that each pixel become visible to the naked eye.
- ![Pixelation effect](../../../assets/gifs/pixelation_effect.gif) + ![Pixelation effect](../../assets/gifs/pixelation_effect.gif)
Pixelation effect
diff --git a/docs/recipes/advanced/transcode-hw-acceleration.md b/docs/recipes/advanced/transcode-hw-acceleration.md index 2b42edbb..30069d9c 100644 --- a/docs/recipes/advanced/transcode-hw-acceleration.md +++ b/docs/recipes/advanced/transcode-hw-acceleration.md @@ -36,14 +36,14 @@ limitations under the License. As we know, using the `–hwaccel cuda -hwaccel_output_format cuda` flags in FFmpeg pipeline will keep video frames in GPU memory, and this ensures that the memory transfers (system memory to video memory and vice versa) are eliminated, and that transcoding is performed with the highest possible performance on the available GPU hardware.
- ![HW Acceleration](../../../assets/images/hw_accel.png){ width="350" } + ![HW Acceleration](../../assets/images/hw_accel.png){ width="350" }
General Memory Flow with Hardware Acceleration
But unfortunately, for processing real-time frames in our python script with FFdecoder and WriteGear APIs, we're bound to sacrifice this performance gain by explicitly copying raw decoded frames between System and GPU memory _(via the PCIe bus)_, thereby creating self-made latency in transfer time and increasing PCIe bandwidth occupancy due to overheads in communication over the bus. Moreover, given PCIe bandwidth limits, copying uncompressed image data would quickly saturate the PCIe bus.
- ![HW Acceleration Limitation](../../../assets/images/hw_accel_limitation.png){ width="350" } + ![HW Acceleration Limitation](../../assets/images/hw_accel_limitation.png){ width="350" }
Memory Flow with Hardware Acceleration
and Real-time Processing
diff --git a/docs/recipes/advanced/transcode-live-frames-complexgraphs.md b/docs/recipes/advanced/transcode-live-frames-complexgraphs.md index c09b79a9..bef1085d 100644 --- a/docs/recipes/advanced/transcode-live-frames-complexgraphs.md +++ b/docs/recipes/advanced/transcode-live-frames-complexgraphs.md @@ -80,7 +80,7 @@ We'll discuss the transcoding of live complex filtergraphs in the following reci ## Transcoding video with Live Custom watermark image overlay
- ![Big Buck Bunny with watermark](../../../assets/gifs/watermark_overlay.gif) + ![Big Buck Bunny with watermark](../../assets/gifs/watermark_overlay.gif)
Big Buck Bunny with custom watermark
@@ -153,7 +153,7 @@ writer.close() ## Transcoding video from sequence of Images with additional filtering
- ![mandelbrot test pattern](../../../assets/gifs/fish_mandelbrot.gif) + ![mandelbrot test pattern](../../assets/gifs/fish_mandelbrot.gif)
Mandelbrot pattern blend with Fish school video
diff --git a/docs/recipes/basic/transcode-live-frames-simplegraphs.md b/docs/recipes/basic/transcode-live-frames-simplegraphs.md index 1f3885bb..231e9fba 100644 --- a/docs/recipes/basic/transcode-live-frames-simplegraphs.md +++ b/docs/recipes/basic/transcode-live-frames-simplegraphs.md @@ -28,7 +28,7 @@ limitations under the License. They can be processed by simply inserting an additional step between decoding and encoding of video frames: - ![Simple filtergraphs](../../../assets/images/simplefiltergraphs.png){ loading=lazy } + ![Simple filtergraphs](../../assets/images/simplefiltergraphs.png){ loading=lazy } Simple filtergraphs are configured with the per-stream `-filter` option _(with `-vf` for video)_. @@ -74,7 +74,7 @@ We'll discuss the transcoding of live simple filtergraphs in the following recip ## Transcoding Trimmed and Reversed video
- ![Big Buck Bunny Reversed](../../../assets/gifs/bigbuckbunny_reversed.gif) + ![Big Buck Bunny Reversed](../../assets/gifs/bigbuckbunny_reversed.gif)
Big Buck Bunny Reversed
@@ -140,7 +140,7 @@ writer.release() ## Transcoding Cropped video
- ![Big Buck Bunny Cropped](../../../assets/gifs/bigbuckbunny_cropped.gif) + ![Big Buck Bunny Cropped](../../assets/gifs/bigbuckbunny_cropped.gif)
Big Buck Bunny Cropped
@@ -206,7 +206,7 @@ writer.release() !!! quote "FFmpeg features **Rotate** Filter that is used to rotate videos by an arbitrary angle (expressed in radians)."
- ![Big Buck Bunny Rotated](../../../assets/gifs/bigbuckbunny_rotate.gif) + ![Big Buck Bunny Rotated](../../assets/gifs/bigbuckbunny_rotate.gif)
Big Buck Bunny Rotated (with rotate filter)
@@ -270,7 +270,7 @@ writer.release() !!! quote "FFmpeg also features **Transpose** Filter that is used to rotate videos by 90 degrees clockwise and counter-clockwise direction as well as flip them vertically and horizontally."
- ![Big Buck Bunny Rotated](../../../assets/gifs/bigbuckbunny_transpose.gif) + ![Big Buck Bunny Rotated](../../assets/gifs/bigbuckbunny_transpose.gif)
Big Buck Bunny Rotated (with transpose filter)
@@ -331,7 +331,7 @@ writer.release() ## Transcoding Horizontally flipped and Scaled video
- ![Big Buck Bunny Horizontally flipped and Scaled](../../../assets/gifs/bigbuckbunny_hflip_scaled.gif) + ![Big Buck Bunny Horizontally flipped and Scaled](../../assets/gifs/bigbuckbunny_hflip_scaled.gif)
Big Buck Bunny Horizontally flipped and Scaled
diff --git a/docs/reference/ffdecoder/params.md b/docs/reference/ffdecoder/params.md index d525a2eb..effb4496 100644 --- a/docs/reference/ffdecoder/params.md +++ b/docs/reference/ffdecoder/params.md @@ -527,7 +527,7 @@ This parameter can be used to manually assigns the system _file-path/directory_ ??? question "How to change FFmpeg Static Binaries download directory?" - You can use `-ffmpeg_download_path` _(via. [`-custom_sourcer_params`](#exclusive-parameters))_ exclusive parameter in FFdecoder API to set the custom directory for downloading FFmpeg Static Binaries during the [Auto-Installation](../../../installation/ffmpeg_install/#a-auto-installation) step on Windows Machines. If this parameter is not altered, then these binaries will auto-save to the default temporary directory (for e.g. `C:/User/temp`) on your windows machine. It can be used as follows in FFdecoder API: + You can use `-ffmpeg_download_path` _(via. [`-custom_sourcer_params`](#b-exclusive-parameters))_ exclusive parameter in FFdecoder API to set the custom directory for downloading FFmpeg Static Binaries during the [Auto-Installation](../../../installation/ffmpeg_install/#a-auto-installation) step on Windows Machines. If this parameter is not altered, then these binaries will auto-save to the default temporary directory (for e.g. `C:/User/temp`) on your windows machine. It can be used as follows in FFdecoder API: ```python # # define suitable parameter to download at "C:/User/foo/foo1" @@ -698,7 +698,7 @@ These parameters are discussed below:   -* **`-custom_sourcer_params`** _(dict)_ : This attribute assigns all [**Exclusive Parameter**](../../sourcer/params/#exclusive-parameters) meant for Sourcer API's `sourcer_params` dictionary parameter directly through FFdecoder API. Its usage is as follows: +* **`-custom_sourcer_params`** _(dict)_ : This attribute assigns all [**Exclusive Parameter**](../../sourcer/params/#b-exclusive-parameters) meant for Sourcer API's `sourcer_params` dictionary parameter directly through FFdecoder API. Its usage is as follows: ```python # define suitable parameter meant for `sourcer_params` From a0e59db2b2f94f8cea17ca4017c9b3a430d5c717 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 14:48:21 +0530 Subject: [PATCH 07/57] =?UTF-8?q?=F0=9F=93=9D=20Docs:=20update=20mkdocs=20?= =?UTF-8?q?config=20with=20nav=20fixes=20and=20plugin=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable navigation.prune for better sidebar performance - Add creation date and fallback to git-revision-date plugin - Configure mkdocstrings filters and fix options nesting - Add mermaid diagram support via superfences custom fences - Enable generic arithmatex and magiclink shorthand options - Replace exclude plugin with exclude_docs directive - Fix typo: "Recipies" -> "Recipes" in nav section --- mkdocs.yml | 43 ++++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 92f1bf21..b53db3ba 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,6 +36,7 @@ theme: language: en features: - announce.dismiss + - navigation.prune - navigation.tabs - navigation.tabs.sticky - navigation.indexes @@ -83,20 +84,24 @@ theme: # Plugins plugins: - search - - git-revision-date-localized + - git-revision-date-localized: + enable_creation_date: true + fallback_to_build_date: true - minify: minify_html: true - mkdocstrings: handlers: python: options: - show_root_heading: false - show_root_toc_entry: false - show_source: true - heading_level: 3 - - exclude: - glob: - - overrides/assets/README.md + filters: + - "!^_" + - "^__init__$" + - "^__call__$" + extra: + show_root_heading: false + show_root_toc_entry: false + show_source: true + heading_level: 3 # Customization extra: @@ -139,7 +144,8 @@ markdown_extensions: permalink_title: Anchor link to this section for reference - codehilite: guess_lang: false - - pymdownx.arithmatex + - pymdownx.arithmatex: + generic: true - pymdownx.betterem: smart_enable: all - pymdownx.caret @@ -163,7 +169,11 @@ markdown_extensions: - pymdownx.smartsymbols - pymdownx.snippets: check_paths: true - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true - pymdownx.tasklist: @@ -171,7 +181,14 @@ markdown_extensions: - pymdownx.tilde - pymdownx.striphtml: strip_comments: true - - pymdownx.magiclink + - pymdownx.magiclink: + normalize_issue_symbols: true + repo_url_shorthand: true + user: abhiTronix + repo: deffcode + +exclude_docs: | + overrides/assets/README.md # Page tree nav: @@ -187,7 +204,7 @@ nav: - Pull Request(PR) Guidelines: contribution/PR.md - Changelog: changelog.md - License: license.md - - Recipies: + - Recipes: - Basic Recipes: - Overview: recipes/basic/index.md - Decoding Video Files: recipes/basic/decode-video-files.md @@ -198,7 +215,7 @@ nav: - Transcoding Live Simple Filtergraphs: recipes/basic/transcode-live-frames-simplegraphs.md - Saving Key-frames as Image: recipes/basic/save-keyframe-image.md - Extracting video metadata: recipes/basic/extract-video-metadata.md - - Advanced Recipies: + - Advanced Recipes: - Overview: recipes/advanced/index.md - Decoding Live Virtual Sources: recipes/advanced/decode-live-virtual-sources.md - Decoding Live Feed Devices: recipes/advanced/decode-live-feed-devices.md From 3725e7711347a861e2cb5010178214438f2f595a Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 16:35:03 +0530 Subject: [PATCH 08/57] =?UTF-8?q?=F0=9F=91=B7=20CI:=20bump=20Python=20vers?= =?UTF-8?q?ion=20to=203.11=20in=20docs=20deployer=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docs_deployer.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs_deployer.yml b/.github/workflows/docs_deployer.yml index 8373c5be..6ba65570 100644 --- a/.github/workflows/docs_deployer.yml +++ b/.github/workflows/docs_deployer.yml @@ -23,7 +23,7 @@ on: types: [published] env: - PYTHON_VERSION: 3.9 + PYTHON_VERSION: 3.11 GIT_TOKEN: ${{ secrets.GIT_TOKEN }} GIT_NAME: ${{ secrets.GIT_NAME }} GIT_EMAIL: ${{ secrets.GIT_EMAIL }} From b8b070f4a4fcc6da20a96f8052ab1415828477e3 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 16:35:40 +0530 Subject: [PATCH 09/57] =?UTF-8?q?=F0=9F=91=B7=20CI:=20update=20AppVeyor=20?= =?UTF-8?q?Python=20matrix=20to=203.10-3.13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appveyor.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 2e7f9817..153b0820 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -18,14 +18,6 @@ image: Visual Studio 2019 environment: matrix: - - PYTHON: "C:\\Python38-x64" - PYTHON_VERSION: "3.8.x" - PYTHON_ARCH: "64" - - - PYTHON: "C:\\Python39-x64" - PYTHON_VERSION: "3.9.x" - PYTHON_ARCH: "64" - - PYTHON: "C:\\Python310-x64" PYTHON_VERSION: "3.10.x" PYTHON_ARCH: "64" @@ -34,7 +26,15 @@ environment: PYTHON_VERSION: "3.11.x" PYTHON_ARCH: "64" -build: off + - PYTHON: "C:\\Python312-x64" + PYTHON_VERSION: "3.12.x" + PYTHON_ARCH: "64" + + - PYTHON: "C:\\Python313-x64" + PYTHON_VERSION: "3.13.x" + PYTHON_ARCH: "64" + +build: false version: '{branch}-{build}' @@ -71,4 +71,4 @@ test_script: - cmd: python -m pytest --verbose --capture=no --cov-report term-missing --cov=deffcode tests/ after_test: - - cmd: python -m codecov \ No newline at end of file + - cmd: python -m codecov \ No newline at end of file From 3e46c8edf24a4c98b67410fe16d50943e3b778b7 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 16:36:35 +0530 Subject: [PATCH 10/57] =?UTF-8?q?=F0=9F=91=B7=20CI=20update=20Python=20ver?= =?UTF-8?q?sions=20and=20Codecov=20uploader=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop Python 3.8/3.9, add 3.12/3.13 to CI matrix - Update Codecov download URLs from uploader.codecov.io to cli.codecov.io - Switch to new Codecov CLI upload-process command with --fail-on-error flag --- azure-pipelines.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index dc335ed4..a44d76a1 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -37,14 +37,14 @@ pool: strategy: matrix: - Python38: - python.version: "3.8" - Python39: - python.version: "3.9" Python310: python.version: "3.10" Python311: python.version: "3.11" + Python312: + python.version: "3.12" + Python313: + python.version: "3.13" steps: - task: UsePythonVersion@0 @@ -76,18 +76,18 @@ steps: - script: | timeout 1500 pytest -sv --cov=deffcode --cov-report=xml --cov-report=html --cov-report term-missing tests/ || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; else echo "##vso[task.setvariable variable=exit_code]$code"; fi displayName: 'pytest' - + - bash: | echo "Exit Code was: $(exit_code)" curl https://keybase.io/codecovsecurity/pgp_keys.asc | gpg --no-default-keyring --keyring trustedkeys.gpg --import - curl -Os https://uploader.codecov.io/latest/macos/codecov - curl -Os https://uploader.codecov.io/latest/macos/codecov.SHA256SUM - curl -Os https://uploader.codecov.io/latest/macos/codecov.SHA256SUM.sig + curl -Os https://cli.codecov.io/latest/macos/codecov + curl -Os https://cli.codecov.io/latest/macos/codecov.SHA256SUM + curl -Os https://cli.codecov.io/latest/macos/codecov.SHA256SUM.sig gpgv codecov.SHA256SUM.sig codecov.SHA256SUM shasum -a 256 -c codecov.SHA256SUM chmod +x codecov if [ "$(exit_code)" != "124" ]; then - ./codecov -t $CODECOV_TOKEN -f coverage.xml -C $(Build.SourceVersion) -B $(Build.SourceBranch) -b $(Build.BuildNumber); + ./codecov --verbose upload-process --fail-on-error -t $CODECOV_TOKEN -f coverage.xml -C $(Build.SourceVersion) -B $(Build.SourceBranch) -b $(Build.BuildNumber); else echo "Timeout test - Skipped Codecov!"; fi From ed56434bfa6debf98d7e04ec0a5bd16c7df6f50a Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 16:37:15 +0530 Subject: [PATCH 11/57] =?UTF-8?q?=F0=9F=91=B7=20CI:=20upgrade=20Linux=20CI?= =?UTF-8?q?=20runner=20and=20action=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump ubuntu-20.04 to ubuntu-22.04 - Update Python matrix to 3.10–3.13 - Upgrade checkout, setup-python, and codecov actions --- .github/workflows/CIlinux.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CIlinux.yml b/.github/workflows/CIlinux.yml index 6e897d3a..50cd4666 100644 --- a/.github/workflows/CIlinux.yml +++ b/.github/workflows/CIlinux.yml @@ -39,13 +39,13 @@ on: jobs: test: name: CI Linux - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install APT Dependencies @@ -75,7 +75,7 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics if: success() - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: name: ${{ matrix.python-version }} token: ${{ secrets.CODECOV_TOKEN }} From 62339054176971fba6723453f0069a42db869a4d Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 17:43:18 +0530 Subject: [PATCH 12/57] =?UTF-8?q?=F0=9F=A7=AA=20Testing:=20improve=20frame?= =?UTF-8?q?=20count=20assertion=20error=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 27164bb7..33ac1903 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -104,7 +104,7 @@ def test_source_playback(source, custom_ffmpeg, output): # increment number of frames frame_num += 1 - assert frame_num >= actual_frame_num, "Test failed" + assert frame_num >= actual_frame_num, f"Test failed - Total Frames: {frame_num} vs Actual Frames: {actual_frame_num}" except Exception as e: if not output: logger.exception(str(e)) From 709ac069225f3dd257f49f8bcd20d255d4372f88 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 18:04:20 +0530 Subject: [PATCH 13/57] =?UTF-8?q?=F0=9F=91=B7=20CI:=20update=20Linux=20CI?= =?UTF-8?q?=20runner=20to=20ubuntu-latest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CIlinux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CIlinux.yml b/.github/workflows/CIlinux.yml index 50cd4666..da897b4a 100644 --- a/.github/workflows/CIlinux.yml +++ b/.github/workflows/CIlinux.yml @@ -39,7 +39,7 @@ on: jobs: test: name: CI Linux - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] From 8dd20168f6201e32ffadde50f8c2221399992259 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 18:07:50 +0530 Subject: [PATCH 14/57] =?UTF-8?q?=F0=9F=91=B7=20CI:=20remove=20wheel=20fro?= =?UTF-8?q?m=20CI=20pip=20install=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CIlinux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CIlinux.yml b/.github/workflows/CIlinux.yml index da897b4a..e6b3ef2f 100644 --- a/.github/workflows/CIlinux.yml +++ b/.github/workflows/CIlinux.yml @@ -60,7 +60,7 @@ jobs: sudo chmod +x scripts/bash/prepare_dataset.sh - name: Install Pip Dependencies run: | - sudo pip install -U pip wheel numpy + sudo pip install -U pip numpy sudo pip install -U . sudo pip install -U opencv-python-headless sudo pip install -U vidgear[core] From 7ad7ca210b5a8f5faa93b934188e1f744ed99f28 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 18:10:20 +0530 Subject: [PATCH 15/57] =?UTF-8?q?=F0=9F=91=B7=20CI:=20remove=20pip=20from?= =?UTF-8?q?=20CI=20pip=20install=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CIlinux.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CIlinux.yml b/.github/workflows/CIlinux.yml index e6b3ef2f..67436d0a 100644 --- a/.github/workflows/CIlinux.yml +++ b/.github/workflows/CIlinux.yml @@ -60,7 +60,7 @@ jobs: sudo chmod +x scripts/bash/prepare_dataset.sh - name: Install Pip Dependencies run: | - sudo pip install -U pip numpy + sudo pip install -U numpy sudo pip install -U . sudo pip install -U opencv-python-headless sudo pip install -U vidgear[core] From 8797e1d234062f22ac5bca7e53e6a4b8ef2080dd Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 18:20:09 +0530 Subject: [PATCH 16/57] =?UTF-8?q?=F0=9F=94=8ACI:=20improve=20debug=20info?= =?UTF-8?q?=20in=20ffdecoder=20shape=20mismatch=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 33ac1903..34c112ec 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -95,12 +95,13 @@ def test_source_playback(source, custom_ffmpeg, output): # gather data actual_frame_num, actual_frame_shape = actual_frame_count_n_frame_size(source) + logger.info(f"Actual Frames Number: {actual_frame_num} and Actual Frame Shape: {actual_frame_shape}") # grab RGB24(default) 3D frames from decoder for frame in decoder.generateFrame(): # check shape if frame.shape != actual_frame_shape: - raise RuntimeError("Test failed") + raise RuntimeError(f"Test failed - Frame Shape: {frame.shape} vs Actual Frame Shape: {actual_frame_shape}") # increment number of frames frame_num += 1 From fb5bbd91005370c770f47357c64cf3b5a32fb21c Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 18:59:26 +0530 Subject: [PATCH 17/57] =?UTF-8?q?=E2=9C=85=20Test:=20update=20remote=20vid?= =?UTF-8?q?eo=20URL=20to=20sample=5F480p.avi=20in=20ffdecoder=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 34c112ec..e275c8ae 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -51,7 +51,7 @@ [ (return_testvideo_path(fmt="av"), return_static_ffmpeg(), True), ( - "https://gitlab.com/abhiTronix/Imbakup/-/raw/master/Images/starship.mkv", + "https://gitlab.com/abhiTronix/Imbakup/-/raw/master/Images/sample_480p.avi", "", True, ), From bd17c7b796292e950affcb155240be1562a3996a Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 19:59:24 +0530 Subject: [PATCH 18/57] =?UTF-8?q?=E2=9C=85=20Test:=20swap=20test=20params?= =?UTF-8?q?=20order=20and=20remove=20trailing=20comma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index e275c8ae..0894124f 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -45,23 +45,22 @@ logger.addHandler(logger_handler()) logger.setLevel(logging.DEBUG) - @pytest.mark.parametrize( "source, custom_ffmpeg, output", [ (return_testvideo_path(fmt="av"), return_static_ffmpeg(), True), ( - "https://gitlab.com/abhiTronix/Imbakup/-/raw/master/Images/sample_480p.avi", + return_generated_frames_path(return_static_ffmpeg()), "", True, ), ("unknown://invalid.com/", "", False), (return_testvideo_path(fmt="ao"), return_static_ffmpeg(), False), ( - return_generated_frames_path(return_static_ffmpeg()), + "https://gitlab.com/abhiTronix/Imbakup/-/raw/master/Images/sample_480p.avi", return_static_ffmpeg(), True, - ), + ) ], ) def test_source_playback(source, custom_ffmpeg, output): From 112d7b439fe4b254eb47bebaf3a26e7a83323620 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 20:06:43 +0530 Subject: [PATCH 19/57] =?UTF-8?q?=E2=9C=85=20Test:=20reorder=20source=5Fpl?= =?UTF-8?q?ayback=20parametrize=20cases=20and=20fix=20ffmpeg=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 0894124f..6ff3631a 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -50,17 +50,17 @@ [ (return_testvideo_path(fmt="av"), return_static_ffmpeg(), True), ( - return_generated_frames_path(return_static_ffmpeg()), - "", + "https://gitlab.com/abhiTronix/Imbakup/-/raw/master/Images/sample_480p.avi", + return_static_ffmpeg(), True, ), ("unknown://invalid.com/", "", False), - (return_testvideo_path(fmt="ao"), return_static_ffmpeg(), False), + (return_testvideo_path(fmt="ao"), "", False), ( - "https://gitlab.com/abhiTronix/Imbakup/-/raw/master/Images/sample_480p.avi", + return_generated_frames_path(return_static_ffmpeg()), return_static_ffmpeg(), True, - ) + ), ], ) def test_source_playback(source, custom_ffmpeg, output): From 03b3ededba3d687abc2038a99e2eb5b651ab26de Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 20:16:06 +0530 Subject: [PATCH 20/57] =?UTF-8?q?=E2=9C=85=20Test:=20update=20remote=20vid?= =?UTF-8?q?eo=20URL=20in=20ffdecoder=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 6ff3631a..296c0af2 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -50,7 +50,7 @@ [ (return_testvideo_path(fmt="av"), return_static_ffmpeg(), True), ( - "https://gitlab.com/abhiTronix/Imbakup/-/raw/master/Images/sample_480p.avi", + "https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4", return_static_ffmpeg(), True, ), From 300e83ed8d46d622da64314f32a98a3d7b041b54 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 20:32:57 +0530 Subject: [PATCH 21/57] =?UTF-8?q?=E2=9C=85=20Test:=20fix=20ffmpeg=20path?= =?UTF-8?q?=20params=20in=20ffdecoder=20parametrize=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 296c0af2..07c72c48 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -48,14 +48,14 @@ @pytest.mark.parametrize( "source, custom_ffmpeg, output", [ - (return_testvideo_path(fmt="av"), return_static_ffmpeg(), True), + (return_testvideo_path(fmt="av"), "", True), ( "https://abhitronix.github.io/html/Big_Buck_Bunny_1080_10s_1MB.mp4", - return_static_ffmpeg(), + "", True, ), ("unknown://invalid.com/", "", False), - (return_testvideo_path(fmt="ao"), "", False), + (return_testvideo_path(fmt="ao"), return_static_ffmpeg(), False), ( return_generated_frames_path(return_static_ffmpeg()), return_static_ffmpeg(), From c8e86f17a1517e70ec92f54e6a93356bf363d8c4 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 20:43:34 +0530 Subject: [PATCH 22/57] =?UTF-8?q?=F0=9F=91=B7=20CI:=20update=20pip=20depen?= =?UTF-8?q?dencies=20in=20azure-pipelines.yml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- azure-pipelines.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index a44d76a1..76204243 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -68,8 +68,8 @@ steps: displayName: 'Prepare dataset' - script: | - python -m pip install --upgrade pip wheel numpy - pip install --upgrade vidgear[core] opencv-python-headless codecov pytest pytest-cov pytest-azurepipelines + python -m pip install --upgrade pip wheel numpy cython opencv-python + pip install --upgrade vidgear[core] codecov pytest pytest-cov pytest-azurepipelines pip install --upgrade . displayName: 'Install pip dependencies' From c90db3c361a1a0c798446aa7e1f2950a20d2734d Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 20:53:58 +0530 Subject: [PATCH 23/57] =?UTF-8?q?=E2=9C=85=20Test:=20handle=20None=20frame?= =?UTF-8?q?=20shape=20in=20source=20playback=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 07c72c48..98898c63 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -96,6 +96,9 @@ def test_source_playback(source, custom_ffmpeg, output): actual_frame_num, actual_frame_shape = actual_frame_count_n_frame_size(source) logger.info(f"Actual Frames Number: {actual_frame_num} and Actual Frame Shape: {actual_frame_shape}") + # Update output if the actual_frame_count_n_frame_size fails to decode stream + output = output and (actual_frame_shape is not None) + # grab RGB24(default) 3D frames from decoder for frame in decoder.generateFrame(): # check shape From f3faa712c4fdaa2ea0502be6dd833a18e5b792fa Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 23:36:28 +0530 Subject: [PATCH 24/57] =?UTF-8?q?=F0=9F=A7=B1=20chore:=20migrate=20package?= =?UTF-8?q?=20config=20from=20setup.py=20to=20pyproject.toml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move metadata, dependencies, classifiers, and project URLs to pyproject.toml; update minimum Python version to 3.10 and add 3.12/3.13 classifier support. --- pyproject.toml | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 60 -------------------------------------------------- 2 files changed, 59 insertions(+), 60 deletions(-) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..02570911 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "deffcode" +description = "A cross-platform High-performance & Flexible Real-time Video Frames Decoder in Python." +authors = [{name = "Abhishek Thakur", email = "abhi.una12@gmail.com"}] +license = {text = "Apache License 2.0"} +requires-python = ">=3.10" +keywords = [ + "FFmpeg", + "Decoder", + "Realtime", + "Framework", + "Cross-platform", + "Video Processing", + "Computer Vision", + "Video Decoding", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Operating System :: POSIX", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Topic :: Multimedia :: Video", + "Topic :: Multimedia :: Video :: Conversion", + "Topic :: Scientific/Engineering", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Intended Audience :: Education", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "cython", + "numpy", + "requests", + "colorlog", + "tqdm", +] +dynamic = ["version", "readme"] + +[project.urls] +Homepage = "https://abhitronix.github.io/deffcode" +"Bug Reports" = "https://github.com/abhiTronix/deffcode/issues" +Funding = "https://ko-fi.com/W7W8WTYO" +Source = "https://github.com/abhiTronix/deffcode" +Documentation = "https://abhitronix.github.io/deffcode" +Changelog = "https://abhitronix.github.io/deffcode/latest/changelog/" + +[tool.setuptools] +packages = ["deffcode"] + +[tool.setuptools.dynamic] +version = {attr = "deffcode.version.__version__"} diff --git a/setup.py b/setup.py index 13580449..0377ee32 100644 --- a/setup.py +++ b/setup.py @@ -18,15 +18,7 @@ =============================================== """ -# import the necessary packages from setuptools import setup -from distutils.util import convert_path - -# parse PKG version -pkg_version = {} -ver_path = convert_path("deffcode/version.py") -with open(ver_path) as ver_file: - exec(ver_file.read(), pkg_version) # apply various patches to README text and prepare # valid long_description @@ -49,59 +41,7 @@ "(#", "(https://github.com/abhiTronix/deffcode#" ) - setup( - name="deffcode", - packages=["deffcode"], - version=pkg_version["__version__"], - description="A cross-platform High-performance & Flexible Real-time Video Frames Decoder in Python.", - license="Apache License 2.0", - author="Abhishek Thakur", - install_requires=[ - "cython", # (not really a dependency) just helper for numpy install - "numpy", - "requests", - "colorlog", - "tqdm", - ], long_description=long_description, long_description_content_type="text/markdown", - author_email="abhi.una12@gmail.com", - url="https://abhitronix.github.io/deffcode", - keywords=[ - "FFmpeg", - "Decoder", - "Realtime", - "Framework", - "Cross-platform", - "Video Processing", - "Computer Vision", - "Video Decoding", - ], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Operating System :: POSIX", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - "Topic :: Multimedia :: Video", - "Topic :: Multimedia :: Video :: Conversion", - "Topic :: Scientific/Engineering", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "Intended Audience :: Education", - "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - ], - python_requires=">=3.8", - scripts=[], - project_urls={ - "Bug Reports": "https://github.com/abhiTronix/deffcode/issues", - "Funding": "https://ko-fi.com/W7W8WTYO", - "Source": "https://github.com/abhiTronix/deffcode", - "Documentation": "https://abhitronix.github.io/deffcode", - "Changelog": "https://abhitronix.github.io/deffcode/latest/changelog/", - }, ) From 02b66cac91443da421ae3fc8e18e093852f34355 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sat, 18 Apr 2026 23:38:17 +0530 Subject: [PATCH 25/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20minimum?= =?UTF-8?q?=20Python=20version=20to=203.10+=20for=20v0.2.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/installation/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/index.md b/docs/installation/index.md index 89f62023..56ecce3a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -102,7 +102,7 @@ DeFFcode is well-tested and supported on the following systems(but not limited t ## Supported Python legacies -:fontawesome-brands-python: [**Python 3.7+**](https://www.python.org/downloads/) are only supported legacies for installing DeFFcode `v0.1.0` and above. +:fontawesome-brands-python: [**Python 3.10+**](https://www.python.org/downloads/) are only supported legacies for installing DeFFcode `v0.2.7` and above.   From baa972a7d21391c164ad8046d9e6691bb4394238 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 00:01:18 +0530 Subject: [PATCH 26/57] =?UTF-8?q?=F0=9F=8E=A8=20chore:=20add=20ruff=20lint?= =?UTF-8?q?ing=20and=20formatting=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 02570911..4b7ccc5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,3 +57,48 @@ packages = ["deffcode"] [tool.setuptools.dynamic] version = {attr = "deffcode.version.__version__"} + +[tool.ruff] +line-length = 100 +target-version = "py310" +extend-exclude = [ + "build", + "dist", + ".venv", + "venv", + "docs", + "*.egg-info", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "PIE", # flake8-pie + "RUF", # Ruff-specific rules +] +ignore = [ + "E501", # line-too-long (handled by formatter) + "B008", # function calls in argument defaults + "B904", # raise ... from ... + "SIM105", # contextlib.suppress (pattern clarity) + "SIM108", # ternary-in-place + "UP032", # f-strings (keep .format() calls for readability in logs) + "RUF012", # mutable class attrs with ClassVar + "E722", # bare except (kept intentional in a few places) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B011", "E402", "F401"] +"deffcode/__init__.py" = ["F401"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" From 261bf080b3e1cf1937db7d747e7c57a8b7360196 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 00:05:53 +0530 Subject: [PATCH 27/57] =?UTF-8?q?=F0=9F=8E=A8=20refactor:=20add=20type=20a?= =?UTF-8?q?nnotations=20and=20modernize=20Python=20idioms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/__init__.py | 2 +- deffcode/ffdecoder.py | 102 ++++++++++++++++++++++------------------ deffcode/ffhelper.py | 105 ++++++++++++++++++++++++------------------ deffcode/sourcer.py | 91 +++++++++++++++++++++--------------- deffcode/utils.py | 27 ++++++----- deffcode/version.py | 2 +- 6 files changed, 184 insertions(+), 145 deletions(-) diff --git a/deffcode/__init__.py b/deffcode/__init__.py index 49d53e35..447ae54a 100644 --- a/deffcode/__init__.py +++ b/deffcode/__init__.py @@ -4,4 +4,4 @@ from .ffdecoder import FFdecoder from .sourcer import Sourcer -from .version import __version__ \ No newline at end of file +from .version import __version__ diff --git a/deffcode/ffdecoder.py b/deffcode/ffdecoder.py index 0509df35..b05cee33 100644 --- a/deffcode/ffdecoder.py +++ b/deffcode/ffdecoder.py @@ -19,19 +19,26 @@ """ # import the necessary packages -import platform +from __future__ import annotations + import logging -import numpy as np +import platform import subprocess as sp from collections import OrderedDict +from collections.abc import Generator +from types import TracebackType +from typing import Any + +import numpy as np -# import utils packages -from .utils import dict2Args, logger_handler -from .sourcer import Sourcer from .ffhelper import ( get_supported_pixfmts, get_supported_vdecoders, ) +from .sourcer import Sourcer + +# import utils packages +from .utils import dict2Args, logger_handler # define FFdecoder logger logger = logging.getLogger("FFdecoder") @@ -72,13 +79,13 @@ class FFdecoder: def __init__( self, - source, - source_demuxer=None, - frame_format=None, - custom_ffmpeg="", - verbose=False, - **ffparams - ): + source: str | int, + source_demuxer: str | None = None, + frame_format: str | None = None, + custom_ffmpeg: str = "", + verbose: bool = False, + **ffparams: Any, + ) -> None: """ This constructor method initializes the object state and attributes of the FFdecoder Class. @@ -141,7 +148,7 @@ def __init__( self.__extra_params = { str(k).strip(): ( str(v).strip() - if not (v is None) + if v is not None and not isinstance(v, (dict, list, int, float, tuple)) else v ) @@ -187,7 +194,7 @@ def __init__( ) # pass FFmpeg filter to Sourcer API params for processing - if set(["-vf", "-filter_complex"]).intersection(self.__extra_params.keys()): + if {"-vf", "-filter_complex"}.intersection(self.__extra_params.keys()): key = "-vf" if "-vf" in self.__extra_params else "-filter_complex" sourcer_params[key] = self.__extra_params[key] @@ -282,7 +289,7 @@ def __init__( self.__inputframerate = float(__framerate) if __framerate > 0.0 else 0.0 else: # warn if wrong type - not (__framerate is None) and logger.warning( + __framerate is not None and logger.warning( "Discarding invalid `-framerate` value of wrong type `{}`!".format( type(__framerate).__name__ ) @@ -309,7 +316,7 @@ def __init__( ) else: # log it - not (self.__custom_resolution is None) and logger.warning( + self.__custom_resolution is not None and logger.warning( "Discarding invalid `-custom_resolution` value: `{}`!".format( self.__custom_resolution ) @@ -317,7 +324,7 @@ def __init__( # reset improper values self.__custom_resolution = None - def formulate(self): + def formulate(self) -> FFdecoder: """ This method formulates all necessary FFmpeg pipeline arguments and executes it inside the FFmpeg `subprocess` pipe. @@ -354,7 +361,7 @@ def formulate(self): self.__extra_params.pop("-vcodec", None) else: # assign video decoder selected here. - if not "-vcodec" in self.__extra_params: + if "-vcodec" not in self.__extra_params: input_params["-vcodec"] = default_vdecodec else: input_params["-vcodec"] = self.__extra_params.pop( @@ -362,7 +369,7 @@ def formulate(self): ) if ( default_vdecodec != "unknown" - and not input_params["-vcodec"] in supported_vdecodecs + and input_params["-vcodec"] not in supported_vdecodecs ): # reset to default if not supported logger.warning( @@ -372,7 +379,7 @@ def formulate(self): ) input_params["-vcodec"] = default_vdecodec # raise error if not valid decoder found - if not input_params["-vcodec"] in supported_vdecodecs: + if input_params["-vcodec"] not in supported_vdecodecs: raise RuntimeError( "Provided FFmpeg does not support any known usable video-decoders." " Either define your own manually or switch to another FFmpeg binaries(if available)." @@ -385,7 +392,7 @@ def formulate(self): ) if "-frames:v" in self.__extra_params: value = self.__extra_params.pop("-frames:v", None) - if not (value is None) and value > 0: + if value is not None and value > 0: output_params["-frames:v"] = value # dynamically calculate default raw-frames pixel format(if not assigned by user). @@ -414,7 +421,7 @@ def formulate(self): # assign output raw-frames pixel format rawframe_pixfmt = None if ( - not (self.__frame_format is None) + self.__frame_format is not None and self.__frame_format in supported_pixfmts ): # check if valid and supported `frame_format` parameter assigned @@ -451,9 +458,7 @@ def formulate(self): "{} Switching to default `{}` pixel-format!".format( ( "Provided FFmpeg does not supports `{}` pixel-format.".format( - self.__sourcer_metadata["output_frames_pixfmt"] - if "output_frames_pixfmt" in self.__sourcer_metadata - else self.__frame_format + self.__sourcer_metadata.get("output_frames_pixfmt", self.__frame_format) ) if self.__frame_format != "null" else "No usable pixel-format defined." @@ -463,11 +468,11 @@ def formulate(self): ) # dynamically calculate raw-frame datatype based on pixel-format selected - (self.__raw_frame_depth, rawframesbpp) = [ + (self.__raw_frame_depth, rawframesbpp) = next( (int(x[1]), int(x[2])) for x in self.__ff_pixfmt_metadata if x[0] == rawframe_pixfmt - ][0] + ) raw_bit_per_component = ( rawframesbpp // self.__raw_frame_depth if self.__raw_frame_depth else 0 ) @@ -482,7 +487,7 @@ def formulate(self): self.__raw_frame_dtype = np.dtype(">u2") else: # reset to both pixel-format and datatype to default if not supported - not (self.__frame_format is None) and logger.warning( + self.__frame_format is not None and logger.warning( "Selected pixel-format `{}` dtype is not supported by FFdecoder API. Switching to default `rgb24` pixel-format!".format( rawframe_pixfmt ) @@ -510,7 +515,7 @@ def formulate(self): ) self.__extra_params.pop("-s", None) # assign output rawframe resolution - if not (self.__custom_resolution is None) and not isinstance( + if self.__custom_resolution is not None and not isinstance( self.__custom_resolution, str ): # assign if assigned by user and not "null"(str) @@ -632,7 +637,7 @@ def formulate(self): if "-frames:v" in input_params: self.__raw_frame_num = input_params["-frames:v"] elif ( - not (self.__sourcer_metadata["approx_video_nframes"] is None) + self.__sourcer_metadata["approx_video_nframes"] is not None and self.__sourcer_metadata["approx_video_nframes"] > 0 ): self.__raw_frame_num = self.__sourcer_metadata["approx_video_nframes"] @@ -660,13 +665,11 @@ def formulate(self): logger.error("This pipeline is already created and running!") return self - def __fetchNextfromPipeline(self): + def __fetchNextfromPipeline(self) -> np.ndarray | None: """ This Internal method to fetch next dataframes(1D arrays) from `subprocess` pipe's standard output(`stdout`) into a Numpy buffer. """ - assert not ( - self.__process is None - ), "Pipeline is not running! You must call `formulate()` method first." + assert self.__process is not None, "Pipeline is not running! You must call `formulate()` method first." # formulated raw frame size and apply YUV pixel formats patch(if applicable) raw_frame_size = ( @@ -692,11 +695,11 @@ def __fetchNextfromPipeline(self): raise RuntimeError("Frame buffering failed with error: {}".format(str(e))) return ( nparray - if not (nparray is None) and len(nparray) == raw_frame_size + if nparray is not None and len(nparray) == raw_frame_size else None ) - def __fetchNextFrame(self): + def __fetchNextFrame(self) -> np.ndarray | None: """ This Internal method grabs and decodes next 3D `ndarray` video-frame from the buffer. """ @@ -732,7 +735,7 @@ def __fetchNextFrame(self): # return frame return frame - def generateFrame(self): + def generateFrame(self) -> Generator[np.ndarray, None, None]: """ This method returns a [Generator function](https://wiki.python.org/moin/Generators) _(also an Iterator using `next()`)_ of video frames, grabbed continuously from the buffer. @@ -752,7 +755,7 @@ def generateFrame(self): break yield frame - def __enter__(self): + def __enter__(self) -> FFdecoder: """ Handles entry with the `with` statement. See [PEP343 -- The 'with' statement'](https://peps.python.org/pep-0343/). @@ -760,14 +763,19 @@ def __enter__(self): """ return self.formulate() - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: """ Handles exit with the `with` statement. See [PEP343 -- The 'with' statement'](https://peps.python.org/pep-0343/). """ self.terminate() @property - def metadata(self): + def metadata(self) -> str: """ A property object that dumps metadata information as JSON string. @@ -787,7 +795,7 @@ def metadata(self): ) @metadata.setter - def metadata(self, value): + def metadata(self, value: dict[str, Any]) -> None: """ A property object that updates metadata information with user-defined dictionary. @@ -826,7 +834,7 @@ def metadata(self, value): ". Try updating `{}` property instead!".format( counterpart_prop[key] ) - if key in counterpart_prop.keys() + if key in counterpart_prop else " and cannot be updated!" ) ) @@ -870,7 +878,9 @@ def metadata(self, value): # otherwise raise error raise ValueError("Invalid datatype metadata assigned. Aborting!") - def __launch_FFdecoderline(self, input_params, output_params): + def __launch_FFdecoderline( + self, input_params: dict[str, Any], output_params: dict[str, Any] + ) -> None: """ This Internal method executes FFmpeg pipeline arguments inside a `subprocess` pipe in a new process. @@ -892,7 +902,7 @@ def __launch_FFdecoderline(self, input_params, output_params): + input_parameters + ( ["-f", self.__sourcer_metadata["source_demuxer"]] - if ("source_demuxer" in self.__sourcer_metadata.keys()) + if ("source_demuxer" in self.__sourcer_metadata) else [] ) + ["-i", self.__sourcer_metadata["source"]] @@ -918,7 +928,7 @@ def __launch_FFdecoderline(self, input_params, output_params): ), ) - def terminate(self): + def terminate(self) -> None: """ Safely terminates all processes. """ @@ -927,7 +937,7 @@ def terminate(self): self.__verbose_logs and logger.debug("Terminating FFdecoder Pipeline...") self.__terminate_stream = True # check if no process was initiated at first place - if self.__process is None or not (self.__process.poll() is None): + if self.__process is None or self.__process.poll() is not None: logger.info("Pipeline already terminated.") return # Attempt to close pipeline. diff --git a/deffcode/ffhelper.py b/deffcode/ffhelper.py index d94a0e23..da1fab7c 100644 --- a/deffcode/ffhelper.py +++ b/deffcode/ffhelper.py @@ -19,18 +19,22 @@ """ # import the necessary packages -import os, re -import requests +from __future__ import annotations + import logging +import os import platform +import re import subprocess as sp - -from tqdm import tqdm from pathlib import Path +from typing import Any + +import requests from requests.adapters import HTTPAdapter, Retry +from tqdm import tqdm # import utils packages -from .utils import logger_handler, delete_file_safe +from .utils import delete_file_safe, logger_handler # define logger logger = logging.getLogger("FFhelper") @@ -47,14 +51,14 @@ class TimeoutHTTPAdapter(HTTPAdapter): A custom Transport Adapter with default timeouts """ - def __init__(self, *args, **kwargs): - self.timeout = DEFAULT_TIMEOUT + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.timeout: float = DEFAULT_TIMEOUT if "timeout" in kwargs: self.timeout = kwargs["timeout"] del kwargs["timeout"] super().__init__(*args, **kwargs) - def send(self, request, **kwargs): + def send(self, request: requests.PreparedRequest, **kwargs: Any) -> requests.Response: timeout = kwargs.get("timeout") if timeout is None: kwargs["timeout"] = self.timeout @@ -62,8 +66,11 @@ def send(self, request, **kwargs): def get_valid_ffmpeg_path( - custom_ffmpeg="", is_windows=False, ffmpeg_download_path="", verbose=False -): + custom_ffmpeg: str = "", + is_windows: bool = False, + ffmpeg_download_path: str = "", + verbose: bool = False, +) -> str | bool: """ ## get_valid_ffmpeg_path @@ -154,7 +161,9 @@ def get_valid_ffmpeg_path( return final_path if validate_ffmpeg(final_path, verbose=verbose) else False -def download_ffmpeg_binaries(path, os_windows=False, os_bit=""): +def download_ffmpeg_binaries( + path: str, os_windows: bool = False, os_bit: str = "" +) -> str: """ ## download_ffmpeg_binaries @@ -219,9 +228,7 @@ def download_ffmpeg_binaries(path, os_windows=False, os_bit=""): if "content-length" in response.headers else len(response.content) ) - assert not ( - total_length is None - ), "[Helper:ERROR] :: Failed to retrieve files, check your Internet connectivity!" + assert total_length is not None, "[Helper:ERROR] :: Failed to retrieve files, check your Internet connectivity!" bar = tqdm(total=int(total_length), unit="B", unit_scale=True) for data in response.iter_content(chunk_size=4096): f.write(data) @@ -229,7 +236,7 @@ def download_ffmpeg_binaries(path, os_windows=False, os_bit=""): bar.close() logger.debug("Extracting executables.") with zipfile.ZipFile(file_name, "r") as zip_ref: - zip_fname, _ = os.path.split(zip_ref.infolist()[0].filename) + _zip_fname, _ = os.path.split(zip_ref.infolist()[0].filename) zip_ref.extractall(base_path) # perform cleaning delete_file_safe(file_name) @@ -239,7 +246,7 @@ def download_ffmpeg_binaries(path, os_windows=False, os_bit=""): return final_path -def validate_ffmpeg(path, verbose=False): +def validate_ffmpeg(path: str, verbose: bool = False) -> bool: """ ## validate_ffmpeg @@ -272,7 +279,7 @@ def validate_ffmpeg(path, verbose=False): return True -def get_supported_pixfmts(path): +def get_supported_pixfmts(path: str) -> list[tuple[str, str, str]]: """ ## get_supported_pixfmts @@ -298,13 +305,13 @@ def get_supported_pixfmts(path): outputs = finder.findall("\n".join(supported_pxfmts)) # return output findings return [ - ([s for s in o[0].split(" ")][-1], o[1].strip(), o[2].strip()) + (list(o[0].split(" "))[-1], o[1].strip(), o[2].strip()) for o in outputs if len(o) == 3 ] -def get_supported_vdecoders(path): +def get_supported_vdecoders(path: str) -> list[str]: """ ## get_supported_vdecoders @@ -328,10 +335,10 @@ def get_supported_vdecoders(path): # find all outputs outputs = finder.findall("\n".join(supported_vdecoders)) # return output findings - return [[s for s in o.split(" ")][-1] for o in outputs] + return [list(o.split(" "))[-1] for o in outputs] -def get_supported_demuxers(path): +def get_supported_demuxers(path: str) -> list[str]: """ ## get_supported_demuxers @@ -345,16 +352,18 @@ def get_supported_demuxers(path): # extract and clean FFmpeg output demuxers = check_sp_output([path, "-hide_banner", "-demuxers"]) splitted = [x.decode("utf-8").strip() for x in demuxers.split(b"\n")] - split_index = [idx for idx, s in enumerate(splitted) if "--" in s][0] + split_index = next(idx for idx, s in enumerate(splitted) if "--" in s) supported_demuxers = splitted[split_index + 1 : len(splitted) - 1] # search all demuxers outputs = [re.search(r"\s[a-z0-9_,-]{2,}\s", d) for d in supported_demuxers] outputs = [o.group(0) for o in outputs if o] # return demuxers output - return [o.strip() if not ("," in o) else o.split(",")[-1].strip() for o in outputs] + return [o.strip() if "," not in o else o.split(",")[-1].strip() for o in outputs] -def extract_device_n_demuxer(path, machine_OS=None, verbose=False): +def extract_device_n_demuxer( + path: str, machine_OS: str | None = None, verbose: bool = False +) -> tuple[list[Any], str]: """ ## get_valid_devicepath @@ -369,22 +378,22 @@ def extract_device_n_demuxer(path, machine_OS=None, verbose=False): **Returns:** Tuple of list of supported device(s) path/name/index and OS specific demuxer used. """ # validate `machine_OS` parameter value - assert not (machine_OS is None) and isinstance( + assert machine_OS is not None and isinstance( machine_OS, str ), "`machine_OS` parameter value is empty or invalid type. Aborting!" # initialize params - devices = [] # handles devices discovered - req_demuxer = None # handle required demuxer + devices: list[Any] = [] # handles devices discovered + req_demuxer: str | None = None # handle required demuxer # define all valid FFmpeg demuxers w.r.t OS platforms - valid_demuxers = dict(Windows="dshow", Darwin="avfoundation", Linux="v4l2") + valid_demuxers = {"Windows": "dshow", "Darwin": "avfoundation", "Linux": "v4l2"} # check OS is supported - if not machine_OS.strip() in list(valid_demuxers.keys()): + if machine_OS.strip() not in list(valid_demuxers.keys()): # raise error if OS isn't supported raise ValueError( - """Unsupported OS detected! The `source_demuxer='auto'` value isn't supported on your OS, + """Unsupported OS detected! The `source_demuxer='auto'` value isn't supported on your OS, Kindly assign `source` and `source_demuxer` parameter values manually.""" ) else: @@ -410,13 +419,13 @@ def extract_device_n_demuxer(path, machine_OS=None, verbose=False): if machine_OS == "Windows": # get metadata metadata = check_sp_output( - [path] + default_ffcommand.split(" "), + [path, *default_ffcommand.split(" ")], force_retrieve_stderr=True, ) # clean and split metadata splitted = [x.decode("utf-8").strip() for x in metadata.split(b"\n")] # find video only - head, sep, tail = "\n".join(splitted).partition("DirectShow audio") + head, _sep, _tail = "\n".join(splitted).partition("DirectShow audio") if head.strip(): # compile regex finder = re.compile(r'"(.*?[^\\])"') @@ -435,17 +444,17 @@ def extract_device_n_demuxer(path, machine_OS=None, verbose=False): # check if command executed properly if ( not decoded - or set(["command", "not", "found"]).issubset(decoded.split(" ")) + or {"command", "not", "found"}.issubset(decoded.split(" ")) or ( - set(["Cannot", "open", "device"]).issubset(decoded.split(" ")) - and not ("):" in decoded) + {"Cannot", "open", "device"}.issubset(decoded.split(" ")) + and "):" not in decoded ) ): logger.error( "Cannot execute `v4l2-ctl` command. " + ( "Kindly install `v4l-utils` package on your linux machine." - if set(["command", "not", "found"]).issubset(decoded.split(" ")) + if {"command", "not", "found"}.issubset(decoded.split(" ")) else "Permission denied! Add your username to the `video` group to fix this error." ) ) @@ -496,13 +505,13 @@ def extract_device_n_demuxer(path, machine_OS=None, verbose=False): else: # Darwin OSes # get metadata metadata = check_sp_output( - [path] + default_ffcommand.split(" "), + [path, *default_ffcommand.split(" ")], force_retrieve_stderr=True, ) # clean and split metadata splitted = [x.decode("utf-8").strip() for x in metadata.split(b"\n")] # find video only - head, sep, tail = "\n".join(splitted).partition("AVFoundation audio") + head, _sep, _tail = "\n".join(splitted).partition("AVFoundation audio") if head.strip(): # compile regex finder = re.compile(r"\[[0-9]\](.*)") @@ -542,7 +551,9 @@ def extract_device_n_demuxer(path, machine_OS=None, verbose=False): ) -def validate_imgseqdir(source, extension="jpg", verbose=False): +def validate_imgseqdir( + source: str, extension: str = "jpg", verbose: bool = False +) -> bool: """ ## validate_imgseqdir @@ -564,13 +575,15 @@ def validate_imgseqdir(source, extension="jpg", verbose=False): return False else: return ( - True if len(list(dirpath.glob("*.{}".format(extension)))) > 2 else False + len(list(dirpath.glob("*.{}".format(extension)))) > 2 ) except: return False -def is_valid_image_seq(path, source=None, verbose=False): +def is_valid_image_seq( + path: str, source: str | None = None, verbose: bool = False +) -> bool: """ ## is_valid_image_seq @@ -593,7 +606,7 @@ def is_valid_image_seq(path, source=None, verbose=False): supported_image_formats = [ x.split("_")[0] for x in extract_formats if x.endswith("_pipe") ] - filename, extension = os.path.splitext(source) + _filename, extension = os.path.splitext(source) # Test and return result whether scheme is supported if extension and source.endswith(tuple(supported_image_formats)): if validate_imgseqdir(source, extension=extension[1:], verbose=verbose): @@ -612,7 +625,9 @@ def is_valid_image_seq(path, source=None, verbose=False): return False -def is_valid_url(path, url=None, verbose=False): +def is_valid_url( + path: str, url: str | None = None, verbose: bool = False +) -> bool: """ ## is_valid_url @@ -653,7 +668,7 @@ def is_valid_url(path, url=None, verbose=False): return False -def check_sp_output(*args, **kwargs): +def check_sp_output(*args: Any, **kwargs: Any) -> bytes: """ ## check_sp_output @@ -673,9 +688,9 @@ def check_sp_output(*args, **kwargs): retrieve_stderr = kwargs.pop("force_retrieve_stderr", False) # execute command in subprocess process = sp.Popen( + *args, stdout=sp.PIPE, stderr=sp.DEVNULL if not (retrieve_stderr) else sp.PIPE, - *args, **kwargs, ) # communicate and poll process diff --git a/deffcode/sourcer.py b/deffcode/sourcer.py index c4ff2527..b20edbd4 100644 --- a/deffcode/sourcer.py +++ b/deffcode/sourcer.py @@ -19,25 +19,30 @@ """ # import required libraries -import re -import os +from __future__ import annotations + import copy import json import logging +import os import platform +import re +from typing import Any + import numpy as np -# import utils packages -from .utils import logger_handler, validate_device_index, dict2Args from .ffhelper import ( check_sp_output, + extract_device_n_demuxer, get_supported_demuxers, - is_valid_url, - is_valid_image_seq, get_valid_ffmpeg_path, - extract_device_n_demuxer, + is_valid_image_seq, + is_valid_url, ) +# import utils packages +from .utils import dict2Args, logger_handler, validate_device_index + # define logger logger = logging.getLogger("Sourcer") logger.propagate = False @@ -71,12 +76,12 @@ class Sourcer: def __init__( self, - source, - source_demuxer=None, - custom_ffmpeg="", - verbose=False, - **sourcer_params, - ): + source: str, + source_demuxer: str | None = None, + custom_ffmpeg: str = "", + verbose: bool = False, + **sourcer_params: Any, + ) -> None: """ This constructor method initializes the object state and attributes of the Sourcer Class. @@ -137,7 +142,7 @@ def __init__( # validate the FFmpeg assets and return location (also downloads static assets on windows) self.__ffmpeg = get_valid_ffmpeg_path( str(custom_ffmpeg), - True if self.__machine_OS == "Windows" else False, + self.__machine_OS == "Windows", ffmpeg_download_path=__ffmpeg_download_path, verbose=self.__verbose_logs, ) @@ -173,7 +178,7 @@ def __init__( # enforce "auto" if valid index device self.__source_demuxer = "auto" if validate_device_index(source) else None # log if not valid index device and invalid type - self.__verbose_logs and not self.__source_demuxer in [ + self.__verbose_logs and self.__source_demuxer not in [ "auto", None, ] and logger.warning( @@ -197,7 +202,7 @@ def __init__( # handles all extracted devices names/paths list # when source_demuxer = "auto" - self.__extracted_devices_list = [] + self.__extracted_devices_list: list[Any] = [] # various source stream params self.__default_video_resolution = "" # handles stream resolution @@ -226,7 +231,9 @@ def __init__( # check whether metadata probed or not? self.__metadata_probed = False - def probe_stream(self, default_stream_indexes=(0, 0)): + def probe_stream( + self, default_stream_indexes: list[int] | tuple[int, int] = (0, 0) + ) -> Sourcer: """ This method Parses/Probes FFmpeg `subprocess` pipe's Standard Output for given input source and Populates the information in private class variables. @@ -258,7 +265,7 @@ def probe_stream(self, default_stream_indexes=(0, 0)): self.__default_video_orientation = video_rfparams["orientation"] # parse output parameters through filters (if available) - if not (self.__metadata_output is None): + if self.__metadata_output is not None: # parse output resolution and framerate out_video_rfparams = self.__extract_resolution_framerate( default_stream=default_stream_indexes[0], extract_output=True @@ -325,7 +332,9 @@ def probe_stream(self, default_stream_indexes=(0, 0)): # return reference to the instance object. return self - def retrieve_metadata(self, pretty_json=False, force_retrieve_missing=False): + def retrieve_metadata( + self, pretty_json: bool = False, force_retrieve_missing: bool = False + ) -> dict[str, Any] | str | tuple[dict[str, Any] | str, dict[str, Any] | str]: """ This method returns Parsed/Probed Metadata of the given source. @@ -386,7 +395,7 @@ def retrieve_metadata(self, pretty_json=False, force_retrieve_missing=False): } ) # add output metadata properties (if available) - if not (self.__metadata_output is None): + if self.__metadata_output is not None: metadata.update( { "output_frames_resolution": self.__output_frames_resolution, @@ -421,7 +430,7 @@ def retrieve_metadata(self, pretty_json=False, force_retrieve_missing=False): return metadata if not force_retrieve_missing else (metadata, metadata_missing) @property - def enumerate_devices(self): + def enumerate_devices(self) -> dict[int, Any]: """ A property object that enumerate all probed Camera Devices connected to your system names along with their respective "device indexes" or "camera indexes" as python dictionary. @@ -437,11 +446,14 @@ def enumerate_devices(self): self.__verbose_logs and logger.debug("Enumerating all probed Camera Devices.") # return probed Camera Devices as python dictionary. - return { - dev_idx: dev for dev_idx, dev in enumerate(self.__extracted_devices_list) - } + return dict(enumerate(self.__extracted_devices_list)) - def __validate_source(self, source, source_demuxer=None, forced_validate=False): + def __validate_source( + self, + source: str, + source_demuxer: str | None = None, + forced_validate: bool = False, + ) -> str: """ This Internal method validates source and extracts its metadata. @@ -452,7 +464,7 @@ def __validate_source(self, source, source_demuxer=None, forced_validate=False): **Returns:** `True` if passed tests else `False`. """ # validate source demuxer(if defined) - if not (source_demuxer is None): + if source_demuxer is not None: # check if "auto" demuxer is specified if source_demuxer == "auto": # integerise source to get index @@ -467,13 +479,10 @@ def __validate_source(self, source, source_demuxer=None, forced_validate=False): verbose=self.__verbose_logs, ) # valid indexes range - valid_indexes = [ - x - for x in range( + valid_indexes = list(range( -len(self.__extracted_devices_list), len(self.__extracted_devices_list), - ) - ] + )) # check index is within valid range if self.__extracted_devices_list and index in valid_indexes: # overwrite actual source device name/path/index @@ -522,7 +531,7 @@ def __validate_source(self, source, source_demuxer=None, forced_validate=False): ) ) # otherwise validate against supported demuxers - elif not (source_demuxer in get_supported_demuxers(self.__ffmpeg)): + elif source_demuxer not in get_supported_demuxers(self.__ffmpeg): # raise if fails raise ValueError( "Installed FFmpeg failed to recognize `{}` demuxer. Check `source_demuxer` parameter value again!".format( @@ -592,7 +601,7 @@ def __validate_source(self, source, source_demuxer=None, forced_validate=False): # return metadata based on params return metadata - def __extract_video_bitrate(self, default_stream=0): + def __extract_video_bitrate(self, default_stream: int = 0) -> str: """ This Internal method parses default video-stream bitrate from metadata. @@ -627,7 +636,7 @@ def __extract_video_bitrate(self, default_stream=0): return final_bitrate return "" - def __extract_video_decoder(self, default_stream=0): + def __extract_video_decoder(self, default_stream: int = 0) -> str: """ This Internal method parses default video-stream decoder from metadata. @@ -658,7 +667,9 @@ def __extract_video_decoder(self, default_stream=0): return filtered_pixfmt[0].split(" ")[-1] return "" - def __extract_video_pixfmt(self, default_stream=0, extract_output=False): + def __extract_video_pixfmt( + self, default_stream: int = 0, extract_output: bool = False + ) -> str: """ This Internal method parses default video-stream pixel-format from metadata. @@ -696,7 +707,9 @@ def __extract_video_pixfmt(self, default_stream=0, extract_output=False): return filtered_pixfmt[0].split(" ")[-1] return "" - def __extract_audio_bitrate_nd_samplerate(self, default_stream=0): + def __extract_audio_bitrate_nd_samplerate( + self, default_stream: int = 0 + ) -> dict[str, str]: """ This Internal method parses default audio-stream bitrate and sample-rate from metadata. @@ -744,7 +757,9 @@ def __extract_audio_bitrate_nd_samplerate(self, default_stream=0): ) return result if result and (len(result) == 2) else {} - def __extract_resolution_framerate(self, default_stream=0, extract_output=False): + def __extract_resolution_framerate( + self, default_stream: int = 0, extract_output: bool = False + ) -> dict[str, Any]: """ This Internal method parses default video-stream resolution, orientation, and framerate from metadata. @@ -838,7 +853,7 @@ def __extract_resolution_framerate(self, default_stream=0, extract_output=False) return result if result and (len(result) == 3) else {} - def __extract_duration(self, inseconds=True): + def __extract_duration(self, inseconds: bool = True) -> float | list[str]: """ This Internal method parses stream duration from metadata. diff --git a/deffcode/utils.py b/deffcode/utils.py index 8e477dc6..49a92c7b 100644 --- a/deffcode/utils.py +++ b/deffcode/utils.py @@ -21,16 +21,20 @@ # Contains all the support functions/modules required by FFdecoder package # import the necessary packages -import os, sys +from __future__ import annotations + import logging +import os from pathlib import Path +from typing import Any + from colorlog import ColoredFormatter # import internal packages from .version import __version__ -def logger_handler(): +def logger_handler() -> logging.Handler: """ ## logger_handler @@ -86,7 +90,7 @@ def logger_handler(): logger.info("Running DeFFcode Version: {}".format(str(__version__))) -def dict2Args(param_dict): +def dict2Args(param_dict: dict[str, Any]) -> list[str]: """ ## dict2Args @@ -97,8 +101,8 @@ def dict2Args(param_dict): **Returns:** Arguments list """ - args = [] - for key in param_dict.keys(): + args: list[str] = [] + for key in param_dict: if key in ["-clones"] or key.startswith("-core"): if isinstance(param_dict[key], list): args.extend(param_dict[key]) @@ -115,7 +119,7 @@ def dict2Args(param_dict): return args -def delete_file_safe(file_path): +def delete_file_safe(file_path: str | os.PathLike[str]) -> None: """ ## delete_ext_safe @@ -126,15 +130,12 @@ def delete_file_safe(file_path): """ try: dfile = Path(file_path) - if sys.version_info >= (3, 8, 0): - dfile.unlink(missing_ok=True) - else: - dfile.exists() and dfile.unlink() + dfile.unlink(missing_ok=True) except Exception as e: logger.exception(str(e)) -def validate_device_index(index): +def validate_device_index(index: int | str | Any) -> bool: """ ## validate_device_index @@ -154,9 +155,7 @@ def validate_device_index(index): index.replace(" ", "") # return true return ( - True - if (index.isnumeric() or (index.startswith("-") and index[1:].isnumeric())) - else False + bool(index.isnumeric() or (index.startswith("-") and index[1:].isnumeric())) ) else: # return false otherwise diff --git a/deffcode/version.py b/deffcode/version.py index 6cd38b74..bebcffe9 100644 --- a/deffcode/version.py +++ b/deffcode/version.py @@ -1 +1 @@ -__version__ = "0.2.7" +__version__: str = "0.2.7" From 4257e0c3dd39f895c7552549d36b75e1fb956feb Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 00:07:58 +0530 Subject: [PATCH 28/57] =?UTF-8?q?=F0=9F=8E=A8=20refactor(tests):=20add=20t?= =?UTF-8?q?ype=20annotations=20and=20modernize=20code=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/__init__.py | 2 +- tests/essentials.py | 25 ++++++---- tests/test_ffdecoder.py | 100 ++++++++++++++++++++++------------------ tests/test_ffhelper.py | 54 ++++++++++++---------- tests/test_sourcer.py | 27 +++++++---- tests/test_utils.py | 16 ++++--- 6 files changed, 129 insertions(+), 95 deletions(-) diff --git a/tests/__init__.py b/tests/__init__.py index 3473f681..d4ca7958 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -__author__ = "Abhishek Thakur (@abhiTronix) " \ No newline at end of file +__author__ = "Abhishek Thakur (@abhiTronix) " diff --git a/tests/essentials.py b/tests/essentials.py index 28cba432..9bb74119 100644 --- a/tests/essentials.py +++ b/tests/essentials.py @@ -19,12 +19,17 @@ """ # import the necessary packages +from __future__ import annotations -import os, cv2 -import tempfile import logging +import os import platform +import tempfile +from typing import Any + +import cv2 from vidgear.gears import WriteGear + from deffcode.utils import logger_handler # define test logger @@ -34,10 +39,10 @@ logger.setLevel(logging.DEBUG) # define machine os -is_windows = True if os.name == "nt" else False +is_windows: bool = os.name == "nt" -def return_static_ffmpeg(): +def return_static_ffmpeg() -> str: """ returns system specific FFmpeg static path """ @@ -57,7 +62,7 @@ def return_static_ffmpeg(): return os.path.abspath(path) -def remove_file_safe(path): +def remove_file_safe(path: str) -> None: """ Remove file safely """ @@ -68,7 +73,7 @@ def remove_file_safe(path): logger.exception(e) -def return_testvideo_path(fmt="av"): +def return_testvideo_path(fmt: str = "av") -> str: """ returns Test video path """ @@ -84,7 +89,7 @@ def return_testvideo_path(fmt="av"): return os.path.abspath(path) -def return_generated_frames_path(path): +def return_generated_frames_path(path: str) -> str: """ returns Test video path """ @@ -107,13 +112,13 @@ def return_generated_frames_path(path): return frames_path -def actual_frame_count_n_frame_size(path): +def actual_frame_count_n_frame_size(path: str) -> tuple[int, Any]: """ simply counts the total frames in a given video """ stream = cv2.VideoCapture(path) - num_cv = 0 - shape = None + num_cv: int = 0 + shape: Any = None while True: (grabbed, frame) = stream.read() if not grabbed: diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 98898c63..971891fd 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -19,25 +19,29 @@ """ # import the necessary packages +from __future__ import annotations +import json +import logging import os +import platform +import tempfile +from typing import Any + import cv2 -import json import pytest -import tempfile -import platform -import numpy as np -import logging +from PIL import Image + +from deffcode import FFdecoder +from deffcode.utils import logger_handler + from .essentials import ( - return_static_ffmpeg, - return_testvideo_path, - return_generated_frames_path, actual_frame_count_n_frame_size, remove_file_safe, + return_generated_frames_path, + return_static_ffmpeg, + return_testvideo_path, ) -from PIL import Image -from deffcode import FFdecoder -from deffcode.utils import logger_handler # define test logger logger = logging.getLogger("Test_FFdecoder") @@ -63,7 +67,7 @@ ), ], ) -def test_source_playback(source, custom_ffmpeg, output): +def test_source_playback(source: str, custom_ffmpeg: str, output: bool) -> None: """ Paths Source Playback - Test playback of various source paths/urls supported by FFdecoder API """ @@ -98,7 +102,7 @@ def test_source_playback(source, custom_ffmpeg, output): # Update output if the actual_frame_count_n_frame_size fails to decode stream output = output and (actual_frame_shape is not None) - + # grab RGB24(default) 3D frames from decoder for frame in decoder.generateFrame(): # check shape @@ -116,20 +120,19 @@ def test_source_playback(source, custom_ffmpeg, output): pytest.fail(str(e)) finally: # terminate the decoder - not (decoder is None) and decoder.terminate() + decoder is not None and decoder.terminate() @pytest.mark.parametrize( "pixfmts", ["bgr24", "gray", "rgba", "invalid", "invalid2", "yuv420p", "bgr48be"] ) -def test_frame_format(pixfmts): +def test_frame_format(pixfmts: str) -> None: """ Testing `frame_format` with different pixel formats. """ decoder = None - frame_num = 0 source = return_testvideo_path(fmt="vo") - actual_frame_num, actual_frame_shape = actual_frame_count_n_frame_size(source) + _actual_frame_num, _actual_frame_shape = actual_frame_count_n_frame_size(source) ffparams = {"-pix_fmt": "bgr24"} try: # formulate the decoder with suitable source(for e.g. foo.mp4) @@ -156,7 +159,7 @@ def test_frame_format(pixfmts): **ffparams, ) # assign manually pix-format via `metadata` property object {special case} - decoder.metadata = dict(output_frames_pixfmt="yuvj422p") + decoder.metadata = {"output_frames_pixfmt": "yuvj422p"} # formulate decoder decoder.formulate() @@ -172,7 +175,7 @@ def test_frame_format(pixfmts): pytest.fail(str(e)) finally: # terminate the decoder - not (decoder is None) and decoder.terminate() + decoder is not None and decoder.terminate() @pytest.mark.parametrize( @@ -198,21 +201,21 @@ def test_frame_format(pixfmts): ), (["invalid"], False), ( - dict( - mystring="abcd", # string data - myint=1234, # integers data - mylist=[1, "Rohan", ["inner_list"]], # list data - mydict={"anotherstring": "hello"}, # dictionary data - myjson=json.loads( + { + "mystring": "abcd", # string data + "myint": 1234, # integers data + "mylist": [1, "Rohan", ["inner_list"]], # list data + "mydict": {"anotherstring": "hello"}, # dictionary data + "myjson": json.loads( '{"name": "John", "age": 30, "city": "New York"}' ), # json data - source_video_resolution=[640, 480], - ), + "source_video_resolution": [640, 480], + }, True, ), ], ) -def test_metadata(custom_params, checks): +def test_metadata(custom_params: Any, checks: bool) -> None: """ Testing `metadata` print and updation """ @@ -253,7 +256,7 @@ def test_metadata(custom_params, checks): pytest.fail(str(e)) finally: # terminate the decoder - not (decoder is None) and decoder.terminate() + decoder is not None and decoder.terminate() @pytest.mark.parametrize( @@ -278,14 +281,13 @@ def test_metadata(custom_params, checks): "-framerate": "invalid", "-ffprefixes": "invalid", "-clones": "invalid", - "-framerate": "invalid", "-vcodec": None, }, "gray", ), ], ) -def test_seek_n_save(ffparams, pixfmts): +def test_seek_n_save(ffparams: dict[str, Any], pixfmts: str) -> None: """ Testing `frame_format` with different colorspaces. """ @@ -305,7 +307,7 @@ def test_seek_n_save(ffparams, pixfmts): frame = next(decoder.generateFrame(), None) # check if frame is None - if not (frame is None) and pixfmts == "rgba": + if frame is not None and pixfmts == "rgba": # Convert and save our output filename = os.path.abspath( os.path.join( @@ -315,7 +317,7 @@ def test_seek_n_save(ffparams, pixfmts): im = Image.fromarray(frame) im = im.convert("RGB") im.save(filename) - elif not (frame is None) and pixfmts == "gray": + elif frame is not None and pixfmts == "gray": # Convert and save our output filename = os.path.abspath( os.path.join( @@ -331,7 +333,7 @@ def test_seek_n_save(ffparams, pixfmts): pytest.fail(str(e)) finally: # terminate the decoder - not (decoder is None) and decoder.terminate() + decoder is not None and decoder.terminate() filename and remove_file_safe(filename) @@ -363,7 +365,9 @@ def test_seek_n_save(ffparams, pixfmts): @pytest.mark.parametrize("source, ffparams, result", test_data) -def test_FFdecoder_params(source, ffparams, result): +def test_FFdecoder_params( + source: str, ffparams: dict[str, Any], result: bool +) -> None: """ Testing FFdecoder API with different parameters and save output """ @@ -410,7 +414,7 @@ def test_FFdecoder_params(source, ffparams, result): pytest.xfail(str(e)) finally: # terminate the decoder - if not (writer is None): + if writer is not None: writer.release() remove_file_safe(f_name) @@ -419,17 +423,17 @@ def test_FFdecoder_params(source, ffparams, result): ( "/dev/video0", "v4l2", - True if platform.system() == "Linux" else False, + platform.system() == "Linux", ), # manual source and demuxer ( 0, None, - True if platform.system() == "Linux" else False, + platform.system() == "Linux", ), # +ve index and no demuxer ( "-1", "auto", - True if platform.system() == "Linux" else False, + platform.system() == "Linux", ), # -ve index and "auto" demuxer ("5", "auto", False), # out-of-range index and "auto" demuxer ("invalid", "auto", False), # invalid source and "auto" demuxer @@ -438,7 +442,9 @@ def test_FFdecoder_params(source, ffparams, result): @pytest.mark.parametrize("source, source_demuxer, result", test_data) -def test_camera_capture(source, source_demuxer, result): +def test_camera_capture( + source: str | int, source_demuxer: str | None, result: bool +) -> None: """ Tests FFdecoder's realtime Webcam and Virtual playback capabilities as well as Index based Camera Device Capturing @@ -453,7 +459,7 @@ def test_camera_capture(source, source_demuxer, result): verbose=True, ).formulate() # capture 5 camera frames - for i in range(5): + for _i in range(5): # grab the bgr24 frame from the decoder frame_recv = next(decoder.generateFrame(), None) # check if frame is None @@ -467,7 +473,7 @@ def test_camera_capture(source, source_demuxer, result): pytest.xfail(str(e)) finally: # terminate - not (decoder is None) and decoder.terminate() + decoder is not None and decoder.terminate() test_data = [ @@ -525,7 +531,9 @@ def test_camera_capture(source, source_demuxer, result): @pytest.mark.parametrize("frame_format, ffparams, result", test_data) -def test_discard_n_filter_params(frame_format, ffparams, result): +def test_discard_n_filter_params( + frame_format: str, ffparams: dict[str, Any], result: bool +) -> None: """ Tests FFdecoder's discarding FFmpeg parameters and using FFmpeg Filter capabilities @@ -533,7 +541,7 @@ def test_discard_n_filter_params(frame_format, ffparams, result): decoder = None try: # initialize and formulate the decode with suitable source - if not frame_format in ["invalid2", "invalid3"]: + if frame_format not in ["invalid2", "invalid3"]: decoder = FFdecoder( return_testvideo_path(), frame_format=frame_format, @@ -556,7 +564,7 @@ def test_discard_n_filter_params(frame_format, ffparams, result): # formulate decoder decoder.formulate() # capture 2 camera frames - for i in range(2): + for _i in range(2): # grab the bgr24 frame from the decoder frame_recv = next(decoder.generateFrame(), None) # check if frame is None @@ -570,4 +578,4 @@ def test_discard_n_filter_params(frame_format, ffparams, result): pytest.xfail(str(e)) finally: # terminate - not (decoder is None) and decoder.terminate() + decoder is not None and decoder.terminate() diff --git a/tests/test_ffhelper.py b/tests/test_ffhelper.py index 78229258..daca1888 100644 --- a/tests/test_ffhelper.py +++ b/tests/test_ffhelper.py @@ -18,29 +18,33 @@ =============================================== """ # import the necessary packages +from __future__ import annotations +import logging import os -import pytest import shutil -import logging -import requests import tempfile -from .essentials import ( - is_windows, - return_static_ffmpeg, - return_testvideo_path, - return_generated_frames_path, -) -from deffcode.utils import logger_handler + +import pytest +import requests + from deffcode.ffhelper import ( - get_valid_ffmpeg_path, + check_sp_output, download_ffmpeg_binaries, - validate_ffmpeg, - validate_imgseqdir, + extract_device_n_demuxer, + get_valid_ffmpeg_path, is_valid_image_seq, is_valid_url, - check_sp_output, - extract_device_n_demuxer, + validate_ffmpeg, + validate_imgseqdir, +) +from deffcode.utils import logger_handler + +from .essentials import ( + is_windows, + return_generated_frames_path, + return_static_ffmpeg, + return_testvideo_path, ) # define test logger @@ -64,7 +68,7 @@ @pytest.mark.parametrize("paths, os_bit", test_data) -def test_ffmpeg_binaries_download(paths, os_bit): +def test_ffmpeg_binaries_download(paths: str, os_bit: str) -> None: """ Testing Static FFmpeg auto-download on Windows OS """ @@ -85,7 +89,7 @@ def test_ffmpeg_binaries_download(paths, os_bit): @pytest.mark.parametrize("paths", ["wrong_test_path", return_static_ffmpeg()]) -def test_validate_ffmpeg(paths): +def test_validate_ffmpeg(paths: str) -> None: """ Testing downloaded FFmpeg Static binaries validation on Windows OS """ @@ -111,7 +115,9 @@ def test_validate_ffmpeg(paths): @pytest.mark.parametrize("paths, ffmpeg_download_paths, results", test_data) -def test_get_valid_ffmpeg_path(paths, ffmpeg_download_paths, results): +def test_get_valid_ffmpeg_path( + paths: str, ffmpeg_download_paths: str, results: bool +) -> None: """ Testing FFmpeg excutables validation and correction: """ @@ -140,7 +146,7 @@ def test_get_valid_ffmpeg_path(paths, ffmpeg_download_paths, results): @pytest.mark.xfail(raises=Exception) -def test_check_sp_output(): +def test_check_sp_output() -> None: """ Testing check_sp_output method """ @@ -155,7 +161,7 @@ def test_check_sp_output(): ("unknown://invalid.com/", False), ], ) -def test_is_valid_url(URL, result): +def test_is_valid_url(URL: str | None, result: bool) -> None: """ Testing is_valid_url method """ @@ -178,7 +184,7 @@ def test_is_valid_url(URL, result): ), ], ) -def test_is_valid_image_seq(source, result): +def test_is_valid_image_seq(source: str | None, result: bool) -> None: """ Testing test_is_valid_image_seq method """ @@ -198,7 +204,7 @@ def test_is_valid_image_seq(source, result): ("unknown://invalid.com/", False), ], ) -def test_validate_imgseqdir(path, result): +def test_validate_imgseqdir(path: str, result: bool) -> None: """ Testing validate_imgseqdir method """ @@ -210,8 +216,8 @@ def test_validate_imgseqdir(path, result): @pytest.mark.xfail(raises=ValueError) -def test_extract_device_n_demuxer(): +def test_extract_device_n_demuxer() -> None: """ Testing extract_device_n_demuxer method """ - extract_device_n_demuxer(return_static_ffmpeg(), machine_OS="invalid", verbose=True) \ No newline at end of file + extract_device_n_demuxer(return_static_ffmpeg(), machine_OS="invalid", verbose=True) diff --git a/tests/test_sourcer.py b/tests/test_sourcer.py index b3e76700..ee500334 100644 --- a/tests/test_sourcer.py +++ b/tests/test_sourcer.py @@ -18,17 +18,22 @@ =============================================== """ # import the necessary packages +from __future__ import annotations -import pytest import logging +from typing import Any + +import pytest + +from deffcode import Sourcer +from deffcode.utils import logger_handler + from .essentials import ( + actual_frame_count_n_frame_size, + return_generated_frames_path, return_static_ffmpeg, return_testvideo_path, - return_generated_frames_path, - actual_frame_count_n_frame_size, ) -from deffcode.utils import logger_handler -from deffcode import Sourcer # define test logger logger = logging.getLogger("Test_Sourcer") @@ -73,7 +78,9 @@ ), ], ) -def test_source(source, sourcer_params, custom_ffmpeg): +def test_source( + source: str, sourcer_params: dict[str, Any], custom_ffmpeg: str +) -> None: """ Paths Source - Test various source paths/urls supported by Sourcer. """ @@ -109,7 +116,11 @@ def test_source(source, sourcer_params, custom_ffmpeg): ), ], ) -def test_probe_stream_n_retrieve_metadata(source, default_stream_indexes, params): +def test_probe_stream_n_retrieve_metadata( + source: str, + default_stream_indexes: tuple[int, ...] | list[int], + params: list[str], +) -> None: """ Test `probe_stream` and `retrieve_metadata` function. """ @@ -130,7 +141,7 @@ def test_probe_stream_n_retrieve_metadata(source, default_stream_indexes, params ).probe_stream(default_stream_indexes=default_stream_indexes) metadata = sourcer.retrieve_metadata() logger.debug("Found Metadata: `{}`".format(metadata)) - assert all(metadata[x] == True for x in params), "Test Failed!" + assert all(metadata[x] is True for x in params), "Test Failed!" if ( source.startswith("http") or source.endswith("png") diff --git a/tests/test_utils.py b/tests/test_utils.py index 4a282866..06e4fb2f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -18,13 +18,17 @@ =============================================== """ # import the necessary packages +from __future__ import annotations -import pytest import logging import os import tempfile from os.path import expanduser -from deffcode.utils import dict2Args, logger_handler, delete_file_safe +from typing import Any + +import pytest + +from deffcode.utils import delete_file_safe, dict2Args, logger_handler # define test logger logger = logging.getLogger("Test_Utilities") @@ -40,14 +44,14 @@ @pytest.mark.parametrize("log_filepath, handler_type", test_data) -def test_loggerhandler(log_filepath, handler_type): +def test_loggerhandler(log_filepath: Any, handler_type: logging.Handler) -> None: """ Testing dict2Args utils function. """ if log_filepath: os.environ["DEFFCODE_LOGFILE"] = log_filepath try: - assert type(logger_handler()) == type(handler_type), "Test failed" + assert type(logger_handler()) is type(handler_type), "Test failed" except AssertionError: pytest.fail("Logger handler test failed!") finally: @@ -75,7 +79,7 @@ def test_loggerhandler(log_filepath, handler_type): @pytest.mark.parametrize("dictionary", test_data) -def test_dict2Args(dictionary): +def test_dict2Args(dictionary: dict[str, Any]) -> None: """ Testing dict2Args utils function. """ @@ -93,7 +97,7 @@ def test_dict2Args(dictionary): @pytest.mark.parametrize("file_path, result", test_data) -def test_delete_file_safe(file_path, result): +def test_delete_file_safe(file_path: str, result: bool) -> None: """ Testing delete_file_safe method """ From 0101d24e742e437b83ebfd6d0aa1083618e7816e Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 00:47:31 +0530 Subject: [PATCH 29/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20restructure=20inst?= =?UTF-8?q?allation=20guide=20and=20add=20Poetry=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/installation/index.md | 117 ++++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 35 deletions(-) diff --git a/docs/installation/index.md b/docs/installation/index.md index 56ecce3a..919827d9 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -29,7 +29,39 @@ limitations under the License. ## Supported Systems -DeFFcode is well-tested and supported on the following systems(but not limited to), with [python 3.7+](https://www.python.org/downloads/) and [pip](https://pip.pypa.io/en/stable/installing/#do-i-need-to-install-pip) installed: +DeFFcode is well-tested and supported on the following systems(but not limited to): + +* Any :material-linux: Linux distro released in 2016 or later +* :fontawesome-brands-windows: Windows 7 or later +* :material-apple: MacOS 10.12.6 (Sierra) or later + +  + +## Supported Python legacies + +:fontawesome-brands-python: [**Python 3.10+**](https://www.python.org/downloads/) are only supported legacies for installing DeFFcode `v0.2.7` and above. + +  + +## Prerequisites + +==:warning: **DeFFcode APIs requires FFmpeg binaries to be installed for all of its core functionality.**== + +### FFmpeg + +When installing DeFFcode, [**FFmpeg**][ffmpeg] is the only prerequisites you need to configure/install manually. **You could easily do it by referring [**FFmpeg Installation doc**](../installation/ffmpeg_install/)**. + +  + +## Installation + +### A. Installation using pip (Recommended) + + +> _Best option for easily getting stable DeFFcode installed._ + + +**Installation is as simple as:** ??? alert ":fontawesome-brands-python: Upgrade your `pip`" @@ -94,38 +126,6 @@ DeFFcode is well-tested and supported on the following systems(but not limited t ``` -* Any :material-linux: Linux distro released in 2016 or later -* :fontawesome-brands-windows: Windows 7 or later -* :material-apple: MacOS 10.12.6 (Sierra) or later - -  - -## Supported Python legacies - -:fontawesome-brands-python: [**Python 3.10+**](https://www.python.org/downloads/) are only supported legacies for installing DeFFcode `v0.2.7` and above. - -  - -## Prerequisites - -==:warning: **DeFFcode APIs requires FFmpeg binaries to be installed for all of its core functionality.**== - -### FFmpeg - -When installing DeFFcode, [**FFmpeg**][ffmpeg] is the only prerequisites you need to configure/install manually. **You could easily do it by referring [**FFmpeg Installation doc**](../installation/ffmpeg_install/)**. - -  - -## Installation - -### A. Installation using pip (Recommended) - - -> _Best option for easily getting stable DeFFcode installed._ - - -**Installation is as simple as:** - ??? warning ":fontawesome-brands-windows: Windows Installation" If you are using Windows, some of the commands given below, may not work out-of-the-box. @@ -165,7 +165,54 @@ pip install deffcode-0.2.0-py3-none-any.whl   -### B. Installation from Source +### B. Installation using Poetry + +> _Best option for managing DeFFcode as a dependency in a [Poetry](https://python-poetry.org/)-managed project._ + +DeFFcode's [`pyproject.toml`](https://github.com/abhiTronix/deffcode/blob/master/pyproject.toml) is PEP 517/621 compliant, so it can be consumed directly by [Poetry](https://python-poetry.org/docs/#installation). + +??? info "Don't have Poetry installed?" + + Follow the [official Poetry installation guide](https://python-poetry.org/docs/#installation) before proceeding. You can verify your install with: + + ```sh + poetry --version + ``` + +**Add DeFFcode to an existing Poetry project:** + +```sh +# Add latest stable release as a project dependency +poetry add deffcode +``` + +**Or, install directly from source in a Poetry-managed environment:** + +```sh +# clone the repository and get inside +git clone https://github.com/abhiTronix/deffcode.git && cd deffcode + +# Install it into Poetry's virtualenv +poetry install +``` + +??? tip "Running commands inside Poetry's virtualenv" + + Use `poetry run` to execute DeFFcode-powered scripts without activating the shell: + + ```sh + poetry run python your_script.py + ``` + + Or spawn a shell inside the virtualenv: + + ```sh + poetry shell + ``` + +  + +### C. Installation from Source > Best option for trying latest patches(maybe experimental), forking for Pull Requests, or automatically installing all prerequisites(with a few exceptions). @@ -220,7 +267,7 @@ git clone https://github.com/abhiTronix/deffcode.git && cd deffcode pip install -U . ``` -  +  [^1]: :warning: The `ensurepip` module is missing/disabled on Ubuntu. Use `pip` method only. From 83c02dad8275365b7ac2bc9914030df8d76c96b3 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 11:11:43 +0530 Subject: [PATCH 30/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20YUV420p=20pe?= =?UTF-8?q?rformance=20mode=20tip=20to=20decode-video-files=20recipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/recipes/basic/decode-video-files.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/recipes/basic/decode-video-files.md b/docs/recipes/basic/decode-video-files.md index fbd67cbd..dbfb7e61 100644 --- a/docs/recipes/basic/decode-video-files.md +++ b/docs/recipes/basic/decode-video-files.md @@ -277,6 +277,12 @@ In this example we will decode live **Grayscale** and **YUV** video frames from !!! quote "With FFdecoder API, frames extracted with YUV pixel formats _(`yuv420p`, `yuv444p`, `nv12`, `nv21` etc.)_ are generally incompatible with OpenCV APIs. But you can make them easily compatible by using exclusive [`-enforce_cv_patch`](../../reference/ffdecoder/params/#b-exclusive-parameters) boolean attribute of its `ffparam` dictionary parameter." + !!! success "Performance Mode — :zap: Faster Decoding via YUV420p" + + Ingesting frames as 12-bit **YUV 4:2:0** instead of 24-bit **RGB/BGR** halves the bytes moving through the FFmpeg pipe, so the subprocess pipeline spends less time blocked on I/O. In community benchmarks on 1080p MP4 _(see [issue #15](https://github.com/abhiTronix/deffcode/issues/15))_, RAW ingest jumped from **~96 FPS (RGB24)** to **~213 FPS (YUV420p)**, and **~155 FPS** when converted to BGR inside Python via OpenCV — a **25–33% gain** over the RGB path for the majority of common video sources _(which are already YUV420 on disk)_. + + Use this mode when you're throughput-bound on decoding and can afford a single `cv2.cvtColor` call per frame. Skip it for scientific workloads where the implicit chroma subsampling of YUV 4:2:0 is unacceptable. + Let's try decoding YUV420p pixel-format frames in following python code: !!! info "You can also use other YUV pixel formats such `yuv422p`(4:2:2 subsampling) or `yuv444p`(4:4:4 subsampling) etc. instead for more higher dynamic range in the similar manner." From ebf920c6fffce414c8c175e0326d25927f51036f Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 11:12:06 +0530 Subject: [PATCH 31/57] =?UTF-8?q?=E2=9C=85=20test(ffdecoder):=20add=20YUV/?= =?UTF-8?q?NV=20ingest=20round-trip=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 971891fd..26c95354 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -178,6 +178,58 @@ def test_frame_format(pixfmts: str) -> None: decoder is not None and decoder.terminate() +@pytest.mark.parametrize( + "pixfmt, cv_color_code", + [ + ("yuv420p", cv2.COLOR_YUV2BGR_I420), + ("nv12", cv2.COLOR_YUV2BGR_NV12), + ("nv21", cv2.COLOR_YUV2BGR_NV21), + ], +) +def test_yuv_family_ingest(pixfmt: str, cv_color_code: int) -> None: + """ + Validates the YUV/NV ingest path from Issue #15: FFdecoder must deliver a + compact 3:2 planar buffer for `yuv`/`nv` pixel-formats under + `-enforce_cv_patch`, and that buffer must round-trip to BGR via OpenCV. + """ + decoder = None + source = return_testvideo_path(fmt="vo") + _, actual_shape = actual_frame_count_n_frame_size(source) + try: + decoder = FFdecoder( + source, + frame_format=pixfmt, + custom_ffmpeg=return_static_ffmpeg(), + verbose=True, + **{"-enforce_cv_patch": True}, + ).formulate() + + # pixel-format may fall back to rgb24 if the local FFmpeg build lacks it + metadata = json.loads(decoder.metadata) + if metadata.get("output_frames_pixfmt") != pixfmt: + pytest.skip(f"FFmpeg build does not advertise `{pixfmt}` pixel-format") + + frame = next(decoder.generateFrame(), None) + assert frame is not None, "Test failed - no frame retrieved" + + h, w = actual_shape[0], actual_shape[1] + # YUV/NV ingest with cv_patch yields a 2D buffer with height = h*3/2 + assert frame.shape == (h * 3 // 2, w), ( + f"Test failed - unexpected YUV buffer shape {frame.shape}, " + f"expected {(h * 3 // 2, w)}" + ) + + # round-trip via OpenCV to confirm planar layout is valid + bgr = cv2.cvtColor(frame, cv_color_code) + assert bgr.shape == (h, w, 3), ( + f"Test failed - unexpected BGR shape after conversion {bgr.shape}" + ) + except Exception as e: + pytest.fail(str(e)) + finally: + decoder is not None and decoder.terminate() + + @pytest.mark.parametrize( "custom_params, checks", [ From b532c8a14555ad6c11418e5262d23d847afac47f Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 11:27:01 +0530 Subject: [PATCH 32/57] =?UTF-8?q?=E2=9C=A8=20feat(ffdecoder):=20add=20-ext?= =?UTF-8?q?ract=5Fluma=20fast-path=20for=20YUV/NV=20grayscale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice the Y-plane directly from YUV/NV bytestreams into a 2D (H, W) uint8 ndarray, bypassing FFmpeg colorspace conversion entirely. Faster than frame_format="gray". Add docs recipe, reference entry, and tests. --- deffcode/ffdecoder.py | 22 ++++++- docs/recipes/basic/decode-video-files.md | 45 ++++++++++++++ docs/reference/ffdecoder/params.md | 13 ++++ tests/test_ffdecoder.py | 77 ++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) diff --git a/deffcode/ffdecoder.py b/deffcode/ffdecoder.py index b05cee33..3b68dd17 100644 --- a/deffcode/ffdecoder.py +++ b/deffcode/ffdecoder.py @@ -225,6 +225,15 @@ def __init__( "Enforcing OpenCV compatibility patch for YUV/NV video frames." ) + # handle Direct Luma (Grayscale) Extraction patch for YUV/NV streams + self.__extract_luma = self.__extra_params.pop("-extract_luma", False) + if not (isinstance(self.__extract_luma, bool)): + self.__extract_luma = False + if self.__extract_luma: + self.__verbose_logs and logger.critical( + "Enforcing Direct Luma (Grayscale) Extraction for YUV/NV video frames." + ) + # handle disabling window for ffmpeg subprocess on Windows OS # this patch prevents ffmpeg creation window from opening when # building exe files @@ -674,7 +683,8 @@ def __fetchNextfromPipeline(self) -> np.ndarray | None: # formulated raw frame size and apply YUV pixel formats patch(if applicable) raw_frame_size = ( (self.__raw_frame_resolution[0] * (self.__raw_frame_resolution[1] * 3 // 2)) - if self.__raw_frame_pixfmt.startswith(("yuv", "nv")) and self.__cv_patch + if self.__raw_frame_pixfmt.startswith(("yuv", "nv")) + and (self.__cv_patch or self.__extract_luma) else ( self.__raw_frame_depth * self.__raw_frame_resolution[0] @@ -708,6 +718,16 @@ def __fetchNextFrame(self) -> np.ndarray | None: # check if empty if frame is None: return frame + elif self.__extract_luma and self.__raw_frame_pixfmt.startswith(("yuv", "nv")): + # Extract pure Luma (Y channel) - sits uncompressed at the top of the YUV bytestream + # Slice the first W*H bytes and reshape to 2D + luma_size = self.__raw_frame_resolution[1] * self.__raw_frame_resolution[0] + frame = frame[:luma_size].reshape( + ( + self.__raw_frame_resolution[1], + self.__raw_frame_resolution[0], + ) + ) elif self.__raw_frame_pixfmt.startswith("gray"): # reconstruct exclusive `gray` frames frame = frame.reshape( diff --git a/docs/recipes/basic/decode-video-files.md b/docs/recipes/basic/decode-video-files.md index dbfb7e61..f360c4b2 100644 --- a/docs/recipes/basic/decode-video-files.md +++ b/docs/recipes/basic/decode-video-files.md @@ -273,6 +273,51 @@ In this example we will decode live **Grayscale** and **YUV** video frames from decoder.terminate() ``` +=== "Decode Grayscale via YUV (fastest)" + + !!! success ":zap: Fastest RAW-to-Grayscale via `-extract_luma`" + + Every YUV/NV bytestream stores the **Luma (Y) plane** uncompressed at the top of each frame. The exclusive [`-extract_luma`](../../reference/ffdecoder/params/#b-exclusive-parameters) boolean attribute makes FFdecoder slice that Y-plane directly and hand back a 2D `(H, W)` grayscale ndarray — **no colorspace conversion in FFmpeg, no `cv2.cvtColor` in Python**. This is strictly faster than `frame_format="gray"`, which still asks FFmpeg to do a `yuv→gray` conversion on every frame. + + Combined with the reduced pipe-bytes of YUV 4:2:0 ingest, this is the fastest grayscale pipeline the API can produce. + + ```python + # import the necessary packages + from deffcode import FFdecoder + import cv2 + + # enable direct Luma (Y-plane) extraction + ffparams = {"-extract_luma": True} + + # initialize the decoder with a YUV pixel-format + decoder = FFdecoder( + "input_foo.mp4", frame_format="yuv420p", verbose=True, **ffparams + ).formulate() + + # grab the 2D (H, W) grayscale frames from the decoder + for gray in decoder.generateFrame(): + + # check if frame is None + if gray is None: + break + + # {do something with the gray frame here} + + # Show output window + cv2.imshow("Gray Output", gray) + + # check for 'q' key if pressed + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + + # close output window + cv2.destroyAllWindows() + + # terminate the decoder + decoder.terminate() + ``` + === "Decode YUV frames" !!! quote "With FFdecoder API, frames extracted with YUV pixel formats _(`yuv420p`, `yuv444p`, `nv12`, `nv21` etc.)_ are generally incompatible with OpenCV APIs. But you can make them easily compatible by using exclusive [`-enforce_cv_patch`](../../reference/ffdecoder/params/#b-exclusive-parameters) boolean attribute of its `ffparam` dictionary parameter." diff --git a/docs/reference/ffdecoder/params.md b/docs/reference/ffdecoder/params.md index effb4496..91ba4808 100644 --- a/docs/reference/ffdecoder/params.md +++ b/docs/reference/ffdecoder/params.md @@ -729,6 +729,19 @@ These parameters are discussed below:   +* **`-extract_luma`** _(bool)_ : This attribute can be enabled(`True`) to directly extract the **Luma (Y) plane** as a 2D grayscale `(H, W)` ndarray from YUV/NV pixel-format streams _(such as `yuv420p`, `yuv422p`, `yuv444p`, `nv12`, `nv21` etc.)_. This is the **fastest path to grayscale** available in FFdecoder — the Y plane sits uncompressed at the top of every YUV/NV bytestream, so no colorspace conversion runs either in FFmpeg or in Python; the decoder just slices it out. It can be used as follows: + + !!! warning "As of now, this flag is only applied when `frame_format` resolves to a pixel-format starting with `yuv` or `nv`. For other pixel-formats, the flag is ignored and the default reshape path is used." + + !!! tip "Pair with [Performance Mode ➶](../../../recipes/basic/decode-video-files/#playing-with-any-other-ffmpeg-pixel-formats) via `frame_format=\"yuv420p\"` for the fastest RAW-to-grayscale pipeline. Takes precedence over `-enforce_cv_patch` when both are enabled." + + ```python + # define suitable parameter + ffparams = {"-extract_luma": True} # direct Y-plane (grayscale) extraction + ``` + +  + * **`-disable_ffmpeg_window`** _(bool)_: This attribute can be used to prevent the FFmpeg command line window from appearing when using the FFdecoder API on Windows. This is especially useful when creating an `.exe` file for your Python script with logging disabled(`verbose=False`), as it stops the FFmpeg window from popping up even in windowed or no-console mode. Its usage is as follows: !!! warning "The `-disable_ffmpeg_window` flag is only available on :fontawesome-brands-windows: Windows OS with logging disabled." diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 26c95354..de82569e 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -29,6 +29,7 @@ from typing import Any import cv2 +import numpy as np import pytest from PIL import Image @@ -230,6 +231,82 @@ def test_yuv_family_ingest(pixfmt: str, cv_color_code: int) -> None: decoder is not None and decoder.terminate() +@pytest.mark.parametrize( + "pixfmt", + ["yuv420p", "nv12", "nv21"], +) +def test_extract_luma(pixfmt: str) -> None: + """ + Validates the `-extract_luma` fast-path: for YUV/NV pixel-formats the + decoder must slice the pure Y-plane out of the bytestream and hand back a + 2D grayscale (H, W) ndarray, without requiring `-enforce_cv_patch`. + """ + decoder = None + source = return_testvideo_path(fmt="vo") + _, actual_shape = actual_frame_count_n_frame_size(source) + try: + decoder = FFdecoder( + source, + frame_format=pixfmt, + custom_ffmpeg=return_static_ffmpeg(), + verbose=True, + **{"-extract_luma": True}, + ).formulate() + + # skip if FFmpeg build does not advertise the requested pixel-format + metadata = json.loads(decoder.metadata) + if metadata.get("output_frames_pixfmt") != pixfmt: + pytest.skip(f"FFmpeg build does not advertise `{pixfmt}` pixel-format") + + h, w = actual_shape[0], actual_shape[1] + frames_checked = 0 + # iterate a few frames to confirm pipe stays aligned across reads + for frame in decoder.generateFrame(): + assert frame is not None, "Test failed - no frame retrieved" + # luma-only output must be a 2D (H, W) uint8 ndarray + assert frame.shape == (h, w), ( + f"Test failed - unexpected luma shape {frame.shape}, " + f"expected {(h, w)}" + ) + assert frame.dtype == np.uint8, ( + f"Test failed - unexpected luma dtype {frame.dtype}" + ) + frames_checked += 1 + if frames_checked >= 3: + break + assert frames_checked > 0, "Test failed - generator yielded no frames" + except Exception as e: + pytest.fail(str(e)) + finally: + decoder is not None and decoder.terminate() + + +def test_extract_luma_invalid_type() -> None: + """ + Non-bool `-extract_luma` values must be discarded silently and the decoder + should fall back to the default reshape path. + """ + decoder = None + source = return_testvideo_path(fmt="vo") + _, actual_shape = actual_frame_count_n_frame_size(source) + try: + decoder = FFdecoder( + source, + frame_format="bgr24", + custom_ffmpeg=return_static_ffmpeg(), + **{"-extract_luma": "yes"}, # invalid, must be coerced to False + ).formulate() + frame = next(decoder.generateFrame(), None) + assert frame is not None and frame.shape == actual_shape, ( + f"Test failed - got {None if frame is None else frame.shape}, " + f"expected {actual_shape}" + ) + except Exception as e: + pytest.fail(str(e)) + finally: + decoder is not None and decoder.terminate() + + @pytest.mark.parametrize( "custom_params, checks", [ From 119b9df177b5dec33984623efdd805e91cb20d4a Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 11:47:19 +0530 Subject: [PATCH 33/57] =?UTF-8?q?=F0=9F=9A=A8=20style:=20remove=20redundan?= =?UTF-8?q?t=20"r"=20mode=20in=20open()=20call?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0377ee32..51dd3376 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ # apply various patches to README text and prepare # valid long_description -with open("README.md", "r", encoding="utf-8") as fh: +with open("README.md", encoding="utf-8") as fh: long_description = fh.read() # patch to remove github README specific text long_description = ( From fb5a1488fca59a586d44551200615e4f8a336e5a Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 12:13:35 +0530 Subject: [PATCH 34/57] =?UTF-8?q?=E2=9C=A8=20feat(ffdecoder):=20add=20asyn?= =?UTF-8?q?c=20per-frame=20metadata=20extraction=20via=20showinfo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/ffdecoder.py | 127 +++++++++++++++- .../advanced/extract-frame-metadata.md | 141 +++++++++++++++++ docs/recipes/advanced/index.md | 3 + docs/reference/ffdecoder/params.md | 31 ++++ mkdocs.yml | 1 + tests/test_ffdecoder.py | 143 ++++++++++++++++++ 6 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 docs/recipes/advanced/extract-frame-metadata.md diff --git a/deffcode/ffdecoder.py b/deffcode/ffdecoder.py index 3b68dd17..f5fed5e6 100644 --- a/deffcode/ffdecoder.py +++ b/deffcode/ffdecoder.py @@ -23,7 +23,10 @@ import logging import platform +import queue +import re import subprocess as sp +import threading from collections import OrderedDict from collections.abc import Generator from types import TracebackType @@ -31,6 +34,12 @@ import numpy as np +# regex to parse FFmpeg `showinfo` lines emitted on stderr +# example: "n: 0 pts:0 pts_time:0 ... iskey:1 type:I checksum:..." +_SHOWINFO_REGEX = re.compile( + r"n:\s*(\d+).*?pts_time:\s*([-0-9.]+).*?iskey:(\d).*?type:([IPB?])" +) + from .ffhelper import ( get_supported_pixfmts, get_supported_vdecoders, @@ -234,6 +243,20 @@ def __init__( "Enforcing Direct Luma (Grayscale) Extraction for YUV/NV video frames." ) + # handle asynchronous per-frame metadata extraction via `showinfo` filter + # when enabled, `generateFrame()` yields (frame, meta_dict) tuples + self.__extract_metadata = self.__extra_params.pop("-extract_metadata", False) + if not isinstance(self.__extract_metadata, bool): + self.__extract_metadata = False + # metadata queue and reader thread state (populated by __launch_FFdecoderline) + self.__metadata_queue: queue.Queue[dict[str, Any]] | None = None + self.__stderr_thread: threading.Thread | None = None + self.__stderr_stop = threading.Event() + if self.__extract_metadata: + self.__verbose_logs and logger.critical( + "Enabling asynchronous `showinfo` per-frame metadata extraction." + ) + # handle disabling window for ffmpeg subprocess on Windows OS # this patch prevents ffmpeg creation window from opening when # building exe files @@ -641,6 +664,22 @@ def formulate(self) -> FFdecoder: # add rest to output parameters output_params.update(self.__extra_params) + # chain the `showinfo` filter onto the pipeline when per-frame + # metadata extraction is enabled. A pre-existing `-vf` is preserved + # via comma-concatenation; `-filter_complex` is not supported here + # because graph-label routing is ambiguous. + if self.__extract_metadata: + if "-filter_complex" in output_params: + logger.warning( + "`-extract_metadata` is incompatible with `-filter_complex`. Disabling metadata extraction." + ) + self.__extract_metadata = False + else: + existing_vf = output_params.get("-vf", "") + output_params["-vf"] = ( + f"{existing_vf},showinfo" if existing_vf else "showinfo" + ) + # dynamically calculate raw-frame numbers based on source (if not assigned by user). # TODO Added support for `-re -stream_loop` and `-loop` if "-frames:v" in input_params: @@ -759,6 +798,10 @@ def generateFrame(self) -> Generator[np.ndarray, None, None]: """ This method returns a [Generator function](https://wiki.python.org/moin/Generators) _(also an Iterator using `next()`)_ of video frames, grabbed continuously from the buffer. + + When the `-extract_metadata` parameter is enabled the generator yields + `(frame, metadata)` tuples, where `metadata` is a dict with keys + `frame_num`, `pts_time`, `is_keyframe`, and `frame_type`. """ if self.__raw_frame_num is None or not self.__raw_frame_num: while not self.__terminate_stream: # infinite raw frames @@ -766,14 +809,31 @@ def generateFrame(self) -> Generator[np.ndarray, None, None]: if frame is None: self.__terminate_stream = True break - yield frame + yield self.__attach_metadata(frame) else: for _ in range(self.__raw_frame_num): # finite raw frames frame = self.__fetchNextFrame() if frame is None: self.__terminate_stream = True break - yield frame + yield self.__attach_metadata(frame) + + def __attach_metadata(self, frame: np.ndarray): + """ + Internal: zip the just-decoded frame with the next queued metadata + dict when `-extract_metadata` is enabled. Uses a bounded timeout so a + mis-emitting filter chain can never deadlock the consumer. + """ + if not self.__extract_metadata: + return frame + try: + meta = self.__metadata_queue.get(timeout=10.0) + except queue.Empty: + logger.warning( + "Timed-out waiting for `showinfo` metadata. Yielding frame with empty metadata." + ) + meta = None + return (frame, meta) def __enter__(self) -> FFdecoder: """ @@ -929,12 +989,22 @@ def __launch_FFdecoderline( + output_parameters + ["-f", "rawvideo", "-"] ) + # When metadata extraction is enabled we must capture stderr regardless + # of verbose mode so the background reader thread can parse showinfo + # lines. Without PIPE the reader would have nothing to read (verbose + # inherits parent stderr; silent discards it). + if self.__extract_metadata: + stderr_target = sp.PIPE + elif self.__verbose_logs: + stderr_target = None + else: + stderr_target = sp.DEVNULL + # compose the FFmpeg process if self.__verbose_logs: logger.debug("Executing FFmpeg command: `{}`".format(" ".join(cmd))) - # In debugging mode self.__process = sp.Popen( - cmd, stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=None + cmd, stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=stderr_target ) else: # In silent mode @@ -942,12 +1012,52 @@ def __launch_FFdecoderline( cmd, stdin=sp.DEVNULL, stdout=sp.PIPE, - stderr=sp.DEVNULL, + stderr=stderr_target, creationflags=( # this prevents ffmpeg creation window from opening when building exe files on Windows sp.DETACHED_PROCESS if self.__ffmpeg_window_disabler_patch else 0 ), ) + # spin up the stderr reader thread that parses `showinfo` lines and + # feeds per-frame metadata dicts into the queue consumed by generateFrame() + if self.__extract_metadata: + self.__metadata_queue = queue.Queue() + self.__stderr_stop.clear() + self.__stderr_thread = threading.Thread( + target=self.__read_stderr, daemon=True + ) + self.__stderr_thread.start() + + def __read_stderr(self) -> None: + """ + Internal: background daemon that parses FFmpeg `showinfo` lines off + stderr and pushes per-frame metadata dicts onto `__metadata_queue`. + Exits when FFmpeg closes stderr or when `__stderr_stop` is signalled. + """ + assert self.__process is not None and self.__process.stderr is not None + stderr = self.__process.stderr + try: + for line in iter(stderr.readline, b""): + if self.__stderr_stop.is_set(): + break + decoded = line.decode("utf-8", errors="ignore") + match = _SHOWINFO_REGEX.search(decoded) + if not match: + continue + meta = { + "frame_num": int(match.group(1)), + "pts_time": float(match.group(2)), + "is_keyframe": bool(int(match.group(3))), + "frame_type": match.group(4), + } + self.__metadata_queue.put(meta) + except (ValueError, OSError): + # stderr pipe closed mid-readline during termination + pass + finally: + # sentinel so consumers unblock on EOF + self.__metadata_queue.put(None) + def terminate(self) -> None: """ Safely terminates all processes. @@ -956,6 +1066,7 @@ def terminate(self) -> None: # signal we are closing self.__verbose_logs and logger.debug("Terminating FFdecoder Pipeline...") self.__terminate_stream = True + self.__stderr_stop.set() # check if no process was initiated at first place if self.__process is None or self.__process.poll() is not None: logger.info("Pipeline already terminated.") @@ -965,9 +1076,15 @@ def terminate(self) -> None: self.__process.stdin and self.__process.stdin.close() # close `stdout` output self.__process.stdout and self.__process.stdout.close() + # close `stderr` so the background reader thread's blocking readline() unblocks + self.__process.stderr and self.__process.stderr.close() # terminate/kill process if still processing self.__process.poll() is None and self.__process.terminate() # wait if not exiting self.__process.wait() + # join the stderr reader thread so it does not outlive the pipeline + if self.__stderr_thread is not None and self.__stderr_thread.is_alive(): + self.__stderr_thread.join(timeout=2.0) + self.__stderr_thread = None self.__process = None logger.info("Pipeline terminated successfully.") diff --git a/docs/recipes/advanced/extract-frame-metadata.md b/docs/recipes/advanced/extract-frame-metadata.md new file mode 100644 index 00000000..ddbbcb08 --- /dev/null +++ b/docs/recipes/advanced/extract-frame-metadata.md @@ -0,0 +1,141 @@ + + +# :material-timer-sync: Per-Frame Metadata Extraction + +> Each raw numpy frame handed to you by FFdecoder normally loses its temporal context — it's just a matrix of pixels with no notion of _when_ it should appear (PTS) or _how_ it was encoded (Keyframe vs. Predictive frame). The [`-extract_metadata`](../../reference/ffdecoder/params/#exclusive-parameters) exclusive parameter closes that gap: when enabled, [`generateFrame()`](../../reference/ffdecoder/#deffcode.ffdecoder.FFdecoder.generateFrame) yields `(frame, meta)` tuples, where `meta` is a python dict parsed from FFmpeg's [`showinfo`](https://ffmpeg.org/ffmpeg-filters.html#showinfo) filter — emitted on stderr and consumed asynchronously by a background daemon thread so the main `stdout` frame pipe is never throttled. + +The metadata dict contains the following keys: + +- **`frame_num`** _(int)_: monotonic frame index as emitted by FFmpeg. +- **`pts_time`** _(float)_: presentation timestamp in seconds. +- **`is_keyframe`** _(bool)_: `True` if the frame is a keyframe _(I-frame)_. +- **`frame_type`** _(str)_: one of `"I"` _(keyframe)_, `"P"` _(predictive)_, `"B"` _(bi-predictive)_, `"?"` _(unknown)_. + +We'll walk through two flagship optimizations this unlocks in the recipes below. + +  + +!!! warning "DeFFcode APIs requires FFmpeg executable" + + ==DeFFcode APIs **MUST** requires valid FFmpeg executable for all of its core functionality==, and any failure in detection will raise `RuntimeError` immediately. Follow dedicated [FFmpeg Installation doc ➶](../../../installation/ffmpeg_install/) for its installation. + +!!! warning "Incompatible with `-filter_complex`" + + `-extract_metadata` cannot be combined with the `-filter_complex` attribute (graph-label routing is ambiguous). If both are supplied, a warning is logged and metadata extraction is silently disabled. A pre-existing `-vf` is fine — `showinfo` is automatically comma-chained onto it. + +??? danger "Never name your python script `deffcode.py`" + + When trying out these recipes, never name your python script `deffcode.py` otherwise it will result in `ModuleNotFound` error. + +  + +## Smart Keyframe-only decoding for heavy AI inference + +> Many Computer Vision workflows — perceptual hashing, scene-change detection, video summarisation, heavyweight AI-model inference _(YOLO, ResNet, etc.)_ — only really care about **Keyframes (I-frames)**. On a 60 FPS source with a typical GOP size, that's ~1-2 frames per second worth looking at. Without `-extract_metadata` you'd still decode and run your model on every single P/B frame and waste 98%+ of your compute on nearly-identical predictive frames. + +With `meta["is_keyframe"]` in hand, you can skip those frames entirely: + +```python +# import the necessary packages +from deffcode import FFdecoder + +# instantiate the decoder with per-frame metadata extraction enabled +decoder = FFdecoder( + "foo.mp4", + frame_format="bgr24", + **{"-extract_metadata": True}, +).formulate() + +# grab (frame, meta) pairs from the generator +for frame, meta in decoder.generateFrame(): + + # check if frame is None + if frame is None: + break + + # OPTIMIZATION: skip processing entirely if it is not a keyframe + if not meta["is_keyframe"]: + continue + + # now run your heavy AI model on ~1-2 frames per second only + results = heavy_ai_model.predict(frame) + +# terminate the decoder +decoder.terminate() +``` + +!!! success "Depending on the source's GOP (Group-of-Pictures) size, this pattern reduces downstream processing time by 10–50× without skipping any scene-boundary information." + +  + +## Variable-Frame-Rate (VFR) synchronization via `pts_time` + +> Most modern video sources — smartphones, screen recordings, webcams, browser captures — are **Variable-Frame-Rate**. The gap between frame 1 and 2 might be 16 ms while the gap between frame 2 and 3 is 40 ms. If you are measuring motion for sports analytics, computing velocity vectors, or keeping OpenCV bounding boxes synchronised with an audio track, _assuming a constant frame rate will drift out of sync very quickly_. + +With `meta["pts_time"]` you know the **exact presentation timestamp** of every frame: + +```python +# import the necessary packages +from deffcode import FFdecoder + +# instantiate decoder for a VFR source +decoder = FFdecoder( + "screen_recording.mp4", + frame_format="bgr24", + **{"-extract_metadata": True}, +).formulate() + +prev_pts = None +for frame, meta in decoder.generateFrame(): + if frame is None: + break + + # exact presentation timestamp in seconds + pts = meta["pts_time"] + + # compute real inter-frame delta (not the nominal 1/fps value) + delta_ms = None if prev_pts is None else (pts - prev_pts) * 1000.0 + prev_pts = pts + + # use real delta for per-frame motion/velocity calculations + # e.g. velocity = displacement_px / delta_ms + +# terminate the decoder +decoder.terminate() +``` + +!!! tip "The same `pts_time` stream is what you need to keep processed frames locked to an audio track when re-muxing downstream." + +  + +## Implementation notes + +- The `showinfo` filter is appended _(not overwritten)_ to any user-supplied `-vf` filter via comma-concatenation, so your existing filter graph is preserved. +- FFmpeg's stderr is captured with `subprocess.PIPE` regardless of the `verbose` flag — otherwise a verbose pipeline would let stderr leak to the parent tty and starve the metadata reader. +- The background reader thread is a **daemon**; on [`terminate()`](../../reference/ffdecoder/#deffcode.ffdecoder.FFdecoder.terminate) the stderr pipe is closed, a stop-event is signalled, and the thread is joined with a 2-second timeout so no pipeline ever outlives the decoder object. +- `metadata_queue.get()` uses a bounded 10-second timeout. If `showinfo` ever stops emitting lines (e.g. an exotic filter chain drops frames), the consumer logs a warning and yields the frame with `meta=None` rather than deadlocking. + +  + + +[ffmpeg]:https://www.ffmpeg.org/ diff --git a/docs/recipes/advanced/index.md b/docs/recipes/advanced/index.md index bafccc7e..08cabea1 100644 --- a/docs/recipes/advanced/index.md +++ b/docs/recipes/advanced/index.md @@ -82,6 +82,9 @@ The following challenging recipes will take your skills to the next level and wi - [x] **[:material-cog-refresh: Updating Video Metadata](../advanced/update-metadata/#updating-video-metadata)** - [Added new attributes to metadata in FFdecoder API](../advanced/update-metadata/#added-new-attributes-to-metadata-in-ffdecoder-api) - [Overriding source video metadata in FFdecoder API](../advanced/update-metadata/#overriding-source-video-metadata-in-ffdecoder-api) +- [x] **[:material-timer-sync: Per-Frame Metadata Extraction](../advanced/extract-frame-metadata/#per-frame-metadata-extraction)** + - [Smart Keyframe-only decoding for heavy AI inference](../advanced/extract-frame-metadata/#smart-keyframe-only-decoding-for-heavy-ai-inference) + - [Variable-Frame-Rate (VFR) synchronization via `pts_time`](../advanced/extract-frame-metadata/#variable-frame-rate-vfr-synchronization-via-pts_time)   diff --git a/docs/reference/ffdecoder/params.md b/docs/reference/ffdecoder/params.md index 91ba4808..385f83a3 100644 --- a/docs/reference/ffdecoder/params.md +++ b/docs/reference/ffdecoder/params.md @@ -742,6 +742,37 @@ These parameters are discussed below:   +* **`-extract_metadata`** _(bool)_: This attribute can be enabled(`True`) to activate **asynchronous per-frame metadata extraction** via FFmpeg's [`showinfo`](https://ffmpeg.org/ffmpeg-filters.html#showinfo) filter. When enabled, the [`generateFrame()`](../../../reference/ffdecoder/#deffcode.ffdecoder.FFdecoder.generateFrame) generator yields `(frame, metadata)` tuples instead of plain ndarrays, where `metadata` is a dict with the following keys: + + - **`frame_num`** _(int)_: monotonic frame index as emitted by FFmpeg. + - **`pts_time`** _(float)_: presentation timestamp in seconds — the exact millisecond the frame is meant to appear, crucial for VFR (Variable-Frame-Rate) sources. + - **`is_keyframe`** _(bool)_: `True` if the frame is a keyframe (I-frame). + - **`frame_type`** _(str)_: one of `"I"` _(keyframe)_, `"P"` _(predictive)_, `"B"` _(bi-predictive)_, or `"?"` _(unknown)_. + + A background daemon thread parses `showinfo` lines off FFmpeg's stderr and feeds them into a thread-safe queue, so the main `stdout` frame pipe is never throttled. It can be used as follows: + + !!! warning "This flag is **incompatible with `-filter_complex`** (graph-label routing is ambiguous). If both are supplied, a warning is logged and `-extract_metadata` is disabled for that pipeline. A pre-existing `-vf` filter **is preserved** — `showinfo` is comma-chained onto it automatically." + + !!! tip "Enables **Smart Keyframe Extraction**: for workflows like perceptual hashing, scene-change detection, or heavy AI-model inference (YOLO, ResNet, etc.) that only need I-frames, you can skip P/B frames entirely and reduce downstream compute by 10–50×, depending on the source's GOP size." + + ```python + # define suitable parameter + ffparams = {"-extract_metadata": True} # yields (frame, meta) tuples + ``` + + Example: skip every non-keyframe for heavy AI inference. + + ```python + decoder = FFdecoder("input.mp4", **{"-extract_metadata": True}).formulate() + + for frame, meta in decoder.generateFrame(): + if not meta["is_keyframe"]: + continue + results = heavy_ai_model.predict(frame) # runs on ~1-2 frames per second + ``` + +  + * **`-disable_ffmpeg_window`** _(bool)_: This attribute can be used to prevent the FFmpeg command line window from appearing when using the FFdecoder API on Windows. This is especially useful when creating an `.exe` file for your Python script with logging disabled(`verbose=False`), as it stops the FFmpeg window from popping up even in windowed or no-console mode. Its usage is as follows: !!! warning "The `-disable_ffmpeg_window` flag is only available on :fontawesome-brands-windows: Windows OS with logging disabled." diff --git a/mkdocs.yml b/mkdocs.yml index b53db3ba..719ff6a9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -224,6 +224,7 @@ nav: - Transcoding Video Art with Filtergraphs: recipes/advanced/transcode-art-filtergraphs.md - Hardware-Accelerated Video Transcoding: recipes/advanced/transcode-hw-acceleration.md - Updating Video Metadata: recipes/advanced/update-metadata.md + - Per-Frame Metadata Extraction: recipes/advanced/extract-frame-metadata.md - API References: - deffcode.FFdecoder: - API: reference/ffdecoder/index.md diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index de82569e..526fde96 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -281,6 +281,149 @@ def test_extract_luma(pixfmt: str) -> None: decoder is not None and decoder.terminate() +def test_extract_metadata_basic() -> None: + """ + Validates the `-extract_metadata` asynchronous showinfo parser: when + enabled, `generateFrame()` must yield `(frame, meta)` tuples with the + documented metadata keys and sensible values for a CFR source. + """ + decoder = None + source = return_testvideo_path(fmt="vo") + _, actual_shape = actual_frame_count_n_frame_size(source) + try: + decoder = FFdecoder( + source, + frame_format="bgr24", + custom_ffmpeg=return_static_ffmpeg(), + verbose=True, + **{"-extract_metadata": True}, + ).formulate() + + expected_keys = {"frame_num", "pts_time", "is_keyframe", "frame_type"} + prev_frame_num = -1 + frames_checked = 0 + for pair in decoder.generateFrame(): + assert isinstance(pair, tuple) and len(pair) == 2, ( + "Test failed - expected (frame, meta) tuple when `-extract_metadata` is enabled" + ) + frame, meta = pair + assert frame is not None and frame.shape == actual_shape, ( + f"Test failed - frame shape {None if frame is None else frame.shape}, " + f"expected {actual_shape}" + ) + assert isinstance(meta, dict), "Test failed - metadata must be a dict" + assert expected_keys.issubset(meta.keys()), ( + f"Test failed - missing metadata keys, got {list(meta.keys())}" + ) + assert meta["frame_num"] == prev_frame_num + 1, ( + f"Test failed - non-monotonic frame_num {meta['frame_num']} after {prev_frame_num}" + ) + assert meta["pts_time"] >= 0.0, "Test failed - negative pts_time" + assert meta["frame_type"] in {"I", "P", "B", "?"}, ( + f"Test failed - unexpected frame_type `{meta['frame_type']}`" + ) + prev_frame_num = meta["frame_num"] + frames_checked += 1 + if frames_checked >= 5: + break + assert frames_checked > 0, "Test failed - generator yielded no frames" + assert prev_frame_num == 0 or any( + True for _ in [0] + ), "sanity: loop must have executed" + except Exception as e: + pytest.fail(str(e)) + finally: + decoder is not None and decoder.terminate() + + +def test_extract_metadata_preserves_user_vf() -> None: + """ + A user-supplied `-vf` filter must be preserved by comma-chaining + `showinfo` onto the filter graph rather than overwriting it. + """ + decoder = None + source = return_testvideo_path(fmt="vo") + try: + decoder = FFdecoder( + source, + frame_format="bgr24", + custom_ffmpeg=return_static_ffmpeg(), + **{"-extract_metadata": True, "-vf": "scale=160:120"}, + ).formulate() + + frame, meta = next(decoder.generateFrame(), (None, None)) + assert frame is not None, "Test failed - no frame retrieved" + # scale filter must have survived alongside showinfo + assert frame.shape == (120, 160, 3), ( + f"Test failed - user `-vf scale=160:120` was not preserved, shape={frame.shape}" + ) + assert isinstance(meta, dict) and "frame_num" in meta, ( + "Test failed - metadata not produced when chaining with user -vf" + ) + except Exception as e: + pytest.fail(str(e)) + finally: + decoder is not None and decoder.terminate() + + +def test_extract_metadata_invalid_type() -> None: + """ + Non-bool `-extract_metadata` values must be discarded silently and the + decoder should fall back to yielding plain ndarray frames (no tuple). + """ + decoder = None + source = return_testvideo_path(fmt="vo") + _, actual_shape = actual_frame_count_n_frame_size(source) + try: + decoder = FFdecoder( + source, + frame_format="bgr24", + custom_ffmpeg=return_static_ffmpeg(), + **{"-extract_metadata": "yes"}, # invalid, must be coerced to False + ).formulate() + frame = next(decoder.generateFrame(), None) + assert frame is not None, "Test failed - no frame retrieved" + assert not isinstance(frame, tuple), ( + "Test failed - invalid `-extract_metadata` value should not enable tuple output" + ) + assert frame.shape == actual_shape + except Exception as e: + pytest.fail(str(e)) + finally: + decoder is not None and decoder.terminate() + + +def test_extract_metadata_filter_complex_disables() -> None: + """ + `-extract_metadata` cannot coexist with `-filter_complex` (graph-label + routing is ambiguous). The decoder must warn and fall back to plain + ndarray frames rather than emitting tuples. + """ + decoder = None + source = return_testvideo_path(fmt="vo") + try: + decoder = FFdecoder( + source, + frame_format="bgr24", + custom_ffmpeg=return_static_ffmpeg(), + **{ + "-extract_metadata": True, + "-filter_complex": "[0:v]scale=160:120[out]", + }, + ).formulate() + frame = next(decoder.generateFrame(), None) + # decoder should fall back to plain ndarray output (not tuple) + assert frame is None or not isinstance(frame, tuple), ( + "Test failed - `-extract_metadata` should be disabled when `-filter_complex` is set" + ) + except Exception as e: + # some FFmpeg builds may reject the exact filter_complex above; that's + # fine — the only contract under test is "no tuple output" + logger.info(f"filter_complex path errored as expected: {e}") + finally: + decoder is not None and decoder.terminate() + + def test_extract_luma_invalid_type() -> None: """ Non-bool `-extract_luma` values must be discarded silently and the decoder From 49cfe005119ebd244e8558aad47f116741373964 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 12:21:42 +0530 Subject: [PATCH 35/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20keyframe=20d?= =?UTF-8?q?ecoding=20and=20VFR=20sync=20recipe=20links=20to=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9f3baf73..9ed98038 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,8 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][ - [Added new attributes to metadata in FFdecoder API][added-new-attributes-to-metadata-in-ffdecoder-api] - [Overriding source video metadata in FFdecoder API][overriding-source-video-metadata-in-ffdecoder-api] +- [Smart Keyframe-only decoding for heavy AI inference][smart-keyframe-only-decoding-for-heavy-ai-inference] +- [Variable-Frame-Rate (VFR) synchronization via pts_time][variable-frame-rate-vfr-synchronization-via-pts_time] @@ -431,6 +433,8 @@ Advanced Recipes [cuda-nvenc-accelerated-end-to-end-lossless-video-transcoding-with-writegear-api]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/transcode-hw-acceleration/#cuda-nvenc-accelerated-end-to-end-lossless-video-transcoding-with-writegear-api [added-new-attributes-to-metadata-in-ffdecoder-api]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/update-metadata/#added-new-attributes-to-metadata-in-ffdecoder-api [overriding-source-video-metadata-in-ffdecoder-api]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/update-metadata/#overriding-source-video-metadata-in-ffdecoder-api +[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 +[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 -# Submitting Pull Request(PR) Guidelines: +# Submitting Pull Request (PR) Guidelines +These guidelines outline how to submit a high-quality Pull Request (PR) to **DeFFcode**. -The following guidelines tells you how to submit a valid PR for DeFFcode: +## :material-rocket-launch: Before You Start -!!! question "Working on your first Pull Request for DeFFcode?" +??? question "First time contributing to DeFFcode?" - * You can learn about "**How to contribute to an Open Source Project on GitHub**" from [this doc ➶](https://opensource.guide/how-to-contribute/) - * If you're stuck at something, please join our [Gitter community channel](https://gitter.im/DeFFcode/community). We will help you get started! + - Learn how open-source contributions work from [this guide ➶](https://opensource.guide/how-to-contribute/) + - Need help? Join our [Gitter community](https://gitter.im/DeFFcode/community) and we’ll assist you -  +  -## Clone branch for PR +## :material-source-branch-plus: Create a Working Branch -You can clone your [**Forked**](https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo) remote git to local and create your PR working branch as a sub-branch of latest [`master`](https://github.com/abhiTronix/deffcode/tree/master) branch as follows: +Start by cloning your fork and creating a feature branch from the latest `master`: -!!! alert "Make sure the [`master`](https://github.com/abhiTronix/deffcode/tree/master) branch of your Forked repository is up-to-date with DeFFcode, before starting working on a Pull Request." +!!! danger "Keep your fork up to date" + + Ensure your fork’s `master` branch is synced with the upstream repository before starting. ```sh -# clone your forked repository(change with your username) and get inside -git clone https://github.com/{YOUR USERNAME}/DeFFcode.git && cd DeFFcode +# Clone your fork (replace with your username) +git clone https://github.com/{YOUR_USERNAME}/DeFFcode.git +cd DeFFcode -# pull any recent updates +# Sync latest changes git pull -# Now create your new branch with suitable name(such as "subbranch_of_master") -git checkout -b subbranch_of_master +# Create a new branch +git checkout -b feature/your-branch-name ``` -Now after working with this newly created branch for your Pull Request, you can commit and push or merge it locally or remotely as usual. +Work on this branch and push changes as usual. -  +  -  +## :octicons-checklist-24: PR Submission Checklist -## PR Submission Checklist +### 1. Open an Issue First -There are some important checks you need to perform while submitting your Pull Request(s) for DeFFcode library: +* Start by creating an issue using the [proposal template](https://github.com/abhiTronix/deffcode/issues/new?labels=issue%3A+proposal&template=proposal.md) +* This helps align your work with project goals and avoids duplicate effort -- [x] **Submit a Related Issue:** - - * The first thing you do is submit an issue with a [proposal template](https://github.com/abhiTronix/deffcode/issues/new?labels=issue%3A+proposal&template=proposal.md) for your work first and then work on your Pull Request. +### 2. Open a Draft PR Early +* Create a **draft PR** from the beginning of your work +* Add: + * A clear and descriptive title + * Summary of what the PR fixes/adds/improves + * Screenshots or outputs (if applicable) +* For bug fixes: + * Include a **failing test case** that your fix resolves +* Ensure all CI checks pass +* Mark as **Ready for Review** once complete -- [x] **Submit a Draft Pull Request:** +### 3. Test, Format & Lint Locally - * Submit the [draft pull request](https://github.blog/2019-02-14-introducing-draft-pull-requests/) from the first day of your development. - * Add a brief but descriptive title for your PR. - * Explain what the PR adds, fixes, or improves. - * In case of bug fixes, add a new unit test case that would fail against your bug fix. - * Provide output or screenshots, if you can. - * Make sure your pull request passed all the CI checks _(triggers automatically on pushing commits against `master` branch)_. If it's somehow failing, then ask the maintainer for a review. - * Click "**ready for review**" when finished. +* Run tests and ensure everything passes +* Format and lint your code before committing +* See [Testing & Linting](#testing-formatting-linting) section below -- [x] **Test, Format & lint code locally:** +### 4. Write Clear Commit Messages - * Make sure to test, format, and lint the modified code locally before every commit. The details are discussed [below ➶](#formatting-linting) +* Keep messages concise and meaningful +* Link issues using keywords like `#!sh resolves #123` +* Use `git commit --amend` to refine commits when needed -- [x] **Make sensible commit messages:** +### 5. Perform Integrity Checks - * If your pull request fixes a separate issue number, remember to include `"resolves #issue_number"` in the commit message. Learn more about it [here ➶](https://help.github.com/articles/closing-issues-using-keywords/). - * Keep the commit message concisely as much as possible at every submit. You can make a supplement to the previous commit with `git commit --amend` command. +!!! warning "Duplicate PRs will be rejected" -- [x] **Perform Integrity Checks:** +* Check for existing related PRs/issues +* Ensure your changes align with DeFFcode’s design and goals +* By contributing, you agree your code will be licensed under the [Apache 2.0 License ➶](https://github.com/abhiTronix/deffcode/blob/master/LICENSE) - !!! warning "Any duplicate pull request will be Rejected!" +### 6. Link Your Issue - * Search GitHub if there's a similar open or closed PR that relates to your submission. - * Check if your purpose code matches the overall direction of the DeFFcode APIs and improves it. - * Retain copyright for your contributions, but also agree to license them for usage by the project and author(s) under the [**Apache 2.0 license ➶**](https://github.com/abhiTronix/deffcode/blob/master/LICENSE). +!!! tip -- [x] **Link your Issues:** + Learn more about linking PRs to issues [here ➶](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) - !!! tip "For more information on Linking a pull request to an issue, See [this doc➶](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue)" +* Link your PR to the relevant issue +* This helps track progress and avoid duplication - * Finally, when you're confident enough, make your pull request public. - * You can link an issue to a pull request manually or using a supported keyword in the pull request description. It helps collaborators see that someone is working on the issue. For more information, see [this doc➶](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) +  -  +## :material-test-tube: Testing, Formatting & Linting -  +All PRs must pass testing and code quality checks. -## Testing, Formatting & Linting +### Requirements -All Pull Request(s) must be tested, formatted & linted against our library standards as discussed below: +!!! info "Python 3.10+ required" -### Requirements +Install dependencies: -Testing DeFFcode requires additional test dependencies and dataset, which can be handled manually as follows: +```sh +# Install OpenCV (if not already installed) +pip install opencv-python + +# Install remaining dependencies +pip install --upgrade ruff pytest vidgear[core] +``` -- [x] **Install additional python libraries:** - - You can easily install these dependencies via pip: +### Test Dataset Setup + +Download required test data: + +=== "Linux :material-linux:/macOS :material-apple:" ```sh - # Install opencv(only if not installed previously) - $ pip install opencv-python + chmod +x scripts/bash/prepare_dataset.sh + ./scripts/bash/prepare_dataset.sh + ``` - # install rest of dependencies - $ pip install --upgrade flake8 black pytest vidgear[core] +=== "Windows :material-microsoft-windows:" + + ```sh + sh scripts/bash/prepare_dataset.sh ``` -- [x] **Download Tests Dataset:** +### Run Tests - To perform tests, you also need to download additional dataset *(to your temp dir)* by running [`prepare_dataset.sh`](https://github.com/abhiTronix/deffcode/blob/master/scripts/bash/prepare_dataset.sh) bash script as follows: +From the project root: - === "On Linux/MacOS" +```sh +pytest -sv +``` - ```sh - $ chmod +x scripts/bash/prepare_dataset.sh - $ ./scripts/bash/prepare_dataset.sh - ``` +  - === "On Windows" +### Formatting & Linting (Ruff) - ```sh - $ sh scripts/bash/prepare_dataset.sh - ``` +DeFFcode uses **[Ruff](https://docs.astral.sh/ruff/)** for both linting and formatting. -### Running Tests +#### Lint Code -All tests can be run with [`pytest`](https://docs.pytest.org/en/stable/)(*in DeFFcode's root folder*) as follows: +```sh +# Check for issues +ruff check {path} - ```sh - $ pytest -sv #-sv for verbose output. - ``` +# Auto-fix issues +ruff check --fix {path} +``` -### Formatting & Linting +#### Format Code -For formatting and linting, following libraries are used: +```sh +# Apply formatting +ruff format {path} -- [x] **Flake8:** You must run [`flake8`](https://flake8.pycqa.org/en/latest/manpage.html) linting for checking the code base against the coding style (PEP8), programming errors and other cyclomatic complexity: +# Check formatting only +ruff format --check {path} +``` - ```sh - $ flake8 {source_file_or_directory} --count --select=E9,F63,F7,F82 --show-source --statistics - ``` +!!! tip "These checks run in CI—running them locally saves time during review." -- [x] **Black:** DeFFcode follows [`black`](https://github.com/psf/black) formatting to make code review faster by producing the smallest diffs possible. You must run it with sensible defaults as follows: - ```sh - $ black {source_file_or_directory} - ``` +  -  +## :material-chat-question: Frequently Asked Questions -  +### Q1. Why is my PR taking time to be reviewed? -## Frequently Asked Questions +!!! success "After your PR is merged" + * You can delete your branch safely + * Changes are first merged into `dev`, then into `master` during release + * Active contributors may receive faster reviews over time -**Q1. Why do my changes taking so long to be Reviewed and/or Merged?** +PRs are reviewed by maintainers based on priority and availability. You may be asked to make changes before approval. -!!! success "Submission Aftermaths" - * After your PR is merged, you can safely delete your branch and pull the changes from the main (upstream) repository. - * The changes will remain in `dev` branch until next DeFFcode version is released, then it will be merged into `master` branch. - * After a successful Merge, your newer contributions will be given priority over others. +### Q2. Can I submit a large PR? -Pull requests will be reviewed by the maintainers and the rationale behind the maintainer’s decision to accept or deny the changes will be posted in the pull request. Please wait for our code review and approval, possibly enhancing your change on request. +* Yes—but ensure changes are **focused and related** +* For major changes: + - [x] Open an issue first for discussion + - [x] Large, unrelated changes should be split into smaller PRs +This helps speed up review and increases the chances of acceptance. -**Q2. Would you accept a huge Pull Request with Lots of Changes?** -First, make sure that the changes are somewhat related. Otherwise, please create separate pull requests. Anyway, before submitting a huge change, it's probably a good idea to [open an issue](../../contribution/issue) in the DeFFcode Github repository to ask the maintainers if they agree with your proposed changes. Otherwise, they could refuse your proposal after you put all that hard work into making the changes. We definitely don't want you to waste your time! +Thanks for contributing to **DeFFcode** 🚀 -  \ No newline at end of file +  \ No newline at end of file diff --git a/docs/contribution/issue.md b/docs/contribution/issue.md index 56033472..3e13c645 100644 --- a/docs/contribution/issue.md +++ b/docs/contribution/issue.md @@ -20,38 +20,68 @@ limitations under the License. # Submitting an Issue Guidelines -If you've found a new bug or you've come up with some new feature which can improve the quality of the DeFFcode, then related issues are welcomed! But, Before you do, please read the following guidelines: +If you've discovered a bug or have an idea that could improve **DeFFcode**, we’d love to hear from you. Before opening an issue, please review the guidelines below—they help us triage faster and resolve issues more efficiently. -??? question "First Issue on GitHub?" +## :material-rocket-launch: Before You Start + +??? question "First issue on GitHub?" + + You can learn how to create one from GitHub’s official guide on [creating an issue](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue). + +!!! info + + Issues can usually be resolved much faster when they include clear reproduction steps, environment details, and a small demo. - You can easily learn about it from [creating an issue](https://help.github.com/en/github/managing-your-work-on-github/creating-an-issue) wiki. + If you're short on time, feel free to submit a brief report—but please note that incomplete reports may take longer to investigate. + +  + +## :material-tab-search: Search the Documentation and Existing Issues + +Before opening a new issue, please check the following first: + +- [x] Search for an existing [open or closed issue](https://github.com/abhiTronix/deffcode/issues?q=is%3Aissue) that matches your problem. +- [x] Review the [FAQ & Troubleshooting section](../../help/get_help/#frequently-asked-questions). +- [x] For quick questions, use our [Gitter community](https://gitter.im/deffcode-python/community) instead of opening an issue. + +You may find that your question has already been answered or that a workaround already exists. + +  + +## :material-folder-edit: Gather Required Information -!!! Info +Please include the following information with your report whenever possible: - Please note that your issue will be fixed much faster if you spend about half an hour preparing it, including the exact reproduction steps and a demo. If you're in a hurry or don't feel confident, it's fine to report issues with less details, but this makes it less likely they'll get fixed soon. +- [x] Enable the `verbose=True` flag in the relevant API to collect debug logs. +- [x] Provide a **minimal reproducible example** that demonstrates the issue. +- [x] Include the installed DeFFcode version using command: `#!sh python -c "import deffcode; print(deffcode.__version__)"` and also: + * Python version + * Operating system + * FFmpeg version (`ffmpeg -version`) -### Search the Docs and Previous Issues +  - * Remember to first search GitHub for a [open or closed issue](https://github.com/abhiTronix/deffcode/issues?q=is%3Aissue) that relates to your submission or already been reported. You may find related information and the discussion might inform you of workarounds that may help to resolve the issue. - * For quick questions, please refrain from opening an issue, as you can reach us on [Gitter](https://gitter.im/deffcode-python/community) community channel. - * Also, go comprehensively through our dedicated [FAQ & Troubleshooting section](../../help/get_help/#frequently-asked-questions). +## :octicons-repo-template-24: Follow the Issue Template -### Gather Required Information +- [x] Select the correct issue template before submitting. +- [x] Complete all relevant sections in the template. +- [x] Reports with insufficient information may be marked **Invalid ⛔** +- [x] If no follow-up details are provided, the issue may be closed. -* All DeFFcode APIs provides a `verbose` boolean flag in parameters, to log debugged output to terminal. Kindly turn this parameter `True` in the respective API for getting debug output, and paste it with your Issue. -* In order to reproduce bugs we will systematically ask you to provide a minimal reproduction code for your report. -* Check and paste, exact DeFFcode version by running command `#!python python -c "import deffcode; print(deffcode.__version__)"`. +  -### Follow the Issue Template +## :fontawesome-solid-fist-raised: Raise the Issue -* Please format your issue by choosing the appropriate template. -* Any improper/insufficient reports will be marked **Invalid ⛔**, and if we don't hear back from you we may close the issue. +Before submitting: -### Raise the Issue +- [x] Write a short but descriptive title +- [x] Keep the report focused on one issue +- [x] Attach relevant logs, screenshots, or source code when available -* Add a brief but descriptive title for your issue. -* Keep the issue phrasing in context of the problem. -* Attach source-code/screenshots if you have one. -* Finally, raise it by choosing the appropriate Issue Template: [**Bug report 🐞**](https://github.com/abhiTronix/deffcode/issues/new?assignees=abhiTronix&labels=Bug+%3Alady_beetle%3A%2CNeeds+Triage+%3Amonocle_face%3A&template=bug_report.yaml&title=%5BBug%5D%3A+), [Idea 💡](https://github.com/abhiTronix/deffcode/issues/new?assignees=&labels=Idea+%3Abulb%3A&template=idea.yaml&title=%5BIdea%5D%3A+), [Question ❔](https://github.com/abhiTronix/deffcode/issues/new?assignees=&labels=Question+%3Agrey_question%3A&template=question.yaml&title=%5BQuestion%5D%3A+). +Choose the appropriate template below: -  \ No newline at end of file +* [**Bug Report 🐞**](https://github.com/abhiTronix/deffcode/issues/new?assignees=abhiTronix&labels=Bug+%3Alady_beetle%3A%2CNeeds+Triage+%3Amonocle_face%3A&template=bug_report.yaml&title=%5BBug%5D%3A+) +* [**Feature Idea 💡**](https://github.com/abhiTronix/deffcode/issues/new?assignees=&labels=Idea+%3Abulb%3A&template=idea.yaml&title=%5BIdea%5D%3A+) +* [**Question ❔**](https://github.com/abhiTronix/deffcode/issues/new?assignees=&labels=Question+%3Agrey_question%3A&template=question.yaml&title=%5BQuestion%5D%3A+) + +  \ No newline at end of file From ce7cf2bfd7dae4f893fce59935abce79145c3ef2 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 16:23:30 +0530 Subject: [PATCH 42/57] =?UTF-8?q?=F0=9F=94=A5docs:=20remove=20duplicate=20?= =?UTF-8?q?pymdownx.magiclink=20extension=20from=20mkdocs=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mkdocs.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 719ff6a9..431b882a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -181,11 +181,6 @@ markdown_extensions: - pymdownx.tilde - pymdownx.striphtml: strip_comments: true - - pymdownx.magiclink: - normalize_issue_symbols: true - repo_url_shorthand: true - user: abhiTronix - repo: deffcode exclude_docs: | overrides/assets/README.md From 19a9c6a469bcccf84215cfee4e8b444809e30ba5 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 16:28:42 +0530 Subject: [PATCH 43/57] =?UTF-8?q?=F0=9F=91=B7=20ci:=20remove=20sudo=20and?= =?UTF-8?q?=20use=20python=20-m=20pip=20in=20Linux=20CI=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CIlinux.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/CIlinux.yml b/.github/workflows/CIlinux.yml index 468addae..7180b4ac 100644 --- a/.github/workflows/CIlinux.yml +++ b/.github/workflows/CIlinux.yml @@ -57,21 +57,21 @@ jobs: - name: Prepare Bash scripts run: | dos2unix scripts/bash/prepare_dataset.sh - sudo chmod +x scripts/bash/prepare_dataset.sh + chmod +x scripts/bash/prepare_dataset.sh - name: Install Pip Dependencies run: | - sudo pip install -U numpy - sudo pip install -U . - sudo pip install -U opencv-python-headless - sudo pip install -U vidgear[core] - sudo pip install -U ruff six codecov pytest pytest-cov + python -m pip install -U pip wheel numpy cython + python -m install -U . + python -m install -U opencv-python-headless + python -m install -U vidgear[core] + python -m install -U ruff six codecov pytest pytest-cov if: success() - name: Run prepare_dataset Bash script run: bash scripts/bash/prepare_dataset.sh shell: bash - name: Run pytest and ruff run: | - timeout 1200 sudo python -m pytest -sv --cov=deffcode --cov-report=xml --cov-report term-missing tests/ || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; else echo "EXIT_CODE=$code" >>$GITHUB_ENV; fi + timeout 1200 python -m pytest -sv --cov=deffcode --cov-report=xml --cov-report term-missing tests/ || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; else echo "EXIT_CODE=$code" >>$GITHUB_ENV; fi ruff check . ruff format --check . if: success() From d166ba54045bc4938f8ed321d22c1292183ab1e8 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 16:30:22 +0530 Subject: [PATCH 44/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20chore:=20add=20readm?= =?UTF-8?q?e=20to=20dynamic=20fields=20in=20pyproject.toml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a0d64134..4b7ccc5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "colorlog", "tqdm", ] -dynamic = ["version"] +dynamic = ["version", "readme"] [project.urls] Homepage = "https://abhitronix.github.io/deffcode" From 88e1cbc16b74c78ecf472238a329c785177366bb Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 16:30:47 +0530 Subject: [PATCH 45/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20fix=20macOS=20capi?= =?UTF-8?q?talization=20and=20add=20OS=20icons=20to=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/installation/index.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/installation/index.md b/docs/installation/index.md index 919827d9..97d1d84d 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -33,7 +33,7 @@ DeFFcode is well-tested and supported on the following systems(but not limited t * Any :material-linux: Linux distro released in 2016 or later * :fontawesome-brands-windows: Windows 7 or later -* :material-apple: MacOS 10.12.6 (Sierra) or later +* :material-apple: macOS 10.12.6 (Sierra) or later   @@ -78,14 +78,14 @@ When installing DeFFcode, [**FFmpeg**][ffmpeg] is the only prerequisites you nee * Download the script, from https://bootstrap.pypa.io/get-pip.py. * Open a terminal/command prompt, `cd` to the folder containing the `get-pip.py` file and run: - === "Linux/MacOS" + === "Linux :material-linux:/macOS :material-apple:" ```sh python get-pip.py ``` - === "Windows" + === "Windows :material-microsoft-windows:" ```sh py get-pip.py From 0778d575998b9d03bb6e1aaafbab9501aabe7a8d Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 16:33:21 +0530 Subject: [PATCH 46/57] =?UTF-8?q?=F0=9F=92=9A=20fix(ci):=20correct=20pip?= =?UTF-8?q?=20install=20commands=20in=20Linux=20CI=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CIlinux.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/CIlinux.yml b/.github/workflows/CIlinux.yml index 7180b4ac..d18d4d44 100644 --- a/.github/workflows/CIlinux.yml +++ b/.github/workflows/CIlinux.yml @@ -61,10 +61,10 @@ jobs: - name: Install Pip Dependencies run: | python -m pip install -U pip wheel numpy cython - python -m install -U . - python -m install -U opencv-python-headless - python -m install -U vidgear[core] - python -m install -U ruff six codecov pytest pytest-cov + python -m pip install -U . + python -m pip install -U opencv-python-headless + python -m pip install -U vidgear[core] + python -m pip install -U ruff six codecov pytest pytest-cov if: success() - name: Run prepare_dataset Bash script run: bash scripts/bash/prepare_dataset.sh From e93be51dc5e500b4f4e4847fcde63fa20ce98cbf Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 16:38:44 +0530 Subject: [PATCH 47/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20ci:=20use=20sudo=20f?= =?UTF-8?q?or=20pip=20install=20and=20pytest=20in=20Linux=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CIlinux.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/CIlinux.yml b/.github/workflows/CIlinux.yml index d18d4d44..890d5dbf 100644 --- a/.github/workflows/CIlinux.yml +++ b/.github/workflows/CIlinux.yml @@ -57,21 +57,21 @@ jobs: - name: Prepare Bash scripts run: | dos2unix scripts/bash/prepare_dataset.sh - chmod +x scripts/bash/prepare_dataset.sh + sudo chmod +x scripts/bash/prepare_dataset.sh - name: Install Pip Dependencies run: | - python -m pip install -U pip wheel numpy cython - python -m pip install -U . - python -m pip install -U opencv-python-headless - python -m pip install -U vidgear[core] - python -m pip install -U ruff six codecov pytest pytest-cov + sudo pip install -U numpy + sudo pip install -U . + sudo pip install -U opencv-python-headless + sudo pip install -U vidgear[core] + sudo pip install -U ruff six codecov pytest pytest-cov if: success() - name: Run prepare_dataset Bash script run: bash scripts/bash/prepare_dataset.sh shell: bash - name: Run pytest and ruff run: | - timeout 1200 python -m pytest -sv --cov=deffcode --cov-report=xml --cov-report term-missing tests/ || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; else echo "EXIT_CODE=$code" >>$GITHUB_ENV; fi + timeout 1200 sudo pytest -sv --cov=deffcode --cov-report=xml --cov-report term-missing tests/ || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; else echo "EXIT_CODE=$code" >>$GITHUB_ENV; fi ruff check . ruff format --check . if: success() From 3a988047bfc95347fa158631dca1851c4f55cfb6 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 18:22:33 +0530 Subject: [PATCH 48/57] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20reorder?= =?UTF-8?q?=20imports=20and=20move=20regex=20to=20after=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/ffdecoder.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/deffcode/ffdecoder.py b/deffcode/ffdecoder.py index f5fed5e6..c55cadf6 100644 --- a/deffcode/ffdecoder.py +++ b/deffcode/ffdecoder.py @@ -34,21 +34,20 @@ import numpy as np -# regex to parse FFmpeg `showinfo` lines emitted on stderr -# example: "n: 0 pts:0 pts_time:0 ... iskey:1 type:I checksum:..." -_SHOWINFO_REGEX = re.compile( - r"n:\s*(\d+).*?pts_time:\s*([-0-9.]+).*?iskey:(\d).*?type:([IPB?])" -) - +# import utils packages from .ffhelper import ( get_supported_pixfmts, get_supported_vdecoders, ) from .sourcer import Sourcer - -# import utils packages from .utils import dict2Args, logger_handler +# regex to parse FFmpeg `showinfo` lines emitted on stderr +# example: "n: 0 pts:0 pts_time:0 ... iskey:1 type:I checksum:..." +_SHOWINFO_REGEX = re.compile( + r"n:\s*(\d+).*?pts_time:\s*([-0-9.]+).*?iskey:(\d).*?type:([IPB?])" +) + # define FFdecoder logger logger = logging.getLogger("FFdecoder") logger.propagate = False From 7ab00ef84d78163e24e1b6968f7f9d4a70bbae2d Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 18:28:25 +0530 Subject: [PATCH 49/57] =?UTF-8?q?=F0=9F=8E=A8=20style:=20reformat=20code?= =?UTF-8?q?=20for=20line=20length=20compliance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/ffdecoder.py | 174 +++++++++++----------------------------- deffcode/ffhelper.py | 97 +++++++--------------- deffcode/sourcer.py | 141 +++++++++----------------------- deffcode/utils.py | 8 +- setup.py | 4 +- tests/essentials.py | 16 +--- tests/test_ffdecoder.py | 59 +++++--------- tests/test_ffhelper.py | 33 +++----- tests/test_sourcer.py | 23 ++---- tests/test_utils.py | 1 + 10 files changed, 166 insertions(+), 390 deletions(-) diff --git a/deffcode/ffdecoder.py b/deffcode/ffdecoder.py index c55cadf6..a54f9854 100644 --- a/deffcode/ffdecoder.py +++ b/deffcode/ffdecoder.py @@ -44,9 +44,7 @@ # regex to parse FFmpeg `showinfo` lines emitted on stderr # example: "n: 0 pts:0 pts_time:0 ... iskey:1 type:I checksum:..." -_SHOWINFO_REGEX = re.compile( - r"n:\s*(\d+).*?pts_time:\s*([-0-9.]+).*?iskey:(\d).*?type:([IPB?])" -) +_SHOWINFO_REGEX = re.compile(r"n:\s*(\d+).*?pts_time:\s*([-0-9.]+).*?iskey:(\d).*?type:([IPB?])") # define FFdecoder logger logger = logging.getLogger("FFdecoder") @@ -107,9 +105,7 @@ def __init__( """ # enable verbose if specified - self.__verbose_logs = ( - verbose if (verbose and isinstance(verbose, bool)) else False - ) + self.__verbose_logs = verbose if (verbose and isinstance(verbose, bool)) else False # define whether initializing self.__initializing = True @@ -156,8 +152,7 @@ def __init__( self.__extra_params = { str(k).strip(): ( str(v).strip() - if v is not None - and not isinstance(v, (dict, list, int, float, tuple)) + if v is not None and not isinstance(v, (dict, list, int, float, tuple)) else v ) for k, v in ffparams.items() @@ -191,9 +186,7 @@ def __init__( ) # handle video and audio stream indexes in case of multiple ones. - default_stream_indexes = self.__extra_params.pop( - "-default_stream_indexes", (0, 0) - ) + default_stream_indexes = self.__extra_params.pop("-default_stream_indexes", (0, 0)) # reset improper values default_stream_indexes = ( (0, 0) @@ -215,7 +208,7 @@ def __init__( source_demuxer=source_demuxer, verbose=verbose, custom_ffmpeg=custom_ffmpeg if isinstance(custom_ffmpeg, str) else "", - **sourcer_params + **sourcer_params, ) .probe_stream(default_stream_indexes=default_stream_indexes) .retrieve_metadata(force_retrieve_missing=True) @@ -259,12 +252,8 @@ def __init__( # handle disabling window for ffmpeg subprocess on Windows OS # this patch prevents ffmpeg creation window from opening when # building exe files - ffmpeg_window_disabler_patch = self.__extra_params.pop( - "-disable_ffmpeg_window", False - ) - if ffmpeg_window_disabler_patch and isinstance( - ffmpeg_window_disabler_patch, bool - ): + ffmpeg_window_disabler_patch = self.__extra_params.pop("-disable_ffmpeg_window", False) + if ffmpeg_window_disabler_patch and isinstance(ffmpeg_window_disabler_patch, bool): # check if value is valid if self.__machine_OS != "Windows" or self.__verbose_logs: logger.warning( @@ -300,20 +289,15 @@ def __init__( self.__opmode = "vo" else: # raise if unknown mode - raise ValueError( - "Unable to find any usable video stream in the given source!" - ) + raise ValueError("Unable to find any usable video stream in the given source!") # store as metadata - self.__missing_prop["ffdecoder_operational_mode"] = self.__supported_opmodes[ - self.__opmode - ] + self.__missing_prop["ffdecoder_operational_mode"] = self.__supported_opmodes[self.__opmode] # handle user-defined output framerate __framerate = self.__extra_params.pop("-framerate", None) if ( isinstance(__framerate, str) - and __framerate - == "null" # special mode to discard `-framerate/-r` parameter + and __framerate == "null" # special mode to discard `-framerate/-r` parameter ): self.__inputframerate = __framerate elif isinstance(__framerate, (float, int)): @@ -332,17 +316,13 @@ def __init__( self.__custom_resolution = self.__extra_params.pop("-custom_resolution", None) if ( isinstance(self.__custom_resolution, str) - and self.__custom_resolution - == "null" # special mode to discard `-size/-s` parameter + and self.__custom_resolution == "null" # special mode to discard `-size/-s` parameter ) or ( isinstance(self.__custom_resolution, (list, tuple)) - and len(self.__custom_resolution) - == 2 # valid resolution(must be a tuple or list) + and len(self.__custom_resolution) == 2 # valid resolution(must be a tuple or list) ): # log it - self.__verbose_logs and not isinstance( - self.__custom_resolution, str - ) and logger.debug( + self.__verbose_logs and not isinstance(self.__custom_resolution, str) and logger.debug( "Setting raw frames size: `{}`.".format(self.__custom_resolution) ) else: @@ -371,23 +351,17 @@ def formulate(self) -> FFdecoder: supported_vdecodecs = get_supported_vdecoders(self.__ffmpeg) default_vdecodec = ( self.__sourcer_metadata["source_video_decoder"] - if self.__sourcer_metadata["source_video_decoder"] - in supported_vdecodecs + if self.__sourcer_metadata["source_video_decoder"] in supported_vdecodecs else "unknown" ) if "-c:v" in self.__extra_params: - self.__extra_params["-vcodec"] = self.__extra_params.pop( - "-c:v", default_vdecodec - ) + self.__extra_params["-vcodec"] = self.__extra_params.pop("-c:v", default_vdecodec) # handle image sequence separately if self.__opmode == "imgseq": # -vcodec is discarded by default # (This is correct or maybe -vcodec required in some unknown case) [TODO] self.__extra_params.pop("-vcodec", None) - elif ( - "-vcodec" in self.__extra_params - and self.__extra_params["-vcodec"] is None - ): + elif "-vcodec" in self.__extra_params and self.__extra_params["-vcodec"] is None: # special case when -vcodec is not needed intentionally self.__extra_params.pop("-vcodec", None) else: @@ -395,9 +369,7 @@ def formulate(self) -> FFdecoder: if "-vcodec" not in self.__extra_params: input_params["-vcodec"] = default_vdecodec else: - input_params["-vcodec"] = self.__extra_params.pop( - "-vcodec", default_vdecodec - ) + input_params["-vcodec"] = self.__extra_params.pop("-vcodec", default_vdecodec) if ( default_vdecodec != "unknown" and input_params["-vcodec"] not in supported_vdecodecs @@ -418,9 +390,7 @@ def formulate(self) -> FFdecoder: # handle user-defined number of frames. if "-vframes" in self.__extra_params: - self.__extra_params["-frames:v"] = self.__extra_params.pop( - "-vframes", None - ) + self.__extra_params["-frames:v"] = self.__extra_params.pop("-vframes", None) if "-frames:v" in self.__extra_params: value = self.__extra_params.pop("-frames:v", None) if value is not None and value > 0: @@ -451,10 +421,7 @@ def formulate(self) -> FFdecoder: ) # assign output raw-frames pixel format rawframe_pixfmt = None - if ( - self.__frame_format is not None - and self.__frame_format in supported_pixfmts - ): + if self.__frame_format is not None and self.__frame_format in supported_pixfmts: # check if valid and supported `frame_format` parameter assigned rawframe_pixfmt = self.__frame_format.strip() self.__verbose_logs and logger.info( @@ -468,9 +435,7 @@ def formulate(self) -> FFdecoder: and self.__sourcer_metadata["output_frames_pixfmt"] in supported_pixfmts ): # assign if valid and supported - rawframe_pixfmt = self.__sourcer_metadata[ - "output_frames_pixfmt" - ].strip() + rawframe_pixfmt = self.__sourcer_metadata["output_frames_pixfmt"].strip() self.__verbose_logs and logger.info( "FFmpeg filter values will be used for this pipeline for defining output pixel-format." ) @@ -480,16 +445,16 @@ def formulate(self) -> FFdecoder: # log it accordingly if self.__frame_format is None: logger.info( - "Using default `{}` pixel-format for this pipeline.".format( - default_pixfmt - ) + "Using default `{}` pixel-format for this pipeline.".format(default_pixfmt) ) else: logger.warning( "{} Switching to default `{}` pixel-format!".format( ( "Provided FFmpeg does not supports `{}` pixel-format.".format( - self.__sourcer_metadata.get("output_frames_pixfmt", self.__frame_format) + self.__sourcer_metadata.get( + "output_frames_pixfmt", self.__frame_format + ) ) if self.__frame_format != "null" else "No usable pixel-format defined." @@ -500,18 +465,14 @@ def formulate(self) -> FFdecoder: # dynamically calculate raw-frame datatype based on pixel-format selected (self.__raw_frame_depth, rawframesbpp) = next( - (int(x[1]), int(x[2])) - for x in self.__ff_pixfmt_metadata - if x[0] == rawframe_pixfmt + (int(x[1]), int(x[2])) for x in self.__ff_pixfmt_metadata if x[0] == rawframe_pixfmt ) raw_bit_per_component = ( rawframesbpp // self.__raw_frame_depth if self.__raw_frame_depth else 0 ) if 4 <= raw_bit_per_component <= 8: self.__raw_frame_dtype = np.dtype("u1") - elif 8 < raw_bit_per_component <= 16 and rawframe_pixfmt.endswith( - ("le", "be") - ): + elif 8 < raw_bit_per_component <= 16 and rawframe_pixfmt.endswith(("le", "be")): if rawframe_pixfmt.endswith("le"): self.__raw_frame_dtype = np.dtype(" FFdecoder: self.__raw_frame_pixfmt = rawframe_pixfmt # also override as metadata(if available) if "output_frames_pixfmt" in self.__sourcer_metadata: - self.__sourcer_metadata["output_frames_pixfmt"] = ( - self.__raw_frame_pixfmt - ) + self.__sourcer_metadata["output_frames_pixfmt"] = self.__raw_frame_pixfmt # handle raw-frame resolution # notify FFmpeg `-s` parameter cannot be assigned directly @@ -563,24 +522,18 @@ def formulate(self) -> FFdecoder: and len(self.__sourcer_metadata["output_frames_resolution"]) == 2 ): # calculate raw-frame resolution/dimensions based on output. - self.__raw_frame_resolution = self.__sourcer_metadata[ - "output_frames_resolution" - ] + self.__raw_frame_resolution = self.__sourcer_metadata["output_frames_resolution"] elif ( self.__sourcer_metadata["source_video_resolution"] and len(self.__sourcer_metadata["source_video_resolution"]) == 2 ): # calculate raw-frame resolution/dimensions based on source. - self.__raw_frame_resolution = self.__sourcer_metadata[ - "source_video_resolution" - ] + self.__raw_frame_resolution = self.__sourcer_metadata["source_video_resolution"] else: # otherwise raise error raise RuntimeError( "Both source and output metadata values found Invalid with {} `-custom_resolution` attribute. Aborting!".format( - "null" - if isinstance(self.__inputframerate, str) - else "undefined" + "null" if isinstance(self.__inputframerate, str) else "undefined" ) ) # special mode to discard `-size/-s` FFmpeg parameter completely @@ -596,8 +549,7 @@ def formulate(self) -> FFdecoder: output_params["-s"] = str(dimensions) # log if filters or default source is used self.__verbose_logs and ( - self.__custom_resolution is None - or isinstance(self.__custom_resolution, str) + self.__custom_resolution is None or isinstance(self.__custom_resolution, str) ) and logger.info( "{} for this pipeline for defining output resolution.".format( "FFmpeg filter values will be used" @@ -607,10 +559,7 @@ def formulate(self) -> FFdecoder: ) # dynamically calculate raw-frame framerate based on source (if not assigned by user). - if ( - not isinstance(self.__inputframerate, str) - and self.__inputframerate > 0.0 - ): + if not isinstance(self.__inputframerate, str) and self.__inputframerate > 0.0: # assign if assigned by user and not "null"(str) output_params["-framerate"] = str(self.__inputframerate) self.__verbose_logs and logger.info( @@ -619,8 +568,7 @@ def formulate(self) -> FFdecoder: ) ) elif ( - "output_framerate" - in self.__sourcer_metadata # means `fps` filter is defined + "output_framerate" in self.__sourcer_metadata # means `fps` filter is defined and self.__sourcer_metadata["output_framerate"] > 0.0 ): # special mode to discard `-framerate/-r` FFmpeg parameter completely @@ -630,9 +578,7 @@ def formulate(self) -> FFdecoder: ) else: # calculate raw-frame framerate based on output - output_params["-framerate"] = str( - self.__sourcer_metadata["output_framerate"] - ) + output_params["-framerate"] = str(self.__sourcer_metadata["output_framerate"]) self.__verbose_logs and logger.info( "FFmpeg filter values will be used for this pipeline for defining output framerate." ) @@ -654,9 +600,7 @@ def formulate(self) -> FFdecoder: # otherwise raise error raise RuntimeError( "Both source and output metadata values found Invalid with {} `-framerate` attribute. Aborting!".format( - "null" - if isinstance(self.__inputframerate, str) - else "undefined" + "null" if isinstance(self.__inputframerate, str) else "undefined" ) ) @@ -675,9 +619,7 @@ def formulate(self) -> FFdecoder: self.__extract_metadata = False else: existing_vf = output_params.get("-vf", "") - output_params["-vf"] = ( - f"{existing_vf},showinfo" if existing_vf else "showinfo" - ) + output_params["-vf"] = f"{existing_vf},showinfo" if existing_vf else "showinfo" # dynamically calculate raw-frame numbers based on source (if not assigned by user). # TODO Added support for `-re -stream_loop` and `-loop` @@ -697,9 +639,7 @@ def formulate(self) -> FFdecoder: # log Mode of Operation self.__verbose_logs and logger.critical( - "Activating {} Mode of Operation.".format( - self.__supported_opmodes[self.__opmode] - ) + "Activating {} Mode of Operation.".format(self.__supported_opmodes[self.__opmode]) ) # compose the Pipeline using formulated FFmpeg parameters @@ -716,7 +656,9 @@ def __fetchNextfromPipeline(self) -> np.ndarray | None: """ This Internal method to fetch next dataframes(1D arrays) from `subprocess` pipe's standard output(`stdout`) into a Numpy buffer. """ - assert self.__process is not None, "Pipeline is not running! You must call `formulate()` method first." + assert self.__process is not None, ( + "Pipeline is not running! You must call `formulate()` method first." + ) # formulated raw frame size and apply YUV pixel formats patch(if applicable) raw_frame_size = ( @@ -734,18 +676,12 @@ def __fetchNextfromPipeline(self) -> np.ndarray | None: try: # read bytes frames from buffer nparray = np.frombuffer( - self.__process.stdout.read( - raw_frame_size * self.__raw_frame_dtype.itemsize - ), + self.__process.stdout.read(raw_frame_size * self.__raw_frame_dtype.itemsize), dtype=self.__raw_frame_dtype, ) except Exception as e: raise RuntimeError("Frame buffering failed with error: {}".format(str(e))) - return ( - nparray - if nparray is not None and len(nparray) == raw_frame_size - else None - ) + return nparray if nparray is not None and len(nparray) == raw_frame_size else None def __fetchNextFrame(self) -> np.ndarray | None: """ @@ -900,9 +836,7 @@ def metadata(self, value: dict[str, Any]) -> None: if key == "source": # metadata properties that cannot be altered logger.warning( - "`{}` metadata property value cannot be altered. Discarding!".format( - key - ) + "`{}` metadata property value cannot be altered. Discarding!".format(key) ) elif key in self.__missing_prop: # missing metadata properties are unavailable and read-only @@ -910,9 +844,7 @@ def metadata(self, value: dict[str, Any]) -> None: logger.warning( "`{}` metadata property is read-only".format(key) + ( - ". Try updating `{}` property instead!".format( - counterpart_prop[key] - ) + ". Try updating `{}` property instead!".format(counterpart_prop[key]) if key in counterpart_prop else " and cannot be updated!" ) @@ -922,20 +854,14 @@ def metadata(self, value: dict[str, Any]) -> None: self.__verbose_logs and logger.info( "Updating `{}`{} metadata property to `{}`.".format( key, - ( - " and its counterpart" - if key in counterpart_prop.values() - else "" - ), + (" and its counterpart" if key in counterpart_prop.values() else ""), value[key], ) ) # update source metadata if valid self.__sourcer_metadata[key] = value[key] # also update missing counterpart property (if available) - counter_key = next( - (k for k, v in counterpart_prop.items() if v == key), "" - ) + counter_key = next((k for k, v in counterpart_prop.items() if v == key), "") if counter_key: self.__missing_prop[counter_key] = value[key] else: @@ -1002,9 +928,7 @@ def __launch_FFdecoderline( # compose the FFmpeg process if self.__verbose_logs: logger.debug("Executing FFmpeg command: `{}`".format(" ".join(cmd))) - self.__process = sp.Popen( - cmd, stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=stderr_target - ) + self.__process = sp.Popen(cmd, stdin=sp.DEVNULL, stdout=sp.PIPE, stderr=stderr_target) else: # In silent mode self.__process = sp.Popen( @@ -1022,9 +946,7 @@ def __launch_FFdecoderline( if self.__extract_metadata: self.__metadata_queue = queue.Queue() self.__stderr_stop.clear() - self.__stderr_thread = threading.Thread( - target=self.__read_stderr, daemon=True - ) + self.__stderr_thread = threading.Thread(target=self.__read_stderr, daemon=True) self.__stderr_thread.start() def __read_stderr(self) -> None: diff --git a/deffcode/ffhelper.py b/deffcode/ffhelper.py index 1ad010f7..3f35602a 100644 --- a/deffcode/ffhelper.py +++ b/deffcode/ffhelper.py @@ -131,9 +131,7 @@ def get_valid_ffmpeg_path( final_path = os.path.join(final_path, "ffmpeg.exe") else: # else return False - verbose and logger.debug( - "No valid FFmpeg executables found at Custom FFmpeg path!" - ) + verbose and logger.debug("No valid FFmpeg executables found at Custom FFmpeg path!") return False else: # otherwise perform test for Unix @@ -147,9 +145,7 @@ def get_valid_ffmpeg_path( final_path = os.path.join(custom_ffmpeg, "ffmpeg") else: # else return False - verbose and logger.debug( - "No valid FFmpeg executables found at Custom FFmpeg path!" - ) + verbose and logger.debug("No valid FFmpeg executables found at Custom FFmpeg path!") return False else: # otherwise assign ffmpeg binaries from system @@ -161,9 +157,7 @@ def get_valid_ffmpeg_path( return final_path if validate_ffmpeg(final_path, verbose=verbose) else False -def download_ffmpeg_binaries( - path: str, os_windows: bool = False, os_bit: str = "" -) -> str: +def download_ffmpeg_binaries(path: str, os_windows: bool = False, os_bit: str = "") -> str: """ ## download_ffmpeg_binaries @@ -183,9 +177,7 @@ def download_ffmpeg_binaries( os_bit ) - file_name = os.path.join( - os.path.abspath(path), "ffmpeg-static-{}-gpl.zip".format(os_bit) - ) + file_name = os.path.join(os.path.abspath(path), "ffmpeg-static-{}-gpl.zip".format(os_bit)) file_path = os.path.join( os.path.abspath(path), "ffmpeg-static-{}-gpl/bin/ffmpeg.exe".format(os_bit), @@ -200,8 +192,7 @@ def download_ffmpeg_binaries( # check if given path has write access assert os.access(path, os.W_OK), ( - "[Helper:ERROR] :: Permission Denied, Cannot write binaries to directory = " - + path + "[Helper:ERROR] :: Permission Denied, Cannot write binaries to directory = " + path ) # remove leftovers if exists os.path.isfile(file_name) and delete_file_safe(file_name) @@ -228,7 +219,9 @@ def download_ffmpeg_binaries( if "content-length" in response.headers else len(response.content) ) - assert total_length is not None, "[Helper:ERROR] :: Failed to retrieve files, check your Internet connectivity!" + assert total_length is not None, ( + "[Helper:ERROR] :: Failed to retrieve files, check your Internet connectivity!" + ) bar = tqdm(total=int(total_length), unit="B", unit_scale=True) for data in response.iter_content(chunk_size=4096): f.write(data) @@ -266,9 +259,7 @@ def validate_ffmpeg(path: str, verbose: bool = False) -> bool: if verbose: # log if test are passed logger.debug("FFmpeg validity Test Passed!") logger.debug( - "Found valid FFmpeg Version: `{}` installed on this system".format( - version - ) + "Found valid FFmpeg Version: `{}` installed on this system".format(version) ) except Exception as e: # log if test are failed @@ -295,20 +286,14 @@ def get_supported_pixfmts(path: str) -> list[tuple[str, str, str]]: srtindex = [i for i, s in enumerate(splitted) if b"-----" in s] # extract video encoders supported_pxfmts = [ - x.decode("utf-8").strip() - for x in splitted[srtindex[0] + 1 :] - if x.decode("utf-8").strip() + x.decode("utf-8").strip() for x in splitted[srtindex[0] + 1 :] if x.decode("utf-8").strip() ] # compile regex finder = re.compile(r"([A-Z]*[\.]+[A-Z]*\s[a-z0-9_-]*)(\s+[0-4])(\s+[0-9]+)") # find all outputs outputs = finder.findall("\n".join(supported_pxfmts)) # return output findings - return [ - (list(o[0].split(" "))[-1], o[1].strip(), o[2].strip()) - for o in outputs - if len(o) == 3 - ] + return [(list(o[0].split(" "))[-1], o[1].strip(), o[2].strip()) for o in outputs if len(o) == 3] def get_supported_vdecoders(path: str) -> list[str]: @@ -352,9 +337,7 @@ def get_supported_demuxers(path: str) -> list[str]: # extract and clean FFmpeg output demuxers = check_sp_output([path, "-hide_banner", "-demuxers"]) splitted = [x.decode("utf-8").strip() for x in demuxers.split(b"\n")] - split_index = next( - (idx for idx, s in enumerate(splitted) if "--" in s), None - ) + split_index = next((idx for idx, s in enumerate(splitted) if "--" in s), None) if split_index is None: logger.warning( "Failed to locate demuxer separator `--` in FFmpeg `-demuxers` output. " @@ -386,9 +369,9 @@ def extract_device_n_demuxer( **Returns:** Tuple of list of supported device(s) path/name/index and OS specific demuxer used. """ # validate `machine_OS` parameter value - assert machine_OS is not None and isinstance( - machine_OS, str - ), "`machine_OS` parameter value is empty or invalid type. Aborting!" + assert machine_OS is not None and isinstance(machine_OS, str), ( + "`machine_OS` parameter value is empty or invalid type. Aborting!" + ) # initialize params devices: list[Any] = [] # handles devices discovered @@ -412,16 +395,14 @@ def extract_device_n_demuxer( verbose and logger.debug("Auto-Searching for valid devices...") # assert if demuxer is supported by provided ffmpeg. - assert req_demuxer in get_supported_demuxers( - path - ), "Required `{}` demuxer isn't supported by provided FFmpeg binaries. Kindly compile FFmpeg with \ + assert req_demuxer in get_supported_demuxers(path), ( + "Required `{}` demuxer isn't supported by provided FFmpeg binaries. Kindly compile FFmpeg with \ suitable flags or manually assign `source` and `source_demuxer` parameter values. Aborting!".format( - valid_demuxers[machine_OS] + valid_demuxers[machine_OS] + ) ) # create default ffmpeg command (for Windows and MacOS) - default_ffcommand = "-hide_banner -list_devices true -f {} -i dummy".format( - req_demuxer - ) + default_ffcommand = "-hide_banner -list_devices true -f {} -i dummy".format(req_demuxer) # find all OS specific FFmpeg devices path and demuxer if machine_OS == "Windows": @@ -453,10 +434,7 @@ def extract_device_n_demuxer( if ( not decoded or {"command", "not", "found"}.issubset(decoded.split(" ")) - or ( - {"Cannot", "open", "device"}.issubset(decoded.split(" ")) - and "):" not in decoded - ) + or ({"Cannot", "open", "device"}.issubset(decoded.split(" ")) and "):" not in decoded) ): logger.error( "Cannot execute `v4l2-ctl` command. " @@ -469,9 +447,7 @@ def extract_device_n_demuxer( else: # clean metadata clean_n_splitted = [ - x.strip() - for x in decoded.split("\n\n") - if "/dev/video" in x and "):" in x + x.strip() for x in decoded.split("\n\n") if "/dev/video" in x and "):" in x ] # compile regex finder = re.compile(r"^[a-zA-Z0-9_.\- ]*") @@ -496,10 +472,7 @@ def extract_device_n_demuxer( ) # decode path metadata decoded_path = metadata_path.decode("utf-8").strip() - if ( - "Width/Height" in decoded_path - and "Pixel Format" in decoded_path - ): + if "Width/Height" in decoded_path and "Pixel Format" in decoded_path: # append once required Width/Height and Pixel Format detected devices.append({path: device_name}) else: @@ -559,9 +532,7 @@ def extract_device_n_demuxer( ) -def validate_imgseqdir( - source: str, extension: str = "jpg", verbose: bool = False -) -> bool: +def validate_imgseqdir(source: str, extension: str = "jpg", verbose: bool = False) -> bool: """ ## validate_imgseqdir @@ -582,16 +553,12 @@ def validate_imgseqdir( ) return False else: - return ( - len(list(dirpath.glob("*.{}".format(extension)))) > 2 - ) + return len(list(dirpath.glob("*.{}".format(extension)))) > 2 except: return False -def is_valid_image_seq( - path: str, source: str | None = None, verbose: bool = False -) -> bool: +def is_valid_image_seq(path: str, source: str | None = None, verbose: bool = False) -> bool: """ ## is_valid_image_seq @@ -611,9 +578,7 @@ def is_valid_image_seq( # extract all FFmpeg supported protocols formats = check_sp_output([path, "-hide_banner", "-formats"]) extract_formats = re.findall(r"\w+_pipe", formats.decode("utf-8").strip()) - supported_image_formats = [ - x.split("_")[0] for x in extract_formats if x.endswith("_pipe") - ] + supported_image_formats = [x.split("_")[0] for x in extract_formats if x.endswith("_pipe")] _filename, extension = os.path.splitext(source) # Test and return result whether scheme is supported if extension and source.endswith(tuple(supported_image_formats)): @@ -633,9 +598,7 @@ def is_valid_image_seq( return False -def is_valid_url( - path: str, url: str | None = None, verbose: bool = False -) -> bool: +def is_valid_url(path: str, url: str | None = None, verbose: bool = False) -> bool: """ ## is_valid_url @@ -660,9 +623,7 @@ def is_valid_url( supported_protocols = splitted[splitted.index("Output:") + 1 : len(splitted) - 1] # RTSP is a demuxer somehow # support both RTSP and RTSPS(over SSL) - supported_protocols += ( - ["rtsp", "rtsps"] if "rtsp" in get_supported_demuxers(path) else [] - ) + supported_protocols += ["rtsp", "rtsps"] if "rtsp" in get_supported_demuxers(path) else [] # Test and return result whether scheme is supported if extracted_scheme_url and extracted_scheme_url in supported_protocols: verbose and logger.debug( diff --git a/deffcode/sourcer.py b/deffcode/sourcer.py index b20edbd4..1ffdbd51 100644 --- a/deffcode/sourcer.py +++ b/deffcode/sourcer.py @@ -106,17 +106,13 @@ def __init__( # sanitize sourcer_params self.__sourcer_params = { str(k).strip(): ( - str(v).strip() - if not isinstance(v, (dict, list, int, float, tuple)) - else v + str(v).strip() if not isinstance(v, (dict, list, int, float, tuple)) else v ) for k, v in sourcer_params.items() } # handle whether to force validate source - self.__forcevalidatesource = self.__sourcer_params.pop( - "-force_validate_source", False - ) + self.__forcevalidatesource = self.__sourcer_params.pop("-force_validate_source", False) if not isinstance(self.__forcevalidatesource, bool): # reset improper values self.__forcevalidatesource = False @@ -168,10 +164,10 @@ def __init__( # assign if valid demuxer value self.__source_demuxer = source_demuxer.strip().lower() # assign if valid demuxer value - assert self.__source_demuxer != "auto" or validate_device_index( - source - ), "Invalid `source_demuxer='auto'` value detected with source: `{}`. Aborting!".format( - source + assert self.__source_demuxer != "auto" or validate_device_index(source), ( + "Invalid `source_demuxer='auto'` value detected with source: `{}`. Aborting!".format( + source + ) ) else: # otherwise find valid default source demuxer value @@ -231,9 +227,7 @@ def __init__( # check whether metadata probed or not? self.__metadata_probed = False - def probe_stream( - self, default_stream_indexes: list[int] | tuple[int, int] = (0, 0) - ) -> Sourcer: + def probe_stream(self, default_stream_indexes: list[int] | tuple[int, int] = (0, 0)) -> Sourcer: """ This method Parses/Probes FFmpeg `subprocess` pipe's Standard Output for given input source and Populates the information in private class variables. @@ -251,9 +245,7 @@ def probe_stream( self.__ffsp_output = self.__validate_source( self.__source, source_demuxer=self.__source_demuxer, - forced_validate=( - self.__forcevalidatesource if self.__source_demuxer is None else True - ), + forced_validate=(self.__forcevalidatesource if self.__source_demuxer is None else True), ) # parse resolution and framerate video_rfparams = self.__extract_resolution_framerate( @@ -345,9 +337,9 @@ def retrieve_metadata( **Returns:** `metadata` or `(metadata, metadata_missing)`, formatted as JSON string or python dictionary. """ # check if metadata has been probed or not - assert ( - self.__metadata_probed - ), "Source Metadata not been probed yet! Check if you called `probe_stream()` method." + assert self.__metadata_probed, ( + "Source Metadata not been probed yet! Check if you called `probe_stream()` method." + ) # log it self.__verbose_logs and logger.debug("Extracting Metadata...") # create metadata dictionary from information populated in private class variables @@ -418,9 +410,7 @@ def retrieve_metadata( } ) # log it - self.__verbose_logs and logger.debug( - "Metadata Extraction completed successfully!" - ) + self.__verbose_logs and logger.debug("Metadata Extraction completed successfully!") # parse as JSON string(`json.dumps`), if defined metadata = json.dumps(metadata, indent=2) if pretty_json else metadata metadata_missing = ( @@ -438,9 +428,9 @@ def enumerate_devices(self) -> dict[int, Any]: **Returns:** Probed Camera Devices as python dictionary. """ # check if metadata has been probed or not - assert ( - self.__metadata_probed - ), "Source Metadata not been probed yet! Check if you called `probe_stream()` method." + assert self.__metadata_probed, ( + "Source Metadata not been probed yet! Check if you called `probe_stream()` method." + ) # log if specified self.__verbose_logs and logger.debug("Enumerating all probed Camera Devices.") @@ -479,10 +469,12 @@ def __validate_source( verbose=self.__verbose_logs, ) # valid indexes range - valid_indexes = list(range( + valid_indexes = list( + range( -len(self.__extracted_devices_list), len(self.__extracted_devices_list), - )) + ) + ) # check index is within valid range if self.__extracted_devices_list and index in valid_indexes: # overwrite actual source device name/path/index @@ -510,15 +502,9 @@ def __validate_source( ( self.__extracted_devices_list[index] if self.__machine_OS != "Linux" - else next( - iter(self.__extracted_devices_list[index].values()) - )[0] - ), - ( - index - if index >= 0 - else len(self.__extracted_devices_list) + index + else next(iter(self.__extracted_devices_list[index].values()))[0] ), + (index if index >= 0 else len(self.__extracted_devices_list) + index), self.__source_demuxer, ) ) @@ -542,9 +528,7 @@ def __validate_source( pass # assert if valid source - assert source and isinstance( - source, str - ), "Input `source` parameter is of invalid type!" + assert source and isinstance(source, str), "Input `source` parameter is of invalid type!" # Differentiate input if forced_validate: @@ -554,9 +538,7 @@ def __validate_source( self.__source = source elif os.path.isfile(source): self.__source = os.path.abspath(source) - elif is_valid_image_seq( - self.__ffmpeg, source=source, verbose=self.__verbose_logs - ): + elif is_valid_image_seq(self.__ffmpeg, source=source, verbose=self.__verbose_logs): self.__source = source self.__contains_images = True elif is_valid_url(self.__ffmpeg, url=source, verbose=self.__verbose_logs): @@ -624,9 +606,7 @@ def __extract_video_bitrate(self, default_stream: int = 0) -> str: else 0 ) ] - filtered_bitrate = re.findall( - r",\s[0-9]+\s\w\w[\/]s", selected_stream.strip() - ) + filtered_bitrate = re.findall(r",\s[0-9]+\s\w\w[\/]s", selected_stream.strip()) if len(filtered_bitrate): default_video_bitrate = filtered_bitrate[0].split(" ")[1:3] final_bitrate = "{}{}".format( @@ -654,22 +634,14 @@ def __extract_video_decoder(self, default_stream: int = 0) -> str: ] if meta_text: selected_stream = meta_text[ - ( - default_stream - if default_stream > 0 and default_stream < len(meta_text) - else 0 - ) + (default_stream if default_stream > 0 and default_stream < len(meta_text) else 0) ] - filtered_pixfmt = re.findall( - r"Video:\s[a-z0-9_-]*", selected_stream.strip() - ) + filtered_pixfmt = re.findall(r"Video:\s[a-z0-9_-]*", selected_stream.strip()) if filtered_pixfmt: return filtered_pixfmt[0].split(" ")[-1] return "" - def __extract_video_pixfmt( - self, default_stream: int = 0, extract_output: bool = False - ) -> str: + def __extract_video_pixfmt(self, default_stream: int = 0, extract_output: bool = False) -> str: """ This Internal method parses default video-stream pixel-format from metadata. @@ -694,22 +666,14 @@ def __extract_video_pixfmt( ) if meta_text: selected_stream = meta_text[ - ( - default_stream - if default_stream > 0 and default_stream < len(meta_text) - else 0 - ) + (default_stream if default_stream > 0 and default_stream < len(meta_text) else 0) ] - filtered_pixfmt = re.findall( - r",\s[a-z][a-z0-9_-]*", selected_stream.strip() - ) + filtered_pixfmt = re.findall(r",\s[a-z][a-z0-9_-]*", selected_stream.strip()) if filtered_pixfmt: return filtered_pixfmt[0].split(" ")[-1] return "" - def __extract_audio_bitrate_nd_samplerate( - self, default_stream: int = 0 - ) -> dict[str, str]: + def __extract_audio_bitrate_nd_samplerate(self, default_stream: int = 0) -> dict[str, str]: """ This Internal method parses default audio-stream bitrate and sample-rate from metadata. @@ -727,19 +691,13 @@ def __extract_audio_bitrate_nd_samplerate( result = {} if meta_text: selected_stream = meta_text[ - ( - default_stream - if default_stream > 0 and default_stream < len(meta_text) - else 0 - ) + (default_stream if default_stream > 0 and default_stream < len(meta_text) else 0) ] # filter data filtered_audio_bitrate = re.findall( r"fltp,\s[0-9]+\s\w\w[\/]s", selected_stream.strip() ) - filtered_audio_samplerate = re.findall( - r",\s[0-9]+\sHz", selected_stream.strip() - ) + filtered_audio_samplerate = re.findall(r",\s[0-9]+\sHz", selected_stream.strip()) # get audio bitrate metadata if filtered_audio_bitrate: filtered = filtered_audio_bitrate[0].split(" ")[1:3] @@ -751,9 +709,7 @@ def __extract_audio_bitrate_nd_samplerate( result["bitrate"] = "" # get audio samplerate metadata result["samplerate"] = ( - filtered_audio_samplerate[0].split(", ")[1] - if filtered_audio_samplerate - else "" + filtered_audio_samplerate[0].split(", ")[1] if filtered_audio_samplerate else "" ) return result if result and (len(result) == 2) else {} @@ -803,33 +759,21 @@ def __extract_resolution_framerate( result = {} if meta_text: selected_stream = meta_text[ - ( - default_stream - if default_stream > 0 and default_stream < len(meta_text) - else 0 - ) + (default_stream if default_stream > 0 and default_stream < len(meta_text) else 0) ] # filter data - filtered_resolution = re.findall( - r"([1-9]\d+)x([1-9]\d+)", selected_stream.strip() - ) - filtered_framerate = re.findall( - r"\d+(?:\.\d+)?\sfps", selected_stream.strip() - ) + filtered_resolution = re.findall(r"([1-9]\d+)x([1-9]\d+)", selected_stream.strip()) + filtered_framerate = re.findall(r"\d+(?:\.\d+)?\sfps", selected_stream.strip()) filtered_tbr = re.findall(r"\d+(?:\.\d+)?\stbr", selected_stream.strip()) # extract framerate metadata if filtered_framerate: # calculate actual framerate - result["framerate"] = float( - re.findall(r"[\d\.\d]+", filtered_framerate[0])[0] - ) + result["framerate"] = float(re.findall(r"[\d\.\d]+", filtered_framerate[0])[0]) elif filtered_tbr: # guess from TBR(if fps unavailable) - result["framerate"] = float( - re.findall(r"[\d\.\d]+", filtered_tbr[0])[0] - ) + result["framerate"] = float(re.findall(r"[\d\.\d]+", filtered_tbr[0])[0]) # extract resolution metadata if filtered_resolution: @@ -844,9 +788,7 @@ def __extract_resolution_framerate( else 0 ) ] - filtered_orientation = re.findall( - r"[-]?\d+\.\d+", selected_stream.strip() - ) + filtered_orientation = re.findall(r"[-]?\d+\.\d+", selected_stream.strip()) result["orientation"] = float(filtered_orientation[0]) else: result["orientation"] = 0.0 @@ -875,10 +817,7 @@ def __extract_duration(self, inseconds: bool = True) -> float | list[str]: ) if t_duration: return ( - sum( - float(x) * 60**i - for i, x in enumerate(reversed(t_duration[0].split(":"))) - ) + sum(float(x) * 60**i for i, x in enumerate(reversed(t_duration[0].split(":")))) if inseconds else t_duration ) diff --git a/deffcode/utils.py b/deffcode/utils.py index 49a92c7b..5e5f282d 100644 --- a/deffcode/utils.py +++ b/deffcode/utils.py @@ -66,9 +66,7 @@ def logger_handler() -> logging.Handler: os.path.dirname(file_path), os.W_OK ): file_path = ( - os.path.join(file_path, "deffcode.log") - if os.path.isdir(file_path) - else file_path + os.path.join(file_path, "deffcode.log") if os.path.isdir(file_path) else file_path ) handler = logging.FileHandler(file_path, mode="a") formatter = logging.Formatter( @@ -154,9 +152,7 @@ def validate_device_index(index: int | str | Any) -> bool: # remove any whitespaces index.replace(" ", "") # return true - return ( - bool(index.isnumeric() or (index.startswith("-") and index[1:].isnumeric())) - ) + return bool(index.isnumeric() or (index.startswith("-") and index[1:].isnumeric())) else: # return false otherwise return False diff --git a/setup.py b/setup.py index 51dd3376..1980233d 100644 --- a/setup.py +++ b/setup.py @@ -37,9 +37,7 @@ # patch for unicodes long_description = long_description.replace("➶", ">>").replace("©", "(c)") # patch internal hyperlinks - long_description = long_description.replace( - "(#", "(https://github.com/abhiTronix/deffcode#" - ) + long_description = long_description.replace("(#", "(https://github.com/abhiTronix/deffcode#") setup( long_description=long_description, diff --git a/tests/essentials.py b/tests/essentials.py index 9bb74119..19ad382e 100644 --- a/tests/essentials.py +++ b/tests/essentials.py @@ -48,17 +48,11 @@ def return_static_ffmpeg() -> str: """ path = "" if platform.system() == "Windows": - path += os.path.join( - tempfile.gettempdir(), "Downloads/FFmpeg_static/ffmpeg/bin/ffmpeg.exe" - ) + path += os.path.join(tempfile.gettempdir(), "Downloads/FFmpeg_static/ffmpeg/bin/ffmpeg.exe") elif platform.system() == "Darwin": - path += os.path.join( - tempfile.gettempdir(), "Downloads/FFmpeg_static/ffmpeg/bin/ffmpeg" - ) + path += os.path.join(tempfile.gettempdir(), "Downloads/FFmpeg_static/ffmpeg/bin/ffmpeg") else: - path += os.path.join( - tempfile.gettempdir(), "Downloads/FFmpeg_static/ffmpeg/ffmpeg" - ) + path += os.path.join(tempfile.gettempdir(), "Downloads/FFmpeg_static/ffmpeg/ffmpeg") return os.path.abspath(path) @@ -83,9 +77,7 @@ def return_testvideo_path(fmt: str = "av") -> str: "ao": "BigBuckBunny_4sec_AO.mp4", } req_fmt = fmt if (fmt in supported_fmts) else "av" - path = "{}/Downloads/Test_videos/{}".format( - tempfile.gettempdir(), supported_fmts[req_fmt] - ) + path = "{}/Downloads/Test_videos/{}".format(tempfile.gettempdir(), supported_fmts[req_fmt]) return os.path.abspath(path) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 526fde96..1d70eb2f 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -50,6 +50,7 @@ logger.addHandler(logger_handler()) logger.setLevel(logging.DEBUG) + @pytest.mark.parametrize( "source, custom_ffmpeg, output", [ @@ -99,7 +100,9 @@ def test_source_playback(source: str, custom_ffmpeg: str, output: bool) -> None: # gather data actual_frame_num, actual_frame_shape = actual_frame_count_n_frame_size(source) - logger.info(f"Actual Frames Number: {actual_frame_num} and Actual Frame Shape: {actual_frame_shape}") + logger.info( + f"Actual Frames Number: {actual_frame_num} and Actual Frame Shape: {actual_frame_shape}" + ) # Update output if the actual_frame_count_n_frame_size fails to decode stream output = output and (actual_frame_shape is not None) @@ -108,11 +111,15 @@ def test_source_playback(source: str, custom_ffmpeg: str, output: bool) -> None: for frame in decoder.generateFrame(): # check shape if frame.shape != actual_frame_shape: - raise RuntimeError(f"Test failed - Frame Shape: {frame.shape} vs Actual Frame Shape: {actual_frame_shape}") + raise RuntimeError( + f"Test failed - Frame Shape: {frame.shape} vs Actual Frame Shape: {actual_frame_shape}" + ) # increment number of frames frame_num += 1 - assert frame_num >= actual_frame_num, f"Test failed - Total Frames: {frame_num} vs Actual Frames: {actual_frame_num}" + assert frame_num >= actual_frame_num, ( + f"Test failed - Total Frames: {frame_num} vs Actual Frames: {actual_frame_num}" + ) except Exception as e: if not output: logger.exception(str(e)) @@ -216,8 +223,7 @@ def test_yuv_family_ingest(pixfmt: str, cv_color_code: int) -> None: h, w = actual_shape[0], actual_shape[1] # YUV/NV ingest with cv_patch yields a 2D buffer with height = h*3/2 assert frame.shape == (h * 3 // 2, w), ( - f"Test failed - unexpected YUV buffer shape {frame.shape}, " - f"expected {(h * 3 // 2, w)}" + f"Test failed - unexpected YUV buffer shape {frame.shape}, expected {(h * 3 // 2, w)}" ) # round-trip via OpenCV to confirm planar layout is valid @@ -265,12 +271,9 @@ def test_extract_luma(pixfmt: str) -> None: assert frame is not None, "Test failed - no frame retrieved" # luma-only output must be a 2D (H, W) uint8 ndarray assert frame.shape == (h, w), ( - f"Test failed - unexpected luma shape {frame.shape}, " - f"expected {(h, w)}" - ) - assert frame.dtype == np.uint8, ( - f"Test failed - unexpected luma dtype {frame.dtype}" + f"Test failed - unexpected luma shape {frame.shape}, expected {(h, w)}" ) + assert frame.dtype == np.uint8, f"Test failed - unexpected luma dtype {frame.dtype}" frames_checked += 1 if frames_checked >= 3: break @@ -327,9 +330,7 @@ def test_extract_metadata_basic() -> None: if frames_checked >= 5: break assert frames_checked > 0, "Test failed - generator yielded no frames" - assert prev_frame_num == 0 or any( - True for _ in [0] - ), "sanity: loop must have executed" + assert prev_frame_num == 0 or any(True for _ in [0]), "sanity: loop must have executed" except Exception as e: pytest.fail(str(e)) finally: @@ -441,8 +442,7 @@ def test_extract_luma_invalid_type() -> None: ).formulate() frame = next(decoder.generateFrame(), None) assert frame is not None and frame.shape == actual_shape, ( - f"Test failed - got {None if frame is None else frame.shape}, " - f"expected {actual_shape}" + f"Test failed - got {None if frame is None else frame.shape}, expected {actual_shape}" ) except Exception as e: pytest.fail(str(e)) @@ -518,8 +518,7 @@ def test_metadata(custom_params: Any, checks: bool) -> None: if checks: assert all( - json.loads(decoder.metadata)[x] == custom_params[x] - for x in custom_params + json.loads(decoder.metadata)[x] == custom_params[x] for x in custom_params ), "Test failed" except Exception as e: if not checks: @@ -582,9 +581,7 @@ def test_seek_n_save(ffparams: dict[str, Any], pixfmts: str) -> None: if frame is not None and pixfmts == "rgba": # Convert and save our output filename = os.path.abspath( - os.path.join( - *[tempfile.gettempdir(), "temp_write", "filename_rgba.jpeg"] - ) + os.path.join(*[tempfile.gettempdir(), "temp_write", "filename_rgba.jpeg"]) ) im = Image.fromarray(frame) im = im.convert("RGB") @@ -592,9 +589,7 @@ def test_seek_n_save(ffparams: dict[str, Any], pixfmts: str) -> None: elif frame is not None and pixfmts == "gray": # Convert and save our output filename = os.path.abspath( - os.path.join( - *[tempfile.gettempdir(), "temp_write", "filename_gray.png"] - ) + os.path.join(*[tempfile.gettempdir(), "temp_write", "filename_gray.png"]) ) cv2.imwrite(filename, frame) else: @@ -637,9 +632,7 @@ def test_seek_n_save(ffparams: dict[str, Any], pixfmts: str) -> None: @pytest.mark.parametrize("source, ffparams, result", test_data) -def test_FFdecoder_params( - source: str, ffparams: dict[str, Any], result: bool -) -> None: +def test_FFdecoder_params(source: str, ffparams: dict[str, Any], result: bool) -> None: """ Testing FFdecoder API with different parameters and save output """ @@ -652,13 +645,10 @@ def test_FFdecoder_params( source, frame_format="bgr24", source_demuxer=( - "lavfi" - if (isinstance(source, str) and source.startswith("testsrc")) - else None + "lavfi" if (isinstance(source, str) and source.startswith("testsrc")) else None ), **ffparams, ) as decoder: - # retrieve JSON Metadata and convert it to dict metadata_dict = json.loads(decoder.metadata) @@ -672,7 +662,6 @@ def test_FFdecoder_params( # grab the BGR24 frame from the decoder for frame in decoder.generateFrame(): - # check if frame is None if frame is None: break @@ -714,9 +703,7 @@ def test_FFdecoder_params( @pytest.mark.parametrize("source, source_demuxer, result", test_data) -def test_camera_capture( - source: str | int, source_demuxer: str | None, result: bool -) -> None: +def test_camera_capture(source: str | int, source_demuxer: str | None, result: bool) -> None: """ Tests FFdecoder's realtime Webcam and Virtual playback capabilities as well as Index based Camera Device Capturing @@ -803,9 +790,7 @@ def test_camera_capture( @pytest.mark.parametrize("frame_format, ffparams, result", test_data) -def test_discard_n_filter_params( - frame_format: str, ffparams: dict[str, Any], result: bool -) -> None: +def test_discard_n_filter_params(frame_format: str, ffparams: dict[str, Any], result: bool) -> None: """ Tests FFdecoder's discarding FFmpeg parameters and using FFmpeg Filter capabilities diff --git a/tests/test_ffhelper.py b/tests/test_ffhelper.py index 1067114d..b5727277 100644 --- a/tests/test_ffhelper.py +++ b/tests/test_ffhelper.py @@ -17,6 +17,7 @@ limitations under the License. =============================================== """ + # import the necessary packages from __future__ import annotations @@ -76,9 +77,7 @@ def test_ffmpeg_binaries_download(paths: str, os_bit: str) -> None: """ file_path = "" try: - file_path = download_ffmpeg_binaries( - path=paths, os_windows=is_windows, os_bit=os_bit - ) + file_path = download_ffmpeg_binaries(path=paths, os_windows=is_windows, os_bit=os_bit) if file_path: logger.debug("FFmpeg Binary path: {}".format(file_path)) assert os.path.isfile(file_path), "FFmpeg download failed!" @@ -117,9 +116,7 @@ def test_validate_ffmpeg(paths: str) -> None: @pytest.mark.parametrize("paths, ffmpeg_download_paths, results", test_data) -def test_get_valid_ffmpeg_path( - paths: str, ffmpeg_download_paths: str, results: bool -) -> None: +def test_get_valid_ffmpeg_path(paths: str, ffmpeg_download_paths: str, results: bool) -> None: """ Testing FFmpeg excutables validation and correction: """ @@ -130,13 +127,11 @@ def test_get_valid_ffmpeg_path( ffmpeg_download_path=ffmpeg_download_paths, verbose=True, ) - if not ( - paths == "wrong_test_path" or ffmpeg_download_paths == "wrong_test_path" - ): - assert ( - bool(output) == results - ), "FFmpeg excutables validation and correction Test failed at path: {} and FFmpeg ffmpeg_download_paths: {}".format( - paths, ffmpeg_download_paths + if not (paths == "wrong_test_path" or ffmpeg_download_paths == "wrong_test_path"): + assert bool(output) == results, ( + "FFmpeg excutables validation and correction Test failed at path: {} and FFmpeg ffmpeg_download_paths: {}".format( + paths, ffmpeg_download_paths + ) ) except Exception as e: if paths == "wrong_test_path" or ffmpeg_download_paths == "wrong_test_path": @@ -191,9 +186,7 @@ def test_is_valid_image_seq(source: str | None, result: bool) -> None: Testing test_is_valid_image_seq method """ try: - result_url = is_valid_image_seq( - return_static_ffmpeg(), source=source, verbose=True - ) + result_url = is_valid_image_seq(return_static_ffmpeg(), source=source, verbose=True) assert result_url == result, "Image sequence validity test Failed!" except Exception as e: result and pytest.fail(str(e)) @@ -244,10 +237,6 @@ def test_get_supported_demuxers_missing_separator(monkeypatch: pytest.MonkeyPatc """ # simulate malformed FFmpeg output with no `--` separator line malformed_output = b"File formats:\n D. = Demuxing supported\n garbage line\n" - monkeypatch.setattr( - ffhelper, "check_sp_output", lambda *args, **kwargs: malformed_output - ) + monkeypatch.setattr(ffhelper, "check_sp_output", lambda *args, **kwargs: malformed_output) result = get_supported_demuxers("fake_ffmpeg") - assert result == [], ( - "Expected empty list when `--` separator is missing from demuxers output." - ) + assert result == [], "Expected empty list when `--` separator is missing from demuxers output." diff --git a/tests/test_sourcer.py b/tests/test_sourcer.py index ee500334..11dc4562 100644 --- a/tests/test_sourcer.py +++ b/tests/test_sourcer.py @@ -17,6 +17,7 @@ limitations under the License. =============================================== """ + # import the necessary packages from __future__ import annotations @@ -78,9 +79,7 @@ ), ], ) -def test_source( - source: str, sourcer_params: dict[str, Any], custom_ffmpeg: str -) -> None: +def test_source(source: str, sourcer_params: dict[str, Any], custom_ffmpeg: str) -> None: """ Paths Source - Test various source paths/urls supported by Sourcer. """ @@ -125,13 +124,9 @@ def test_probe_stream_n_retrieve_metadata( Test `probe_stream` and `retrieve_metadata` function. """ try: - source_demuxer = ( - "lavfi" if source == "mandelbrot=size=1280x720:rate=30" else None - ) + source_demuxer = "lavfi" if source == "mandelbrot=size=1280x720:rate=30" else None if source == "invalid": - sourcer = Sourcer( - source, custom_ffmpeg=return_static_ffmpeg(), verbose=True - ) + sourcer = Sourcer(source, custom_ffmpeg=return_static_ffmpeg(), verbose=True) else: sourcer = Sourcer( source, @@ -149,14 +144,12 @@ def test_probe_stream_n_retrieve_metadata( ): logger.debug("Skipped check!") else: - assert ( - metadata["approx_video_nframes"] - >= actual_frame_count_n_frame_size(source)[0] - ), "Test Failed for frames count!" + assert metadata["approx_video_nframes"] >= actual_frame_count_n_frame_size(source)[0], ( + "Test Failed for frames count!" + ) except Exception as e: if isinstance(e, ValueError) or ( - source in ["invalid", "unknown://invalid.com/"] - and isinstance(e, AssertionError) + source in ["invalid", "unknown://invalid.com/"] and isinstance(e, AssertionError) ): pytest.xfail("Test Still Passed!") else: diff --git a/tests/test_utils.py b/tests/test_utils.py index 06e4fb2f..a5f880e8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -17,6 +17,7 @@ limitations under the License. =============================================== """ + # import the necessary packages from __future__ import annotations From e82555ddb8f6aeda51017b764a2087a82a92fb09 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 22:14:05 +0530 Subject: [PATCH 50/57] =?UTF-8?q?=E2=9C=A8=20feat(ffdecoder,sourcer):=20ad?= =?UTF-8?q?d=20multi-input=20source=20list=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/ffdecoder.py | 80 ++++++++++++++++++++++----- deffcode/sourcer.py | 126 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 187 insertions(+), 19 deletions(-) diff --git a/deffcode/ffdecoder.py b/deffcode/ffdecoder.py index a54f9854..a803635f 100644 --- a/deffcode/ffdecoder.py +++ b/deffcode/ffdecoder.py @@ -85,8 +85,8 @@ class FFdecoder: def __init__( self, - source: str | int, - source_demuxer: str | None = None, + source: str | list[str], + source_demuxer: str | list[str] | None = None, frame_format: str | None = None, custom_ffmpeg: str = "", verbose: bool = False, @@ -110,6 +110,10 @@ def __init__( # define whether initializing self.__initializing = True + # handle source list mapping + self.__is_multi = isinstance(source, list) + self.__source_list = source if self.__is_multi else [source] + # define frame pixel-format for decoded frames self.__frame_format = ( frame_format.lower().strip() if isinstance(frame_format, str) else None @@ -175,8 +179,32 @@ def __init__( ) # reset improper values self.__ffmpeg_prefixes = [] + elif self.__is_multi: + # multi-input requires per-source list-of-lists with matching length; + # reject ambiguous flat lists or length mismatches up front so the + # cmd builder never sees a malformed shape. + if self.__ffmpeg_prefixes: + if not all(isinstance(p, list) for p in self.__ffmpeg_prefixes): + raise ValueError( + "Multi-input `-ffprefixes` must be a list of per-input lists " + "(e.g. `[['-re'], ['-stream_loop', '-1']]`). " + "Flat lists are ambiguous in multi-input mode." + ) + if len(self.__ffmpeg_prefixes) != len(self.__source_list): + raise ValueError( + "`-ffprefixes` length ({}) must match `source` list length ({})!".format( + len(self.__ffmpeg_prefixes), len(self.__source_list) + ) + ) + sourcer_params["-ffprefixes"] = self.__ffmpeg_prefixes else: - # also pass valid ffmpeg pre-headers to Sourcer API + # single-input keeps the flat-list contract; nested lists are only + # meaningful in multi-input mode, so discard them with a warning. + if any(isinstance(p, list) for p in self.__ffmpeg_prefixes): + logger.warning( + "Nested lists in `-ffprefixes` are only supported for multi-input sources. Discarding!" + ) + self.__ffmpeg_prefixes = [] sourcer_params["-ffprefixes"] = self.__ffmpeg_prefixes # pass parameter(if specified) to Sourcer API, specifying where to save the downloaded FFmpeg Static @@ -343,6 +371,13 @@ def formulate(self) -> FFdecoder: """ # assign values to class variables on first run if self.__initializing: + if self.__is_multi and not {"-map", "-filter_complex"}.intersection( + self.__extra_params.keys() + ): + raise ValueError( + "Multi-input setups require `-map` or `-filter_complex` to route the outputs unambiguously." + ) + # prepare parameter dict input_params = OrderedDict() output_params = OrderedDict() @@ -900,20 +935,39 @@ def __launch_FFdecoderline( output_parameters = dict2Args(output_params) # format command - cmd = ( - [self.__ffmpeg] - + (["-hide_banner"] if not self.__verbose_logs else []) - + self.__ffmpeg_prefixes - + input_parameters - + ( + cmd = [self.__ffmpeg] + (["-hide_banner"] if not self.__verbose_logs else []) + if self.__is_multi: + for idx, _src in enumerate(self.__source_list): + _prefixes = ( + self.__ffmpeg_prefixes[idx] + if len(self.__ffmpeg_prefixes) > idx + and isinstance(self.__ffmpeg_prefixes[idx], list) + else [] + ) + cmd += _prefixes + # apply standard input parameters ONLY to the primary source + if idx == 0: + cmd += input_parameters + _src_meta = ( + self.__sourcer_metadata["sources"][idx] + if "sources" in self.__sourcer_metadata + else {} + ) + if _src_meta.get("source_demuxer"): + cmd += ["-f", _src_meta["source_demuxer"]] + cmd += ["-i", _src] + else: + cmd += self.__ffmpeg_prefixes + cmd += input_parameters + cmd += ( ["-f", self.__sourcer_metadata["source_demuxer"]] if ("source_demuxer" in self.__sourcer_metadata) else [] ) - + ["-i", self.__sourcer_metadata["source"]] - + output_parameters - + ["-f", "rawvideo", "-"] - ) + cmd += ["-i", self.__sourcer_metadata["source"]] + + cmd += output_parameters + cmd += ["-f", "rawvideo", "-"] # When metadata extraction is enabled we must capture stderr regardless # of verbose mode so the background reader thread can parse showinfo # lines. Without PIPE the reader would have nothing to read (verbose diff --git a/deffcode/sourcer.py b/deffcode/sourcer.py index 1ffdbd51..5045d9a6 100644 --- a/deffcode/sourcer.py +++ b/deffcode/sourcer.py @@ -27,6 +27,7 @@ import os import platform import re +import shutil from typing import Any import numpy as np @@ -76,8 +77,8 @@ class Sourcer: def __init__( self, - source: str, - source_demuxer: str | None = None, + source: str | list[str], + source_demuxer: str | list[str] | None = None, custom_ffmpeg: str = "", verbose: bool = False, **sourcer_params: Any, @@ -117,17 +118,77 @@ def __init__( # reset improper values self.__forcevalidatesource = False + # sanitize externally accessible parameters and setup list mapping + self.__is_multi = isinstance(source, list) + self.__source_list = source if self.__is_multi else [source] + + # validate source list early so downstream errors stay coherent + if self.__is_multi and not self.__source_list: + raise ValueError("Input `source` list is empty!") + # handle user defined ffmpeg pre-headers(parameters such as `-re`) parameters (must be a list) - self.__ffmpeg_prefixes = self.__sourcer_params.pop("-ffprefixes", []) - if not isinstance(self.__ffmpeg_prefixes, list): + _prefixes = self.__sourcer_params.pop("-ffprefixes", []) + if not isinstance(_prefixes, list): # log it logger.warning( "Discarding invalid `-ffprefixes` value of wrong type `{}`!".format( - type(self.__ffmpeg_prefixes).__name__ + type(_prefixes).__name__ ) ) # reset improper values - self.__ffmpeg_prefixes = [] + _prefixes = [] + + if self.__is_multi: + # multi-input requires per-source list-of-lists with matching length + # to keep prefix routing unambiguous; flat lists are rejected. + if _prefixes: + if not all(isinstance(p, list) for p in _prefixes): + raise ValueError( + "Multi-input `-ffprefixes` must be a list of per-input lists " + "(e.g. `[['-re'], ['-stream_loop', '-1']]`). " + "Flat lists are ambiguous in multi-input mode." + ) + if len(_prefixes) != len(self.__source_list): + raise ValueError( + "`-ffprefixes` length ({}) must match `source` list length ({})!".format( + len(_prefixes), len(self.__source_list) + ) + ) + self.__ffmpeg_prefixes_list = _prefixes + self.__ffmpeg_prefixes = _prefixes[0] + else: + self.__ffmpeg_prefixes_list = [[] for _ in self.__source_list] + self.__ffmpeg_prefixes = [] + else: + # single-input keeps the original flat-list contract; nested lists are + # only meaningful in multi-input mode, so reject them with a warning. + if any(isinstance(p, list) for p in _prefixes): + logger.warning( + "Nested lists in `-ffprefixes` are only supported for multi-input sources. Discarding!" + ) + _prefixes = [] + self.__ffmpeg_prefixes = _prefixes + self.__ffmpeg_prefixes_list = [_prefixes] + + # handle source_demuxer list mapping + if self.__is_multi: + if isinstance(source_demuxer, list): + if len(source_demuxer) != len(self.__source_list): + raise ValueError( + "`source_demuxer` length ({}) must match `source` list length ({})!".format( + len(source_demuxer), len(self.__source_list) + ) + ) + self.__source_demuxer_list = source_demuxer + else: + self.__source_demuxer_list = [source_demuxer] * len(self.__source_list) + else: + self.__source_demuxer_list = [source_demuxer] + + # initialize per-source metadata buffer so retrieve_metadata can be + # called safely (e.g. via the recursive primary probe in probe_stream) + # without polluting the result with stale `sources` keys. + self.__multi_source_metadata: list[Any] = [] # handle where to save the downloaded FFmpeg Static assets on Windows(if specified) __ffmpeg_download_path = self.__sourcer_params.pop("-ffmpeg_download_path", "") @@ -155,6 +216,12 @@ def __init__( ) # sanitize externally accessible parameters and assign them + # Use primary index 0 for fallback properties validation + if not self.__source_list: + raise ValueError("Input `source` parameter is empty!") + source = self.__source_list[0] + source_demuxer = self.__source_demuxer_list[0] + # handles source demuxer if source is None: # first check if source value is empty @@ -321,6 +388,41 @@ def probe_stream(self, default_stream_indexes: list[int] | tuple[int, int] = (0, # signal metadata has been probed self.__metadata_probed = True + if self.__is_multi: + # collect per-source metadata for the `sources` key. The primary + # source's flat metadata is captured first via retrieve_metadata; + # the guard inside retrieve_metadata (checks for non-empty + # __multi_source_metadata) prevents `sources: []` self-pollution. + self.__multi_source_metadata.append(self.retrieve_metadata(force_retrieve_missing=True)) + for idx in range(1, len(self.__source_list)): + _src = self.__source_list[idx] + _demux = self.__source_demuxer_list[idx] + _prefixes = self.__ffmpeg_prefixes_list[idx] + _params = self.__sourcer_params.copy() + _params["-ffprefixes"] = _prefixes + # spawn an independent single-source Sourcer per extra input; + # this reuses the resolved ffmpeg path and isolates per-source + # parsing state (which probe_stream otherwise clobbers). + # Resolve to an absolute path: on Unix `self.__ffmpeg` may be + # the bare command "ffmpeg" found via PATH, which the nested + # `get_valid_ffmpeg_path()` would reject as "not a file". + _custom_ffmpeg = ( + self.__ffmpeg + if self.__ffmpeg and os.path.isfile(self.__ffmpeg) + else (shutil.which(self.__ffmpeg) or "") + ) + _s = Sourcer( + _src, + source_demuxer=_demux, + custom_ffmpeg=_custom_ffmpeg, + verbose=self.__verbose_logs, + **_params, + ) + _s.probe_stream(default_stream_indexes) + self.__multi_source_metadata.append( + _s.retrieve_metadata(force_retrieve_missing=True) + ) + # return reference to the instance object. return self @@ -409,6 +511,18 @@ def retrieve_metadata( "output_orientation": self.__default_video_orientation, } ) + + # Only emit the `sources` key after per-source metadata is populated. + # probe_stream() calls retrieve_metadata() once for the primary input + # *before* populating __multi_source_metadata; without this guard the + # primary's per-source dict would carry a stray empty `sources: []` + # field that pollutes metadata["sources"][0]. + if self.__is_multi and self.__multi_source_metadata: + metadata["sources"] = [m[0] for m in self.__multi_source_metadata] + force_retrieve_missing and metadata_missing.update( + {"sources": [m[1] for m in self.__multi_source_metadata]} + ) + # log it self.__verbose_logs and logger.debug("Metadata Extraction completed successfully!") # parse as JSON string(`json.dumps`), if defined From cfa9bff8b267608df310388e5ce0f1661e6a756a Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 22:14:49 +0530 Subject: [PATCH 51/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20multi-input?= =?UTF-8?q?=20source=20configurations=20recipe=20and=20update=20refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + docs/index.md | 1 + docs/recipes/advanced/index.md | 6 + docs/recipes/advanced/multi_input.md | 330 +++++++++++++++++++++++++++ docs/reference/ffdecoder/params.md | 19 +- docs/reference/sourcer/params.md | 18 +- mkdocs.yml | 1 + 7 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 docs/recipes/advanced/multi_input.md diff --git a/README.md b/README.md index 0c4e8f28..6780699c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Here are some key features that stand out: - Curated list of well-documented recipes ranging from [**Basic**][basic-recipes] to [**Advanced**][advanced-recipes] skill levels. - Hands down the easiest [**Index based Camera Device Capturing**][decoding-camera-devices-using-indexes], similar to OpenCV. - Easy to code **Real-time [Simple][transcoding-live-simple-filtergraphs] & [Complex][transcoding-live-complex-filtergraphs] Filtergraphs**. _(Yes, You read it correctly "Real-time"!)_ +- Native **[Multi-Input Source Configurations][multi-input-source-configurations]** support for decoding complex topologies. - Lightning fast dedicated **GPU-Accelerated Video [Decoding][hardware-accelerated-video-decoding] & [Transcoding][hardware-accelerated-video-transcoding]**. - Enables precise FFmpeg [**Key-frame Seeking**][extracting-key-frames-as-png-image] with pinpoint accuracy. - Effortless [**Metadata Extraction**][extracting-video-metadata] from all streams available in the source. @@ -193,6 +194,7 @@ Once you have DeFFcode installed, checkout our Well-Documented **[Recipes 🍱][ - [CUDA-accelerated Video Transcoding with OpenCV's VideoWriter API][cuda-accelerated-video-transcoding-with-opencvs-videowriter-api] - [CUDA-NVENC-accelerated Video Transcoding with WriteGear API][cuda-nvenc-accelerated-video-transcoding-with-writegear-api] - [CUDA-NVENC-accelerated End-to-end Lossless Video Transcoding with WriteGear API][cuda-nvenc-accelerated-end-to-end-lossless-video-transcoding-with-writegear-api] +- [Multi-Input Source Configurations][multi-input-source-configurations] @@ -435,6 +437,7 @@ Advanced Recipes [overriding-source-video-metadata-in-ffdecoder-api]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/update-metadata/#overriding-source-video-metadata-in-ffdecoder-api [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 [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 +[multi-input-source-configurations]: https://abhitronix.github.io/deffcode/latest/recipes/advanced/multi_input/#multi-input-source-configurations + +# :material-play-box-multiple: Multi-Input Source Configurations + +> DeFFcode's [Sourcer](../../reference/sourcer/) and [FFdecoder](../../reference/ffdecoder/) APIs accept their `source` and `source_demuxer` parameters as Python lists, ingesting multiple media streams simultaneously inside a single FFmpeg instance. This unlocks side-by-side composites, Picture-in-Picture (PiP) overlays, multi-camera comparisons, and custom video mixing — all driven natively by FFmpeg's filter graph, with no inter-process glue on your side. + +We'll walk through Multi-Input Source Configurations in the recipes below: + +  + +!!! warning "DeFFcode APIs requires FFmpeg executable" + + ==DeFFcode APIs **MUST** requires valid FFmpeg executable for all of its core functionality==, and any failure in detection will raise `RuntimeError` immediately. Follow dedicated [FFmpeg Installation doc ➶](../../../installation/ffmpeg_install/) for its installation. + +??? info "Additional Python Dependencies for following recipes" + + Following recipes requires additional python dependencies which can be installed easily as below: + + - [x] **OpenCV:** OpenCV is required for previewing video frames. You can easily install it directly via [`pip`](https://pypi.org/project/opencv-python/): + + ??? tip "OpenCV installation from source" + + You can also follow online tutorials for building & installing OpenCV on [Windows](https://www.learnopencv.com/install-opencv3-on-windows/), [Linux](https://www.pyimagesearch.com/2018/05/28/ubuntu-18-04-how-to-install-opencv/), [MacOS](https://www.pyimagesearch.com/2018/08/17/install-opencv-4-on-macos/) and [Raspberry Pi](https://www.pyimagesearch.com/2018/09/26/install-opencv-4-on-your-raspberry-pi/) machines manually from its source. + + :warning: Make sure not to install both *pip* and *source* version together. Otherwise installation will fail to work! + + ??? info "Other OpenCV binaries" + + OpenCV maintainers also provide additional binaries via pip that contains both main modules and contrib/extra modules [`opencv-contrib-python`](https://pypi.org/project/opencv-contrib-python/), and for server (headless) environments like [`opencv-python-headless`](https://pypi.org/project/opencv-python-headless/) and [`opencv-contrib-python-headless`](https://pypi.org/project/opencv-contrib-python-headless/). You can also install ==any one of them== in similar manner. More information can be found [here](https://github.com/opencv/opencv-python#installation-and-usage). + + + ```sh + pip install opencv-python + ``` + +!!! warning "FFdecoder requires explicit stream routing in multi-input mode" + + With multiple `-i` inputs FFmpeg auto-selects only the "best" video stream when no routing is given, which is rarely what you want. To prevent ambiguous decoding, FFdecoder API **requires** you to pass either `-map` or `-filter_complex` whenever `source` is a list. If neither is present, [`formulate()`](../../reference/ffdecoder/#deffcode.ffdecoder.FFdecoder.formulate) raises a **`ValueError`** at initialization time. + +!!! danger "Multi-input pipeline limitations" + + 1. **`-vcodec` is input-scoped to source 0.** A single `-vcodec` parameter only applies to the first `-i` input (FFmpeg's positional-options rule). To pin a decoder per input in a multi-decoder pipeline, route it explicitly via `-filter_complex` or use FFmpeg's per-input options inside `-ffprefixes`. + 2. **`-extract_metadata` is incompatible with `-filter_complex`.** The [`showinfo`](https://ffmpeg.org/ffmpeg-filters.html#showinfo) filter that backs per-frame metadata cannot share the graph with `-filter_complex`, so FFdecoder will warn and disable `-extract_metadata` in any multi-input pipeline that uses one. + 3. **Per-input lists must match `source` length.** If you pass `-ffprefixes` or `source_demuxer` as a list, its length must equal the `source` list length — otherwise DeFFcode raises `ValueError` immediately. Use an empty inner list (`[]`) or `None` for any input that needs no value. + +!!! tip "To learn about exclusive `-ffprefixes` parameter and its multi-input list-of-lists shape, see [Exclusive Parameters ➶](../../reference/ffdecoder/params/#b-exclusive-parameters)." + +!!! note "Always use FFdecoder API's [`terminate()`](../../reference/ffdecoder/#deffcode.ffdecoder.FFdecoder.terminate) method at the end to avoid undesired behavior." + +??? danger "Never name your python script `deffcode.py`" + + When trying out these recipes, never name your python script `deffcode.py` otherwise it will result in `ModuleNotFound` error. + +  + +## Decoding multiple inputs as side-by-side composite + +> The simplest multi-input workflow is binding two media streams together horizontally with FFmpeg's [`hstack`](https://ffmpeg.org/ffmpeg-filters.html#hstack) filter — useful for A/B comparisons, before/after diffs, or multi-camera views. + +In this example we will decode two video files _(say `video_stream_1.mp4` and `video_stream_2.mp4`)_ as a single side-by-side BGR24 frame stream by passing them as a list to FFdecoder API and routing both inputs through `hstack` via the `-filter_complex` parameter, and preview the composited frames using OpenCV Library's `cv2.imshow()` method in real-time. + +!!! alert "Both inputs must share the same height for `hstack` to succeed. Use a `scale` clause inside `-filter_complex` if your sources differ in resolution." + +```python +# import the necessary packages +from deffcode import FFdecoder +import cv2 + +# define our two media paths to stack side-by-side +source = [ + "video_stream_1.mp4", # first input (-i #0) + "video_stream_2.mp4", # second input (-i #1) +] + +# `-filter_complex` is mandatory in multi-input mode; +# `hstack=inputs=2` concatenates both streams horizontally +ffparams = {"-filter_complex": "hstack=inputs=2"} + +# initialize and formulate the decoder for BGR24 output +decoder = FFdecoder(source, frame_format="bgr24", **ffparams).formulate() + +# grab the BGR24 frame from the decoder +for frame in decoder.generateFrame(): + + # check if frame is None + if frame is None: + break + + # {do something with the frame here} + + # Show output window + cv2.imshow("Output", frame) + + # check for 'q' key if pressed + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + +# close output window +cv2.destroyAllWindows() + +# terminate the decoder +decoder.terminate() +``` + +  + +## Decoding multiple RTSP streams in parallel + +> When ingesting multiple live network streams _(such as IP cameras over RTSP)_, you typically need transport-level options that differ per camera _(e.g. forcing TCP transport to reduce packet corruption)_. The `-ffprefixes` exclusive parameter accepts a **list of per-input lists** in source order so each `-i` group gets its own pre-input options. + +In this example we will decode two live RTSP camera feeds, force TCP transport on both inputs through per-input `-ffprefixes`, route them side-by-side with `hstack`, and preview the multiplexed BGR24 frames using OpenCV Library's `cv2.imshow()` method in real-time. + +!!! alert "Remember to replace the placeholder RTSP URLs with the credentials and addresses of your own cameras before using this recipe." + +```python +# import the necessary packages +from deffcode import FFdecoder +import cv2 + +# define multiple RTSP camera streams as our source list +source = [ + "rtsp://admin:pass@192.168.1.10:554/stream1", + "rtsp://admin:pass@192.168.1.11:554/stream2", +] + +# define per-input prefixes: one inner list per source, in source order +ffparams = { + "-ffprefixes": [ + ["-rtsp_transport", "tcp"], # applies to source 0 only + ["-rtsp_transport", "tcp"], # applies to source 1 only + ], + # route both inputs side-by-side + "-filter_complex": "hstack=inputs=2", +} + +# initialize and formulate the decoder for BGR24 output +decoder = FFdecoder(source, frame_format="bgr24", **ffparams).formulate() + +# grab the BGR24 frame from the decoder +for frame in decoder.generateFrame(): + + # check if frame is None + if frame is None: + break + + # {do something with the frame here} + + # Show output window + cv2.imshow("Output", frame) + + # check for 'q' key if pressed + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + +# close output window +cv2.destroyAllWindows() + +# terminate the decoder +decoder.terminate() +``` + +  + +## Decoding Picture-in-Picture overlay with per-input configuration + +> Real-world multi-input pipelines almost always need _different_ per-input options — for instance, real-time pacing _(`-re`)_ on a live stream paired with infinite looping _(`-stream_loop -1`)_ on a local asset. With `-ffprefixes` shaped as a list-of-lists, every input is configured independently while still sharing a single FFmpeg pipeline. + +In this example we will overlay a looping local video file _(say `local_file.mp4`)_ in the top-right corner of a paced live HLS stream _(say `network_stream_1.m3u8`)_ via FFmpeg's [`overlay`](https://ffmpeg.org/ffmpeg-filters.html#toc-overlay-1) filter inside `-filter_complex`, supply per-input prefixes for each, and preview the resulting Picture-in-Picture BGR24 frames using OpenCV Library's `cv2.imshow()` method in real-time. + +!!! info "You can use FFdecoder's [`metadata`](../../reference/ffdecoder/#deffcode.ffdecoder.FFdecoder.metadata) property to inspect the per-source metadata under the `sources` key once the pipeline is formulated." + +```python +# import the necessary packages +from deffcode import FFdecoder +import cv2 + +# define our multi-input sources +source = [ + "network_stream_1.m3u8", # live HLS stream as the base layer + "local_file.mp4", # local asset overlaid in the top-right corner +] + +# define per-input prefixes and the overlay filter graph +ffparams = { + "-ffprefixes": [ + ["-re"], # pace input 0 at native frame rate + ["-stream_loop", "-1"], # loop input 1 infinitely + ], + # PiP overlay: input 1 anchored 10px from the top-right of input 0 + "-filter_complex": "[0:v][1:v]overlay=main_w-overlay_w-10:10", +} + +# initialize and formulate the decoder for BGR24 output +decoder = FFdecoder(source, frame_format="bgr24", **ffparams).formulate() + +# grab the BGR24 frame from the decoder +for frame in decoder.generateFrame(): + + # check if frame is None + if frame is None: + break + + # {do something with the frame here} + + # Show output window + cv2.imshow("Output", frame) + + # check for 'q' key if pressed + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + +# close output window +cv2.destroyAllWindows() + +# terminate the decoder +decoder.terminate() +``` + +  + +## Decoding mixed sources with different demuxers + +> Inputs in a multi-input pipeline can also originate from completely different device classes — for instance, a Linux webcam captured via `v4l2` paired with a synthetically generated [`lavfi`](http://underpop.online.fr/f/ffmpeg/help/lavfi.htm.gz) source. The `source_demuxer` parameter accepts a list whose entries align positionally with `source`, so each input gets its own `-f` directive. + +In this example we will combine a live webcam feed _(captured via `v4l2` on Linux)_ with a generated Mandelbrot pattern _(via `lavfi`)_, stack them side-by-side with `hstack`, and preview the composite BGR24 frames using OpenCV Library's `cv2.imshow()` method in real-time. + +!!! alert "This recipe requires Linux for `v4l2`. On other operating systems substitute `dshow` (Windows) or `avfoundation` (MacOS) along with the platform-appropriate device path." + +!!! tip "Use `None` for any inner entry of `source_demuxer` whose corresponding source does not need an explicit `-f` directive — DeFFcode will simply omit it for that input." + +```python +# import the necessary packages +from deffcode import FFdecoder +import cv2 + +# webcam + virtual mandelbrot source +source = [ + "/dev/video0", # v4l2 camera (Linux) + "mandelbrot=size=1280x720:rate=30", # libavfilter virtual source +] + +# per-input demuxers, aligned positionally with source +source_demuxer = [ + "v4l2", # for /dev/video0 + "lavfi", # for the mandelbrot filtergraph +] + +# stack the camera feed next to the generated mandelbrot +ffparams = {"-filter_complex": "hstack=inputs=2"} + +# initialize and formulate the decoder for BGR24 output +decoder = FFdecoder( + source, source_demuxer=source_demuxer, frame_format="bgr24", **ffparams +).formulate() + +# grab the BGR24 frame from the decoder +for frame in decoder.generateFrame(): + + # check if frame is None + if frame is None: + break + + # {do something with the frame here} + + # Show output window + cv2.imshow("Output", frame) + + # check for 'q' key if pressed + key = cv2.waitKey(1) & 0xFF + if key == ord("q"): + break + +# close output window +cv2.destroyAllWindows() + +# terminate the decoder +decoder.terminate() +``` + +  + +## Probing multiple inputs with Sourcer API + +> The [Sourcer API](../../reference/sourcer/) probes each source independently — no `-map` or `-filter_complex` is required because nothing is being decoded into a single stream. The primary input's flat metadata fields _(`source_video_resolution`, `source_video_framerate`, etc.)_ come from `source[0]` and remain in the same shape as a single-source probe, while a new `sources` key is appended carrying the per-input metadata dict for every input in order. + +In this example we will probe two video files _(say `video1.mp4` and `video2.mp4`)_ as a single Sourcer call and pretty-print the per-source metadata list extracted from the `sources` key. + +!!! info "The flat top-level fields _(e.g. `source_video_resolution`)_ always describe `source[0]` so existing single-source consumers keep working unchanged." + +```python +# import the necessary packages +from deffcode import Sourcer +import json + +# define our multi-input sources +source = ["video1.mp4", "video2.mp4"] + +# initialize the sourcer and probe each source sequentially +sourcer = Sourcer(source).probe_stream() + +# the returned metadata mirrors the single-input shape for source[0] +# and exposes per-source dicts under the `sources` key +metadata = sourcer.retrieve_metadata() + +# pretty-print the per-source metadata list +print(json.dumps(metadata["sources"], indent=4)) +``` + +  diff --git a/docs/reference/ffdecoder/params.md b/docs/reference/ffdecoder/params.md index 385f83a3..44d28f19 100644 --- a/docs/reference/ffdecoder/params.md +++ b/docs/reference/ffdecoder/params.md @@ -29,9 +29,9 @@ This parameter defines the input source (`-i`) for decoding real-time frames. !!! danger "FFdecoder API checks for _`video bitrate`_ or _`frame-size` and `framerate`_ in video's metadata to ensure given input `source` has usable video stream available. Thereby, it will throw `ValueError` if it fails to find those parameters." -!!! info "Multiple video inputs are not yet supported!" +!!! info "Multiple video inputs are fully supported! Pass a Python list of source strings to natively process multiple media streams simultaneously. A `-filter_complex` or `-map` parameter is required." -**Data-Type:** String. +**Data-Type:** String or List of Strings. Its valid input can be one of the following: @@ -471,7 +471,7 @@ This parameter specifies the demuxer(`-f`) for the input source _(such as `dshow !!! example "Related usage recipes :material-pot-steam: can found [here ➶](../../../recipes/basic/decode-camera-devices)" -**Data-Type:** String +**Data-Type:** String or List of Strings (if `source` is a list, you can pass a list of identical length mapping demuxers to corresponding sources). **Default Value:** Its default value is `None`. @@ -683,6 +683,19 @@ These parameters are discussed below: ffparams = {"-ffprefixes": ['-re']} # executes as `ffmpeg -re ` ``` + !!! info "Multi-input mode: per-source list-of-lists" + When [`source`](#source) is a list, `-ffprefixes` must be a **list of per-input lists** with one entry per source (in the same order). Flat lists are rejected as ambiguous, and a length mismatch raises `ValueError`. + + ```python + # source[0] gets `-re`; source[1] gets `-stream_loop -1` + ffparams = { + "-ffprefixes": [["-re"], ["-stream_loop", "-1"]], + "-filter_complex": "hstack=inputs=2", # required for multi-input + } + ``` + + Use an empty inner list (`[]`) for any input that needs no prefix. See the [Multi-Input Source Configurations recipe ➶](../../../recipes/advanced/multi_input/#multi-input-source-configurations) for full examples. +   * **`-clones`** _(list)_: This attribute sets the special FFmpeg parameters after that are repeated more than once or occurs in a specific order _(that cannot be altered)_ in the FFmpeg command. Its value can be of datatype **`list`** only and its usage is as follows: diff --git a/docs/reference/sourcer/params.md b/docs/reference/sourcer/params.md index 5fe78f4f..d431e52c 100644 --- a/docs/reference/sourcer/params.md +++ b/docs/reference/sourcer/params.md @@ -28,9 +28,9 @@ This parameter defines the input source (`-i`) for probing. !!! danger "Sourcer API checks for _`video bitrate`_ or _`frame-size` and `framerate`_ in video's metadata to ensure given input `source` has usable video stream available. Thereby, it will throw `ValueError` if it fails to find those parameters." -!!! info "Multiple video inputs are not yet supported!" +!!! info "Multiple video inputs are fully supported! Pass a Python list of source strings to probe multiple media streams simultaneously. The probed dictionaries will be appended to the `sources` metadata list." -**Data-Type:** String. +**Data-Type:** String or List of Strings. Its valid input can be one of the following: @@ -362,7 +362,7 @@ This parameter specifies the demuxer(`-f`) for the input source _(such as `dshow sourcer = Sourcer("0", source_demuxer="auto).probe_stream() ``` -**Data-Type:** String +**Data-Type:** String or List of Strings (if `source` is a list, you can pass a list of identical length mapping demuxers to corresponding sources). **Default Value:** Its default value is `None`. @@ -461,6 +461,18 @@ These parameters are discussed below: sourcer_params = {"-ffprefixes": ['-re']} # executes as `ffmpeg -re ` ``` + !!! info "Multi-input mode: per-source list-of-lists" + When [`source`](#source) is a list, `-ffprefixes` must also be a **list of per-input lists** with one entry per source (in the same order). Flat lists are rejected as ambiguous, and a length mismatch raises `ValueError`. + + ```python + # source[0] gets `-re`; source[1] gets `-stream_loop -1` + sourcer_params = { + "-ffprefixes": [["-re"], ["-stream_loop", "-1"]], + } + ``` + + Use an empty inner list (`[]`) for any input that needs no prefix. +   * **`-ffmpeg_download_path`** _(string)_: sets the custom directory for downloading FFmpeg Static Binaries in Compression Mode, during the [Auto-Installation](../ffmpeg_install/#a-auto-installation) on Windows Machines Only. If this parameter is not altered, then these binaries will auto-save to the default temporary directory (for e.g. `C:/User/temp`) on your windows machine. It can be used as follows: diff --git a/mkdocs.yml b/mkdocs.yml index 431b882a..17691dd0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -212,6 +212,7 @@ nav: - Extracting video metadata: recipes/basic/extract-video-metadata.md - Advanced Recipes: - Overview: recipes/advanced/index.md + - Multi-Input Source Configurations: recipes/advanced/multi_input.md - Decoding Live Virtual Sources: recipes/advanced/decode-live-virtual-sources.md - Decoding Live Feed Devices: recipes/advanced/decode-live-feed-devices.md - Hardware-Accelerated Video Decoding: recipes/advanced/decode-hw-acceleration.md From 49543a28fe30faf9750698767cb72eada87ccf47 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Sun, 19 Apr 2026 22:15:23 +0530 Subject: [PATCH 52/57] =?UTF-8?q?=F0=9F=91=B7=20test:=20add=20multi-input?= =?UTF-8?q?=20source=20and=20decoder=20parameter=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_ffdecoder.py | 14 ++++++++++- tests/test_sourcer.py | 52 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/tests/test_ffdecoder.py b/tests/test_ffdecoder.py index 1d70eb2f..5422baef 100644 --- a/tests/test_ffdecoder.py +++ b/tests/test_ffdecoder.py @@ -628,11 +628,23 @@ def test_seek_n_save(ffparams: dict[str, Any], pixfmts: str) -> None: }, True, ), + ( + [return_testvideo_path(), return_testvideo_path()], + {"-filter_complex": "hstack=inputs=2"}, + True, + ), + ( + [return_testvideo_path(), return_testvideo_path()], + { + "-extract_metadata": True + }, # Should fail with ValueError because of missing map/filter_complex + False, + ), ] @pytest.mark.parametrize("source, ffparams, result", test_data) -def test_FFdecoder_params(source: str, ffparams: dict[str, Any], result: bool) -> None: +def test_FFdecoder_params(source: str | list[str], ffparams: dict[str, Any], result: bool) -> None: """ Testing FFdecoder API with different parameters and save output """ diff --git a/tests/test_sourcer.py b/tests/test_sourcer.py index 11dc4562..a30fa330 100644 --- a/tests/test_sourcer.py +++ b/tests/test_sourcer.py @@ -77,9 +77,25 @@ {}, "invalid_ffmpeg", # invalid FFmpeg ), + ( + [return_testvideo_path(), return_testvideo_path()], + { + "-ffprefixes": [["-re"], ["-stream_loop", "-1"]], + }, + return_static_ffmpeg(), + ), + ( + [return_testvideo_path(), return_testvideo_path()], + { + "-ffprefixes": "invalid" # list of lists mismatch + }, + return_static_ffmpeg(), + ), ], ) -def test_source(source: str, sourcer_params: dict[str, Any], custom_ffmpeg: str) -> None: +def test_source( + source: str | list[str], sourcer_params: dict[str, Any], custom_ffmpeg: str +) -> None: """ Paths Source - Test various source paths/urls supported by Sourcer. """ @@ -113,10 +129,15 @@ def test_source(source: str, sourcer_params: dict[str, Any], custom_ffmpeg: str) (0, 0), ["source_has_image_sequence"], ), + ( + [return_testvideo_path(), "mandelbrot=size=1280x720:rate=30"], + (0, 0), + ["source_has_video", "sources"], # tests sources list + ), ], ) def test_probe_stream_n_retrieve_metadata( - source: str, + source: str | list[str], default_stream_indexes: tuple[int, ...] | list[int], params: list[str], ) -> None: @@ -124,7 +145,14 @@ def test_probe_stream_n_retrieve_metadata( Test `probe_stream` and `retrieve_metadata` function. """ try: - source_demuxer = "lavfi" if source == "mandelbrot=size=1280x720:rate=30" else None + source_demuxer = None + if isinstance(source, list): + source_demuxer = [ + "lavfi" if s == "mandelbrot=size=1280x720:rate=30" else None for s in source + ] + elif source == "mandelbrot=size=1280x720:rate=30": + source_demuxer = "lavfi" + if source == "invalid": sourcer = Sourcer(source, custom_ffmpeg=return_static_ffmpeg(), verbose=True) else: @@ -136,12 +164,26 @@ def test_probe_stream_n_retrieve_metadata( ).probe_stream(default_stream_indexes=default_stream_indexes) metadata = sourcer.retrieve_metadata() logger.debug("Found Metadata: `{}`".format(metadata)) - assert all(metadata[x] is True for x in params), "Test Failed!" - if ( + + # Test sources exists and is valid + if "sources" in params: + assert "sources" in metadata and len(metadata["sources"]) == len(source), ( + "Multi-input Test Failed!" + ) + + assert all( + metadata.get(x, metadata["sources"] if x == "sources" else False) for x in params + ), "Test Failed!" + + is_skipped = False + if isinstance(source, list) or ( source.startswith("http") or source.endswith("png") or source == "mandelbrot=size=1280x720:rate=30" ): + is_skipped = True + + if is_skipped: logger.debug("Skipped check!") else: assert metadata["approx_video_nframes"] >= actual_frame_count_n_frame_size(source)[0], ( From 0739e3b947ae860a844b7c9d66068efc97293a10 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Mon, 20 Apr 2026 09:33:21 +0530 Subject: [PATCH 53/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20reorder=20multi-in?= =?UTF-8?q?put=20recipe=20in=20advanced=20nav=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 17691dd0..c9c556dc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -212,13 +212,13 @@ nav: - Extracting video metadata: recipes/basic/extract-video-metadata.md - Advanced Recipes: - Overview: recipes/advanced/index.md - - Multi-Input Source Configurations: recipes/advanced/multi_input.md - Decoding Live Virtual Sources: recipes/advanced/decode-live-virtual-sources.md - Decoding Live Feed Devices: recipes/advanced/decode-live-feed-devices.md - Hardware-Accelerated Video Decoding: recipes/advanced/decode-hw-acceleration.md - Transcoding Live Complex Filtergraphs: recipes/advanced/transcode-live-frames-complexgraphs.md - Transcoding Video Art with Filtergraphs: recipes/advanced/transcode-art-filtergraphs.md - Hardware-Accelerated Video Transcoding: recipes/advanced/transcode-hw-acceleration.md + - Multi-Input Source Configurations: recipes/advanced/multi_input.md - Updating Video Metadata: recipes/advanced/update-metadata.md - Per-Frame Metadata Extraction: recipes/advanced/extract-frame-metadata.md - API References: From a258911587adec90823ed0c175470708c0694513 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Mon, 20 Apr 2026 09:34:02 +0530 Subject: [PATCH 54/57] =?UTF-8?q?=F0=9F=93=84=20docs(changelog):=20add=20v?= =?UTF-8?q?0.2.7=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 4219e698..f7b43bac 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,9 +20,68 @@ limitations under the License. # Release Notes -## v0.2.6 (2024-07-08) :material-new-box: +## v0.2.7 (2026-04-20) :material-new-box: ???+ new "New Features" + - [x] **FFdecoder:** + * Added **Multi-input source list support** in both `FFdecoder` and `Sourcer`, enabling simultaneous decoding from multiple input streams (e.g., multiple RTSP sources). + * Added **Async per-frame metadata extraction** via FFmpeg's `showinfo` filter, exposing frame number, PTS time, and keyframe info per decoded frame. + * Added **Fast luma-only (`-extract_luma`) YUV decoding**, slicing the Y-plane directly from YUV/NV bytestreams into a 2D `uint8` ndarray. + * 💬 Bypasses FFmpeg colorspace conversion for a significant speed boost over `frame_format="gray"`. + +??? success "Updates/Improvements" + - [x] **Core:** + * Added official support for Python `3.12.x` and `3.13.x` legacies. + * Modernized codebase with type annotations across core source files and tests, adopting idiomatic Python 3.10+ style. + - [x] **Packaging:** + * Migrated packaging from `setup.py` to `pyproject.toml`, including metadata, dependencies, classifiers, and project URLs. + - [x] **Tooling:** + * Adopted **Ruff** project-wide for linting and formatting, replacing `flake8`/`black`. + - [x] **CI/CD:** + * Upgraded Linux runner from `ubuntu-20.04` to `ubuntu-22.04`/`ubuntu-latest`. + * Updated Python CI matrix to `3.10`–`3.13` across GitHub Actions, Azure Pipelines, and AppVeyor. + * Migrated Codecov uploader to new CLI (`cli.codecov.io`) with `--fail-on-error`. + * Pinned GitHub Actions to latest `checkout`, `setup-python`, and `codecov` action versions. + * Bumped docs deployer Python to `3.11`; replaced legacy `mkdocstrings`. + - [x] **Docs:** + * Restructured Installation guide with dedicated Poetry install instructions. + * Added new recipes: multi-input source configurations, per-frame metadata extraction, YUV grayscale fast-path, YUV420p performance tip, and Input/Output Seeking methods with pros/cons. + * Updated contributions guide to reference Ruff instead of flake8/black. + * Updated README with keyframe decoding and VFR sync recipe links. + * Improved docs formatting and nav: OS icons in install tabs, HW-acceleration limitation section, transcode recipe clarity fixes. + * Updated Citation and Zenodo badge to v0.2.6 DOI. + +??? danger "Breaking Updates/Changes" + * **Core:** + - [x] :skull_crossbones: **Minimum Python version raised to `3.10+`.** Python `3.8` and `3.9` are no longer supported and have been officially dropped from all CI/CD pipelines and package metadata. + +??? bug "Bug-fixes" + - [x] **FFhelper:** + * Fixed `StopIteration` crash in `get_supported_demuxers` when FFmpeg `-demuxers` output lacks the expected `--` separator line; now returns an empty list with a warning instead of raising. + * Fixed regex expression bugs in `get_supported_demuxers`: simplified regex, corrected multi-line output handling, and fixed comma-within-demuxer-name stripping. + - [x] **Sourcer:** + * Fixed incorrect `param` name in `Sourcer.retrieve_metadata` docstring. + - [x] **CI:** + * Fixed `output_filename` → `output` parameter name in WriteGear API test calls to match upstream API changes. + - [x] **Docs:** + * Fixed asset paths and typos in recipe and reference documentation pages. + * Fixed duplicate `pymdownx.magiclink` extension entry in MkDocs config. + +??? question "Pull Requests" + * PR #65 + * PR #64 + * PR #63 + * PR #61 + * PR #60 + * PR #59 + +  + +  + +## v0.2.6 (2024-07-08) :material-new-box: + +??? new "New Features" - [x] **FFdecoder:** * Introduced a new optional `-disable_ffmpeg_window` boolean parameter. * 💬 Prevents the FFmpeg command line window from appearing by applying the `DETACHED_PROCESS` flag to the subprocess FFmpeg pipeline when building `.exe` files on Windows in silent (`verbose=False`) mode. From 943a7569b5850dcc330538be69e1fa01985ce693 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Mon, 20 Apr 2026 10:03:50 +0530 Subject: [PATCH 55/57] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20API=20ref?= =?UTF-8?q?erence=20for=20ffhelper=20and=20utils=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/ffhelper.md | 4 ---- docs/reference/utils.md | 4 ++++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/ffhelper.md b/docs/reference/ffhelper.md index 07e2ecc0..987db760 100644 --- a/docs/reference/ffhelper.md +++ b/docs/reference/ffhelper.md @@ -28,10 +28,6 @@ limitations under the License.   -::: deffcode.ffhelper.get_valid_ffmpeg_path - -  - ::: deffcode.ffhelper.download_ffmpeg_binaries   diff --git a/docs/reference/utils.md b/docs/reference/utils.md index 25d46704..4c920624 100644 --- a/docs/reference/utils.md +++ b/docs/reference/utils.md @@ -35,4 +35,8 @@ limitations under the License. ::: deffcode.utils.delete_file_safe +  + +::: deffcode.utils.validate_device_index +   \ No newline at end of file From ded20e11ce1c45b78e50e0c65275cc38315d6e00 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Mon, 20 Apr 2026 10:04:09 +0530 Subject: [PATCH 56/57] =?UTF-8?q?=F0=9F=8E=89=20docs:=20update=20announcem?= =?UTF-8?q?ent=20banner=20for=20v0.2.7=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/overrides/main.html | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 910935b6..71f45d25 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -18,12 +18,15 @@ {% endblock %} {% block announce %} -{% set announcement_link = config.site_url ~ -'/recipes/basic/decode-camera-devices/#decoding-camera-devices-using-indexes' %} +{% set announcement_link_1 = config.site_url ~ +'/recipes/advanced/multi_input/' %} +{% set announcement_link_2 = config.site_url ~ +'recipes/advanced/extract-frame-metadata/' %} +{% set announcement_link_3 = config.site_url ~ +'/recipes/basic/decode-video-files/#__tabbed_3_2' %} -Hey, Index based Camera Device Capture support has been added in v0.2.4. Checkout these new recipes {% include ".icons/material/pot-steam-outline.svg" - %} + + {% include ".icons/material/rocket-launch.svg" %} v0.2.7 is out! Decode multiple input streams in parallel ➶, grab per-frame metadata async ➶, and fly through YUV with -extract_luma {% endblock %} {% block outdated %} You're not viewing the latest version. From 130b0c36d444257accfec5cb3db6e45bfec7b2f9 Mon Sep 17 00:00:00 2001 From: abhiTronix Date: Mon, 20 Apr 2026 10:19:57 +0530 Subject: [PATCH 57/57] =?UTF-8?q?=F0=9F=92=A1=20style:=20add=20inline=20co?= =?UTF-8?q?mment=20for=20input=20framerate=20assignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deffcode/ffdecoder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deffcode/ffdecoder.py b/deffcode/ffdecoder.py index a803635f..5cb312c3 100644 --- a/deffcode/ffdecoder.py +++ b/deffcode/ffdecoder.py @@ -329,6 +329,7 @@ def __init__( ): self.__inputframerate = __framerate elif isinstance(__framerate, (float, int)): + # assign input framerate self.__inputframerate = float(__framerate) if __framerate > 0.0 else 0.0 else: # warn if wrong type