Skip to content

Commit ed13d64

Browse files
authored
Merge branch 'develop' into docs/improve-detect-and-annotate-tutorial
2 parents 41c5869 + 7e28315 commit ed13d64

3 files changed

Lines changed: 211 additions & 7 deletions

File tree

.github/workflows/publish-docs.yml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,29 @@ jobs:
6464
run: |
6565
mike deploy --push latest
6666
67+
- name: 🏷️ Determine release deployment metadata
68+
id: release_metadata
69+
run: |
70+
is_rc=false
71+
release_tag=""
72+
if [[ "$GITHUB_EVENT_NAME" == "release" ]]; then
73+
release_tag="${GITHUB_REF_NAME#v}"
74+
release_tag="${release_tag%.post*}"
75+
release_tag_lower="${release_tag,,}"
76+
# Match RC suffixes with separators (1.0-rc1, 1.0.rc1) or compact form (1.0rc1).
77+
if [[ "$release_tag_lower" =~ (^|[._-])rc[0-9]+$ ]] || [[ "$release_tag_lower" =~ [0-9]rc[0-9]+$ ]]; then
78+
is_rc=true
79+
fi
80+
fi
81+
echo "is_rc=$is_rc" >> "$GITHUB_OUTPUT"
82+
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
83+
6784
- name: 🚀 Deploy Release Docs
68-
if: github.event_name == 'release' && github.event.action == 'published'
85+
if: github.event_name == 'release' && github.event.action == 'published' && steps.release_metadata.outputs.is_rc != 'true'
6986
env:
7087
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.GITHUB_TOKEN }}
7188
run: |
72-
release_tag="${GITHUB_REF_NAME#v}"
73-
mike deploy --push "$release_tag"
89+
mike deploy --push "${{ steps.release_metadata.outputs.release_tag }}"
7490
7591
# IndexNow key: 0d5d9799b1cc4a39825146388c6781eb
7692
# This key must stay in sync across three files:
@@ -84,7 +100,7 @@ jobs:
84100
(github.event_name == 'push' && github.ref == 'refs/heads/develop') ||
85101
github.event_name == 'workflow_dispatch' ||
86102
(github.event_name == 'push' && github.ref == 'refs/heads/release/latest') ||
87-
(github.event_name == 'release' && github.event.action == 'published')
103+
(github.event_name == 'release' && github.event.action == 'published' && steps.release_metadata.outputs.is_rc != 'true')
88104
run: |
89105
cp docs/robots.txt /tmp/robots.txt
90106
cp docs/llms.txt /tmp/llms.txt

src/supervision/utils/video.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from __future__ import annotations
22

3+
import os
4+
import shutil
5+
import subprocess
6+
import tempfile
37
import threading
48
import time
59
from collections import deque
@@ -133,6 +137,73 @@ def __exit__(
133137
self.__writer.release()
134138

135139

140+
def _mux_audio(source_path: str, video_path: str) -> None:
141+
"""Mux audio from `source_path` into `video_path` in-place using ffmpeg.
142+
143+
Args:
144+
source_path: Path to the original video file containing the audio stream.
145+
video_path: Path to the video-only file to be updated with audio.
146+
"""
147+
ffmpeg_path = shutil.which("ffmpeg")
148+
if ffmpeg_path is None:
149+
logger.warning(
150+
"ffmpeg not found on PATH. Audio will not be preserved. "
151+
"Install ffmpeg to enable audio preservation."
152+
)
153+
return
154+
155+
tmp_path = None
156+
try:
157+
tmp_fd, tmp_path = tempfile.mkstemp(
158+
suffix=os.path.splitext(video_path)[1],
159+
dir=os.path.dirname(os.path.abspath(video_path)),
160+
)
161+
os.close(tmp_fd)
162+
result = subprocess.run( # noqa: S603
163+
[
164+
ffmpeg_path,
165+
"-y",
166+
"-loglevel",
167+
"error",
168+
"-nostats",
169+
"-i",
170+
video_path,
171+
"-i",
172+
source_path,
173+
"-c:v",
174+
"copy",
175+
"-c:a",
176+
"copy",
177+
"-map",
178+
"0:v:0",
179+
"-map",
180+
"1:a:0?",
181+
"-shortest",
182+
tmp_path,
183+
],
184+
stdout=subprocess.DEVNULL,
185+
stderr=subprocess.PIPE,
186+
timeout=300,
187+
)
188+
if result.returncode != 0:
189+
stderr_msg = result.stderr.decode(errors="replace").strip()
190+
logger.warning(
191+
"ffmpeg failed to mux audio (return code %d)%s. "
192+
"The output video will not have audio.",
193+
result.returncode,
194+
f": {stderr_msg}" if stderr_msg else "",
195+
)
196+
return
197+
os.replace(tmp_path, video_path)
198+
except Exception as exc:
199+
logger.warning(
200+
"Audio muxing failed: %s. Output video will not have audio.", exc
201+
)
202+
finally:
203+
if tmp_path is not None and os.path.exists(tmp_path):
204+
os.remove(tmp_path)
205+
206+
136207
def _validate_and_setup_video(
137208
source_path: str, start: int, end: int | None, iterative_seek: bool = False
138209
) -> tuple[cv2.VideoCapture, int, int]:
@@ -219,6 +290,7 @@ def process_video(
219290
writer_buffer: int = 32,
220291
show_progress: bool = False,
221292
progress_message: str = "Processing video",
293+
preserve_audio: bool = False,
222294
) -> None:
223295
"""
224296
Process video frames asynchronously using a threaded pipeline.
@@ -252,13 +324,18 @@ def process_video(
252324
show_progress: Whether to display a tqdm progress bar during processing.
253325
Default is False.
254326
progress_message: Description shown in the progress bar.
327+
preserve_audio: If True, copy the audio stream from `source_path` into
328+
`target_path` after frame processing. Requires `ffmpeg` on PATH
329+
(e.g. `apt install ffmpeg`, `brew install ffmpeg`). If ffmpeg is
330+
not found or the mux step fails, a warning is logged and the output
331+
video is saved without audio — no exception is raised. Audio is
332+
truncated to match the processed video duration. Default is False.
255333
256334
Returns:
257335
None
258336
259337
Example:
260338
```python
261-
import cv2
262339
import supervision as sv
263340
from rfdetr import RFDETRMedium
264341
@@ -267,10 +344,11 @@ def process_video(
267344
def callback(frame, frame_index):
268345
return model.predict(frame)
269346
270-
process_video(
347+
sv.process_video(
271348
source_path="source.mp4",
272349
target_path="target.mp4",
273350
callback=callback,
351+
preserve_audio=True,
274352
)
275353
```
276354
"""
@@ -368,6 +446,15 @@ def writer_thread(video_sink: VideoSink) -> None:
368446
if exception_in_worker is not None:
369447
raise exception_in_worker
370448

449+
if preserve_audio:
450+
if writer_worker.is_alive():
451+
logger.warning(
452+
"Writer thread did not finish in time; skipping audio mux "
453+
"to avoid reading an incomplete output file."
454+
)
455+
else:
456+
_mux_audio(source_path=source_path, video_path=target_path)
457+
371458

372459
class FPSMonitor:
373460
"""

tests/utils/test_video.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import os
2+
import shutil
3+
from unittest.mock import MagicMock, patch
24

35
import cv2
46
import numpy as np
57
import pytest
68

7-
from supervision.utils.video import VideoInfo, get_video_frames_generator, process_video
9+
from supervision.utils.video import (
10+
VideoInfo,
11+
_mux_audio,
12+
get_video_frames_generator,
13+
process_video,
14+
)
815

916

1017
@pytest.fixture
@@ -206,6 +213,100 @@ def test_get_video_frames_generator_with_stride(dummy_video_path):
206213
assert len(frames) == 5
207214

208215

216+
def test_process_video_preserve_audio_calls_mux(dummy_video_path, tmp_path):
217+
"""
218+
Verify that process_video calls _mux_audio when preserve_audio=True.
219+
220+
Scenario: Processing a video with preserve_audio=True and ffmpeg available.
221+
Expected: _mux_audio is called exactly once with the correct source and target
222+
paths, confirming the audio muxing step is triggered after frame writing completes.
223+
"""
224+
target_path = str(tmp_path / "target_audio.mp4")
225+
226+
with patch("supervision.utils.video._mux_audio") as mock_mux:
227+
process_video(
228+
source_path=dummy_video_path,
229+
target_path=target_path,
230+
callback=lambda frame, idx: frame,
231+
preserve_audio=True,
232+
)
233+
mock_mux.assert_called_once_with(
234+
source_path=dummy_video_path, video_path=target_path
235+
)
236+
237+
238+
def test_process_video_no_audio_by_default(dummy_video_path, tmp_path):
239+
"""
240+
Verify that process_video does not call _mux_audio when preserve_audio=False.
241+
242+
Scenario: Default process_video call without setting preserve_audio.
243+
Expected: _mux_audio is never called, preserving existing behavior for callers
244+
that do not need audio.
245+
"""
246+
target_path = str(tmp_path / "target_no_audio.mp4")
247+
248+
with patch("supervision.utils.video._mux_audio") as mock_mux:
249+
process_video(
250+
source_path=dummy_video_path,
251+
target_path=target_path,
252+
callback=lambda frame, idx: frame,
253+
)
254+
mock_mux.assert_not_called()
255+
256+
257+
@pytest.mark.parametrize(
258+
("which_rv", "run_kwargs"),
259+
[
260+
pytest.param(None, {}, id="ffmpeg_missing"),
261+
pytest.param(
262+
"/usr/bin/ffmpeg",
263+
{"return_value": MagicMock(returncode=1, stderr=b"")},
264+
id="ffmpeg_fails",
265+
),
266+
pytest.param(
267+
"/usr/bin/ffmpeg",
268+
{"side_effect": OSError("mux failed")},
269+
id="subprocess_raises",
270+
),
271+
],
272+
)
273+
def test_mux_audio_file_unchanged_on_failure(
274+
dummy_video_path, tmp_path, which_rv, run_kwargs
275+
):
276+
"""_mux_audio leaves the output file unchanged when muxing cannot complete."""
277+
target_path = str(tmp_path / "video.mp4")
278+
shutil.copy(dummy_video_path, target_path)
279+
original_size = os.path.getsize(target_path)
280+
281+
with (
282+
patch("supervision.utils.video.shutil.which", return_value=which_rv),
283+
patch("supervision.utils.video.subprocess.run", **run_kwargs),
284+
):
285+
_mux_audio(source_path=dummy_video_path, video_path=target_path)
286+
287+
assert os.path.getsize(target_path) == original_size
288+
289+
290+
def test_mux_audio_replaces_file_on_success(dummy_video_path, tmp_path):
291+
"""_mux_audio calls os.replace with video_path as destination on success."""
292+
target_path = str(tmp_path / "video.mp4")
293+
shutil.copy(dummy_video_path, target_path)
294+
295+
success_result = MagicMock()
296+
success_result.returncode = 0
297+
success_result.stderr = b""
298+
299+
with (
300+
patch("supervision.utils.video.shutil.which", return_value="/usr/bin/ffmpeg"),
301+
patch("supervision.utils.video.subprocess.run", return_value=success_result),
302+
patch("supervision.utils.video.os.replace") as mock_replace,
303+
):
304+
_mux_audio(source_path=dummy_video_path, video_path=target_path)
305+
306+
mock_replace.assert_called_once()
307+
assert mock_replace.call_args[0][1] == target_path
308+
309+
209310
def test_get_video_frames_generator_with_start_end(dummy_video_path):
210311
"""
211312
Verify that get_video_frames_generator respects start and end frame indices.

0 commit comments

Comments
 (0)