diff --git a/docs/changelog.md b/docs/changelog.md index 6ed57efd..65b2b19e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,6 @@ --- description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version." -date_modified: 2026-07-15 +date_modified: 2026-07-16 --- # Changelog @@ -48,6 +48,11 @@ date_modified: 2026-07-15 - Fixed: dataset IO/export edge cases now avoid mutating caller-owned `Detections` during `DetectionDataset` construction, reject non-integer and out-of-range class ids with a clear `ValueError`, load COCO annotations that omit optional `iscrowd`/`area` fields, expose `DetectionDataset.from_coco(use_iscrowd=...)` without changing the existing positional `show_progress` argument, export mask pixel area to COCO when no stored area is present, ignore folder-structure root clutter and non-image files inside class folders, and accept PIL-readable YOLO images such as RGBA or palette PNGs. ### Added +- Added a cv2-free PyAV fallback for file-video capture, writing, frame seeking, + metadata, and `process_video(preserve_audio=True)` audio remuxing. OpenCV remains + the primary backend when available; `av>=14.2.0` is now required alongside + OpenCV during the transition, with the later OpenCV-removal integration removing + the OpenCV dependency. - Added a cv2-free Hershey text fallback covering all eight OpenCV font faces, italic variants, exact text metrics, and packaged glyph provenance. OpenCV remains the primary renderer when available; the fallback uses the private diff --git a/pyproject.toml b/pyproject.toml index 789d58a0..7d6efcdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "av>=14.2", "defusedxml>=0.7.1", "matplotlib>=3.6", "numpy>=1.21.2", @@ -191,8 +192,12 @@ mypy_path = "src" explicit_package_bases = true ignore_missing_imports = false python_version = "3.10" +warn_unused_ignores = true strict = true -overrides = [ { module = [ "examples.*", "tests.*" ], ignore_errors = true } ] +overrides = [ + { module = [ "examples.*", "tests.*" ], ignore_errors = true }, + { module = [ "supervision._cv2" ], warn_unused_ignores = false }, +] [tool.pytest] ini_options.testpaths = [ "src", "tests" ] diff --git a/src/supervision/_cv2/__init__.py b/src/supervision/_cv2/__init__.py index fad41803..820d2ebc 100644 --- a/src/supervision/_cv2/__init__.py +++ b/src/supervision/_cv2/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from supervision._cv2._color import _cvt_color, _merge, _split -from supervision._cv2._common import BackendUnavailableError, _unavailable +from supervision._cv2._common import BackendUnavailableError from supervision._cv2._components import ( _connected_components, _connected_components_with_stats, @@ -40,6 +40,11 @@ from supervision._cv2._transform import ( _get_rotation_matrix_2d, _warp_affine, ) +from supervision._cv2._video import ( + _video_writer_fourcc, + _VideoCapture, + _VideoWriter, +) from supervision._cv2.constants import ( _BORDER_CONSTANT, _CAP_PROP_FPS, @@ -82,7 +87,7 @@ else: _IS_CV2_AVAILABLE = True if _IS_CV2_AVAILABLE: - from cv2 import ( + from cv2 import ( # type: ignore[attr-defined] BORDER_CONSTANT, CAP_PROP_FPS, CAP_PROP_FRAME_COUNT, @@ -116,7 +121,7 @@ if _IS_CV2_AVAILABLE: RETR_TREE, VideoCapture, VideoWriter, - VideoWriter_fourcc, + VideoWriter_fourcc, # type: ignore[attr-defined] addWeighted, approxPolyDP, blur, @@ -185,39 +190,41 @@ else: LINE_AA = _LINE_AA RETR_TREE = _RETR_TREE - VideoCapture = _unavailable - VideoWriter = _unavailable - VideoWriter_fourcc = _unavailable - addWeighted = _add_weighted - approxPolyDP = _approx_poly_dp - blur = _blur - circle = _circle - connectedComponents = _connected_components - connectedComponentsWithStats = _connected_components_with_stats - contourArea = _contour_area - convertScaleAbs = _convert_scale_abs - copyMakeBorder = _copy_make_border - cvtColor = _cvt_color - distanceTransform = _distance_transform - drawContours = _draw_contours - ellipse = _ellipse - fillPoly = _fill_poly - findContours = _find_contours - flip = _flip - getRotationMatrix2D = _get_rotation_matrix_2d - getTextSize = _get_text_size - imread = _imread - imwrite = _imwrite - intersectConvexConvex = _intersect_convex_convex - line = _line - mean = _mean - merge = _merge - polylines = _polylines - putText = _put_text - rectangle = _rectangle - resize = _resize - split = _split - warpAffine = _warp_affine + # Fallback implementations when cv2 is not available. Suppress type errors because + # fallback types differ from cv2 types, but are functionally equivalent. + VideoCapture = _VideoCapture # type: ignore[assignment,misc] + VideoWriter = _VideoWriter # type: ignore[assignment,misc] + VideoWriter_fourcc = _video_writer_fourcc # type: ignore[assignment] + addWeighted = _add_weighted # type: ignore[assignment] + approxPolyDP = _approx_poly_dp # type: ignore[assignment] + blur = _blur # type: ignore[assignment] + circle = _circle # type: ignore[assignment] + connectedComponents = _connected_components # type: ignore[assignment] + connectedComponentsWithStats = _connected_components_with_stats # type: ignore[assignment] + contourArea = _contour_area # type: ignore[assignment] + convertScaleAbs = _convert_scale_abs # type: ignore[assignment] + copyMakeBorder = _copy_make_border # type: ignore[assignment] + cvtColor = _cvt_color # type: ignore[assignment] + distanceTransform = _distance_transform # type: ignore[assignment] + drawContours = _draw_contours # type: ignore[assignment] + ellipse = _ellipse # type: ignore[assignment] + fillPoly = _fill_poly # type: ignore[assignment] + findContours = _find_contours # type: ignore[assignment] + flip = _flip # type: ignore[assignment] + getRotationMatrix2D = _get_rotation_matrix_2d # type: ignore[assignment] + getTextSize = _get_text_size # type: ignore[assignment] + imread = _imread # type: ignore[assignment] + imwrite = _imwrite # type: ignore[assignment] + intersectConvexConvex = _intersect_convex_convex # type: ignore[assignment] + line = _line # type: ignore[assignment] + mean = _mean # type: ignore[assignment] + merge = _merge # type: ignore[assignment] + polylines = _polylines # type: ignore[assignment] + putText = _put_text # type: ignore[assignment] + rectangle = _rectangle # type: ignore[assignment] + resize = _resize # type: ignore[assignment] + split = _split # type: ignore[assignment] + warpAffine = _warp_affine # type: ignore[assignment] __all__ = [ diff --git a/src/supervision/_cv2/_video.py b/src/supervision/_cv2/_video.py new file mode 100644 index 00000000..2f70df6b --- /dev/null +++ b/src/supervision/_cv2/_video.py @@ -0,0 +1,397 @@ +"""Private PyAV-backed video and audio fallbacks.""" + +from __future__ import annotations + +import logging +import os +import tempfile +from collections.abc import Callable, Iterator +from fractions import Fraction +from pathlib import Path +from typing import Any + +import av +import numpy as np +import numpy.typing as npt + +from supervision._cv2._common import BackendUnavailableError +from supervision._cv2.constants import ( + _CAP_PROP_FPS, + _CAP_PROP_FRAME_COUNT, + _CAP_PROP_FRAME_HEIGHT, + _CAP_PROP_FRAME_WIDTH, + _CAP_PROP_POS_FRAMES, +) + +logger = logging.getLogger(__name__) + +_CODECS = { + "mp4v": ("mpeg4", "yuv420p"), + "xvid": ("mpeg4", "yuv420p"), + "avc1": ("libx264", "yuv420p"), + "h264": ("libx264", "yuv420p"), + "mjpg": ("mjpeg", "yuvj420p"), + "vp09": ("libvpx-vp9", "yuv420p"), +} + + +def _video_writer_fourcc(*chars: str) -> int: + """Encode four single-character strings using OpenCV's integer layout.""" + if len(chars) != 4 or any(len(char) != 1 for char in chars): + raise TypeError("VideoWriter_fourcc requires exactly four characters") + return sum(ord(char) << (8 * index) for index, char in enumerate(chars)) + + +def _decode_fourcc(fourcc: int) -> str: + """Decode a fourcc integer into its four-character representation.""" + return "".join(chr((fourcc >> (8 * index)) & 0xFF) for index in range(4)) + + +def _codec_details(fourcc: int) -> tuple[str, str]: + """Return the PyAV codec and pixel format for a supported fourcc.""" + code = _decode_fourcc(fourcc).lower() + try: + return _CODECS[code] + except KeyError as exc: + raise ValueError(f"Unsupported video codec: {code!r}") from exc + + +class _VideoCapture: + """Expose OpenCV-shaped file capture backed by PyAV decoding.""" + + def __init__(self, source: str | os.PathLike[str] | int) -> None: + """Open a file source and retain a lazy PyAV frame iterator.""" + self._container: Any = None + self._stream: Any = None + self._frames: Iterator[Any] | None = None + self._source = source + self._position = 0 + self._frame_count_cache: int | None = None + self._opened = False + self._error: Exception | None = None + + try: + if isinstance(source, int): + raise BackendUnavailableError( + "PyAV fallback supports file paths, not webcam device indexes." + ) + self._container = av.open(str(source), mode="r") + if not self._container.streams.video: + raise ValueError(f"Video source has no video stream: {source}") + self._stream = self._container.streams.video[0] + self._frames = iter(self._container.decode(video=self._stream.index)) + self._opened = True + except Exception as exc: + self._error = exc + logger.warning("Failed to open video source %r: %s", source, exc) + self.release() + + def isOpened(self) -> bool: + """Return whether the underlying video file is open for reading.""" + return self._opened + + def _frame_count(self) -> int: + """Return the stream count, decoding a second handle if metadata lacks it.""" + if self._frame_count_cache is not None: + return self._frame_count_cache + + count = int(getattr(self._stream, "frames", 0) or 0) + if count <= 0 and not isinstance(self._source, int): + container = av.open(str(self._source), mode="r") + try: + count = sum(1 for _ in container.decode(video=self._stream.index)) + finally: + container.close() + self._frame_count_cache = count + return count + + def get(self, property_id: int) -> float: + """Return the supported OpenCV capture property as a float.""" + if not self._opened: + return 0.0 + if property_id == _CAP_PROP_FRAME_WIDTH: + return float(self._stream.width) + if property_id == _CAP_PROP_FRAME_HEIGHT: + return float(self._stream.height) + if property_id == _CAP_PROP_FPS: + rate = getattr(self._stream, "average_rate", None) or getattr( + self._stream, "base_rate", None + ) + return float(rate) if rate is not None else 0.0 + if property_id == _CAP_PROP_FRAME_COUNT: + return float(self._frame_count()) + if property_id == _CAP_PROP_POS_FRAMES: + return float(self._position) + return 0.0 + + def _reset(self) -> None: + """Seek the decoder to the first frame and reset the logical position.""" + self._container.seek(0, stream=self._stream, backward=True) + self._frames = iter(self._container.decode(video=self._stream.index)) + self._position = 0 + + def set(self, property_id: int, value: float) -> bool: + """Set the supported frame-position property using exact frame decoding.""" + if not self._opened or property_id != _CAP_PROP_POS_FRAMES: + return False + target = max(0, round(value)) + if target > self._frame_count(): + return False + if target < self._position: + self._reset() + while self._position < target: + success, _ = self.read() + if not success: + return False + return True + + def read(self) -> tuple[bool, npt.NDArray[np.uint8] | None]: + """Decode and return the next frame in OpenCV's BGR array format.""" + if not self._opened or self._frames is None: + return False, None + try: + frame = next(self._frames) + except StopIteration: + return False, None + except Exception as exc: + self._error = exc + return False, None + + self._position += 1 + return True, frame.to_ndarray(format="bgr24") + + def grab(self) -> bool: + """Decode and discard one frame.""" + success, _ = self.read() + return success + + def release(self) -> None: + """Close the PyAV container and make subsequent reads return false.""" + container = self._container + self._container = None + self._stream = None + self._frames = None + self._opened = False + if container is not None: + container.close() + + +class _VideoWriter: + """Expose OpenCV-shaped video writing backed by PyAV encoding.""" + + def __init__( + self, + filename: str | os.PathLike[str], + fourcc: int, + fps: float, + frame_size: tuple[int, int], + is_color: bool = True, + ) -> None: + """Open a PyAV writer for the requested codec and frame dimensions. + + The PyAV fallback always encodes 3-channel BGR frames, so grayscale + output is unsupported. ``is_color=False`` is rejected up front rather + than silently ignored, keeping the OpenCV-shaped contract honest for + callers that would otherwise expect single-channel writes. + + Raises: + NotImplementedError: If ``is_color`` is ``False``; grayscale + writing is not supported by the PyAV fallback. + """ + if not is_color: + raise NotImplementedError( + "PyAV video fallback only supports color (3-channel BGR) frames; " + "is_color=False is not implemented." + ) + self._container: Any = None + self._stream: Any = None + self._width, self._height = frame_size + self._opened = False + self._error: Exception | None = None + + try: + codec, pixel_format = _codec_details(fourcc) + self._container = av.open(str(filename), mode="w") + rate = Fraction(str(fps)).limit_denominator(100_000) + self._stream = self._container.add_stream(codec, rate=rate) + self._stream.width = self._width + self._stream.height = self._height + self._stream.pix_fmt = pixel_format + self._opened = True + except Exception as exc: + self._error = exc + self.release() + + def isOpened(self) -> bool: + """Return whether the writer initialized successfully.""" + return self._opened + + def write(self, frame: npt.NDArray[np.uint8]) -> None: + """Encode one BGR frame and mux all packets produced by the encoder.""" + if not self._opened or self._container is None or self._stream is None: + raise RuntimeError("Video writer is not open") from self._error + if frame.shape != (self._height, self._width, 3): + raise ValueError( + "Video frame must have shape " + f"({self._height}, {self._width}, 3), got {frame.shape}" + ) + if frame.dtype != np.uint8: + raise ValueError("Video frames must use uint8 dtype") + + video_frame = av.VideoFrame.from_ndarray( + np.ascontiguousarray(frame), format="bgr24" + ) + for packet in self._stream.encode(video_frame): + self._container.mux(packet) + + def release(self) -> None: + """Flush delayed encoder packets and close the output container.""" + container = self._container + stream = self._stream + self._container = None + self._stream = None + self._opened = False + if container is None: + return + try: + if stream is not None: + for packet in stream.encode(): + container.mux(packet) + finally: + container.close() + + +def _copy_stream(container: Any, source_stream: Any) -> Any: + """Create an output stream with the codec parameters needed for remuxing.""" + codec = source_stream.codec_context.name + if source_stream.type == "video": + rate = source_stream.average_rate or source_stream.base_rate + else: + rate = source_stream.sample_rate + output_stream = container.add_stream(codec, rate=rate) + + if source_stream.type == "video": + output_stream.width = source_stream.width + output_stream.height = source_stream.height + if source_stream.codec_context.format is not None: + output_stream.pix_fmt = source_stream.codec_context.format.name + else: + output_stream.layout = source_stream.layout.name + + extradata = source_stream.codec_context.extradata + if extradata: + output_stream.codec_context.extradata = extradata + return output_stream + + +def _timestamp_seconds(timestamp: int | None, time_base: Any) -> float | None: + """Convert a stream timestamp to seconds while preserving missing values.""" + return None if timestamp is None else float(timestamp * time_base) + + +def _best_effort_cleanup(action: Callable[[], None], description: str) -> None: + """Run a cleanup action, logging and suppressing any failure. + + Cleanup steps in a ``finally`` block must never raise, otherwise a failing + ``container.close()`` (or file removal) would mask or replace the primary + result or the original exception that sent control into ``finally``. + """ + try: + action() + except Exception as exc: + logger.debug("Cleanup step failed (%s): %s", description, exc) + + +def _mux_audio(source_path: str, video_path: str) -> None: + """Remux the source's first audio stream into the processed video with PyAV.""" + source_container: Any = None + video_container: Any = None + output_container: Any = None + temporary_path: str | None = None + try: + source_container = av.open(source_path, mode="r") + video_container = av.open(video_path, mode="r") + if not source_container.streams.audio or not video_container.streams.video: + logger.info("No audio or video stream available; leaving output unchanged") + return + + source_audio = source_container.streams.audio[0] + target_video = video_container.streams.video[0] + suffix = Path(video_path).suffix + with tempfile.NamedTemporaryFile( + suffix=suffix, dir=str(Path(video_path).absolute().parent), delete=False + ) as temporary_file: + temporary_path = temporary_file.name + + output_container = av.open(temporary_path, mode="w") + output_video = _copy_stream(output_container, target_video) + output_audio = _copy_stream(output_container, source_audio) + + # Copy encoded packets to avoid a lossy decode/re-encode cycle for both streams. + video_base: int | None = None + video_duration: float | None = None + for packet in video_container.demux(target_video): + if packet.pts is None and packet.dts is None: + continue + if video_base is None: + video_base = packet.pts if packet.pts is not None else packet.dts + if video_base is None: + continue + packet.pts = None if packet.pts is None else packet.pts - video_base + packet.dts = None if packet.dts is None else packet.dts - video_base + packet.stream = output_video + output_container.mux(packet) + packet_end = packet.pts + if packet_end is not None: + packet_end += packet.duration or 0 + packet_seconds = _timestamp_seconds(packet_end, target_video.time_base) + if packet_seconds is not None: + video_duration = max(video_duration or 0.0, packet_seconds) + + # Rebase audio independently and stop at the processed video duration, matching + # ffmpeg's `-shortest` behavior without requiring a system executable. + audio_base: int | None = None + for packet in source_container.demux(source_audio): + if packet.pts is None and packet.dts is None: + continue + if audio_base is None: + audio_base = packet.pts if packet.pts is not None else packet.dts + if audio_base is None: + continue + relative_pts = None if packet.pts is None else packet.pts - audio_base + relative_seconds = _timestamp_seconds(relative_pts, source_audio.time_base) + if ( + video_duration is not None + and relative_seconds is not None + and relative_seconds > video_duration + ): + break + packet.pts = relative_pts + packet.dts = None if packet.dts is None else packet.dts - audio_base + packet.stream = output_audio + output_container.mux(packet) + + output_container.close() + output_container = None + video_container.close() + video_container = None + source_container.close() + source_container = None + os.replace(temporary_path, video_path) + temporary_path = None + except Exception as exc: + logger.warning("Audio remuxing failed: %s. Output video has no audio.", exc) + finally: + # Cleanup runs best-effort: a failing close/remove here must not mask the + # primary result or replace the original exception handled above. + if output_container is not None: + _best_effort_cleanup(output_container.close, "closing output container") + if video_container is not None: + _best_effort_cleanup(video_container.close, "closing video container") + if source_container is not None: + _best_effort_cleanup(source_container.close, "closing source container") + if temporary_path is not None and os.path.exists(temporary_path): + leftover_path = temporary_path + _best_effort_cleanup( + lambda: os.remove(leftover_path), "removing temporary file" + ) diff --git a/src/supervision/utils/video.py b/src/supervision/utils/video.py index 5cbc69f6..a492a47a 100644 --- a/src/supervision/utils/video.py +++ b/src/supervision/utils/video.py @@ -1,9 +1,5 @@ from __future__ import annotations -import os -import shutil -import subprocess -import tempfile import threading import time from collections import deque @@ -18,6 +14,7 @@ import numpy.typing as npt from tqdm.auto import tqdm from supervision import _cv2 as cv2 +from supervision._cv2._video import _mux_audio from supervision.utils.logger import _get_logger logger = _get_logger(__name__) @@ -170,73 +167,6 @@ class VideoSink: self.__writer = None -def _mux_audio(source_path: str, video_path: str) -> None: - """Mux audio from `source_path` into `video_path` in-place using ffmpeg. - - Args: - source_path: Path to the original video file containing the audio stream. - video_path: Path to the video-only file to be updated with audio. - """ - ffmpeg_path = shutil.which("ffmpeg") - if ffmpeg_path is None: - logger.warning( - "ffmpeg not found on PATH. Audio will not be preserved. " - "Install ffmpeg to enable audio preservation." - ) - return - - tmp_path = None - try: - tmp_fd, tmp_path = tempfile.mkstemp( - suffix=os.path.splitext(video_path)[1], - dir=os.path.dirname(os.path.abspath(video_path)), - ) - os.close(tmp_fd) - result = subprocess.run( # noqa: S603 - [ - ffmpeg_path, - "-y", - "-loglevel", - "error", - "-nostats", - "-i", - video_path, - "-i", - source_path, - "-c:v", - "copy", - "-c:a", - "copy", - "-map", - "0:v:0", - "-map", - "1:a:0?", - "-shortest", - tmp_path, - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - timeout=300, - ) - if result.returncode != 0: - stderr_msg = result.stderr.decode(errors="replace").strip() - logger.warning( - "ffmpeg failed to mux audio (return code %d)%s. " - "The output video will not have audio.", - result.returncode, - f": {stderr_msg}" if stderr_msg else "", - ) - return - os.replace(tmp_path, video_path) - except Exception as exc: - logger.warning( - "Audio muxing failed: %s. Output video will not have audio.", exc - ) - finally: - if tmp_path is not None and os.path.exists(tmp_path): - os.remove(tmp_path) - - def _validate_and_setup_video( source_path: str, start: int, end: int | None, iterative_seek: bool = False ) -> tuple[cv2.VideoCapture, int, int]: @@ -290,7 +220,11 @@ def get_video_frames_generator( Note: For live camera streams, use `cv2.VideoCapture` with an integer device index directly. `get_video_frames_generator` is designed for file-based - sources; `cv2.VideoCapture` must be released by the caller when done: + sources; `cv2.VideoCapture` must be released by the caller when done. + This requires OpenCV to be installed — the PyAV-based fallback used + when OpenCV is unavailable only supports file paths, not webcam device + indexes; passing an integer source to it always leaves the capture + closed (`isOpened()` returns `False`): ```python from supervision import _cv2 as cv2 @@ -379,11 +313,11 @@ def process_video( Default is False. progress_message: Description shown in the progress bar. preserve_audio: If True, copy the audio stream from `source_path` into - `target_path` after frame processing. Requires `ffmpeg` on PATH - (e.g. `apt install ffmpeg`, `brew install ffmpeg`). If ffmpeg is - not found or the mux step fails, a warning is logged and the output - video is saved without audio — no exception is raised. Audio is - truncated to match the processed video duration. Default is False. + `target_path` after frame processing. Remuxing is done with PyAV; + no external `ffmpeg` executable is required. If the mux step + fails, a warning is logged and the output video is saved without + audio — no exception is raised. Audio is truncated to match the + processed video duration. Default is False. Returns: None diff --git a/tests/cv2/test_video.py b/tests/cv2/test_video.py new file mode 100644 index 00000000..b1a79af1 --- /dev/null +++ b/tests/cv2/test_video.py @@ -0,0 +1,210 @@ +"""Tests for the PyAV-backed video compatibility surface.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import av +import numpy as np +import pytest + +from supervision import _cv2 +from supervision._cv2._video import ( + _mux_audio, + _VideoCapture, + _VideoWriter, +) + + +def _write_video(path: Path, values: list[int], fps: int = 5) -> None: + """Write a small deterministic MPEG-4 video for fallback tests.""" + container = av.open(str(path), mode="w") + stream = container.add_stream("mpeg4", rate=fps) + stream.width = 16 + stream.height = 16 + stream.pix_fmt = "yuv420p" + try: + for value in values: + frame = av.VideoFrame.from_ndarray( + np.full((16, 16, 3), value, dtype=np.uint8), format="bgr24" + ) + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + finally: + container.close() + + +def _write_video_with_audio(path: Path, frame_count: int = 5, fps: int = 5) -> None: + """Write a short video with one AAC audio stream for remux tests.""" + container = av.open(str(path), mode="w") + video_stream = container.add_stream("mpeg4", rate=fps) + video_stream.width = 16 + video_stream.height = 16 + video_stream.pix_fmt = "yuv420p" + audio_stream = container.add_stream("aac", rate=8_000) + audio_stream.layout = "mono" + try: + for value in range(frame_count): + frame = av.VideoFrame.from_ndarray( + np.full((16, 16, 3), value * 20, dtype=np.uint8), format="bgr24" + ) + for packet in video_stream.encode(frame): + container.mux(packet) + + samples = np.zeros((1, 8_000), dtype=np.int16) + audio_frame = av.AudioFrame.from_ndarray(samples, format="s16", layout="mono") + audio_frame.sample_rate = 8_000 + audio_frame.pts = 0 + for packet in audio_stream.encode(audio_frame): + container.mux(packet) + for packet in video_stream.encode(): + container.mux(packet) + for packet in audio_stream.encode(): + container.mux(packet) + finally: + container.close() + + +def _run_without_opencv(source: str) -> None: + """Run a Python snippet with cv2 imports blocked.""" + env = os.environ.copy() + source_path = str(Path(__file__).resolve().parents[2] / "src") + env["PYTHONPATH"] = os.pathsep.join( + filter(None, (source_path, env.get("PYTHONPATH"))) + ) + result = subprocess.run( # noqa: S603 + [sys.executable, "-c", source], + check=False, + capture_output=True, + text=True, + env=env, + timeout=60, + ) + assert result.returncode == 0, result.stderr + + +def test_video_module_uses_required_pyav_dependency() -> None: + """The video fallback imports required PyAV directly without a loader.""" + from supervision._cv2 import _video + + assert _video.av.__name__ == "av" + assert not hasattr(_video, "_load_av") + + +def test_fallback_capture_reports_metadata_and_supports_exact_seek( + tmp_path: Path, +) -> None: + """Fallback capture exposes metadata and starts decoding at requested frames.""" + source_path = tmp_path / "source.mp4" + _write_video(source_path, [0, 40, 80, 120, 160]) + + capture = _VideoCapture(str(source_path)) + assert capture.isOpened() + assert capture.get(_cv2.CAP_PROP_FRAME_WIDTH) == 16 + assert capture.get(_cv2.CAP_PROP_FRAME_HEIGHT) == 16 + assert capture.get(_cv2.CAP_PROP_FPS) == pytest.approx(5.0) + assert capture.get(_cv2.CAP_PROP_FRAME_COUNT) == 5 + + assert capture.set(_cv2.CAP_PROP_POS_FRAMES, 3) + success, frame = capture.read() + capture.release() + + assert success + assert frame is not None + assert frame.shape == (16, 16, 3) + assert float(frame.mean()) == pytest.approx(120.0, abs=20.0) + assert not capture.isOpened() + + +def test_fallback_writer_default_codec_round_trips(tmp_path: Path) -> None: + """The guaranteed mp4v fallback writer creates a readable video.""" + target_path = tmp_path / "target.mp4" + fourcc = _cv2.VideoWriter_fourcc(*"mp4v") + writer = _VideoWriter(str(target_path), fourcc, 5.0, (16, 16)) + + assert writer.isOpened() + for value in [0, 40, 80]: + writer.write(np.full((16, 16, 3), value, dtype=np.uint8)) + writer.release() + + capture = _VideoCapture(str(target_path)) + frames = [] + while True: + success, frame = capture.read() + if not success: + break + assert frame is not None + frames.append(frame) + capture.release() + + assert len(frames) == 3 + assert target_path.stat().st_size > 0 + + +def test_fallback_video_works_when_opencv_is_blocked(tmp_path: Path) -> None: + """Production video APIs use PyAV when cv2 cannot be imported.""" + source_path = tmp_path / "source.mp4" + target_path = tmp_path / "target.mp4" + _write_video(source_path, [0, 40, 80]) + source = f""" +import sys + + +class BlockCv2: + def find_spec(self, fullname, path=None, target=None): + if fullname == "cv2": + raise ModuleNotFoundError("blocked for test") + return None + + +sys.meta_path.insert(0, BlockCv2()) +from supervision import _cv2 +from supervision.utils.video import VideoInfo, VideoSink, get_video_frames_generator + +assert _cv2._IS_CV2_AVAILABLE is False +info = VideoInfo.from_video_path({str(source_path)!r}) +assert (info.width, info.height, info.total_frames) == (16, 16, 3) +frames = list(get_video_frames_generator({str(source_path)!r}, start=1, end=3)) +assert len(frames) == 2 +with VideoSink({str(target_path)!r}, info) as sink: + for frame in frames: + sink.write_frame(frame) +assert _cv2.VideoCapture({str(target_path)!r}).isOpened() +""" + _run_without_opencv(source) + + +def test_mux_audio_remuxes_first_audio_stream_and_truncates_to_video( + tmp_path: Path, +) -> None: + """Audio remuxing uses PyAV and keeps the processed video duration.""" + source_path = tmp_path / "source_with_audio.mp4" + target_path = tmp_path / "target.mp4" + _write_video_with_audio(source_path, frame_count=5) + _write_video(target_path, [0, 40, 80], fps=5) + + _mux_audio(str(source_path), str(target_path)) + + output = av.open(str(target_path)) + try: + assert len(output.streams.video) == 1 + assert len(output.streams.audio) == 1 + assert output.streams.video[0].frames == 3 + finally: + output.close() + + +def test_mux_audio_leaves_target_unchanged_on_failure(tmp_path: Path) -> None: + """A failed PyAV remux never replaces the existing target file.""" + target_path = tmp_path / "target.mp4" + _write_video(target_path, [0, 40, 80]) + original = target_path.read_bytes() + + _mux_audio(str(tmp_path / "missing.mp4"), str(target_path)) + + assert target_path.read_bytes() == original diff --git a/tests/utils/test_video.py b/tests/utils/test_video.py index 772735cb..ade432b7 100644 --- a/tests/utils/test_video.py +++ b/tests/utils/test_video.py @@ -1,10 +1,9 @@ import os -import shutil from pathlib import Path from queue import Empty, Full from queue import Queue as StdQueue from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch import numpy as np import pytest @@ -13,7 +12,6 @@ from supervision import _cv2 as cv2 from supervision.utils.video import ( FPSMonitor, VideoInfo, - _mux_audio, get_video_frames_generator, process_video, ) @@ -625,59 +623,6 @@ def test_process_video_no_audio_by_default(dummy_video_path, tmp_path) -> None: mock_mux.assert_not_called() -@pytest.mark.parametrize( - ("which_rv", "run_kwargs"), - [ - pytest.param(None, {}, id="ffmpeg_missing"), - pytest.param( - "/usr/bin/ffmpeg", - {"return_value": MagicMock(returncode=1, stderr=b"")}, - id="ffmpeg_fails", - ), - pytest.param( - "/usr/bin/ffmpeg", - {"side_effect": OSError("mux failed")}, - id="subprocess_raises", - ), - ], -) -def test_mux_audio_file_unchanged_on_failure( - dummy_video_path, tmp_path, which_rv, run_kwargs -) -> None: - """_mux_audio leaves the output file unchanged when muxing cannot complete.""" - target_path = str(tmp_path / "video.mp4") - shutil.copy(dummy_video_path, target_path) - original_size = os.path.getsize(target_path) - - with ( - patch("supervision.utils.video.shutil.which", return_value=which_rv), - patch("supervision.utils.video.subprocess.run", **run_kwargs), - ): - _mux_audio(source_path=dummy_video_path, video_path=target_path) - - assert os.path.getsize(target_path) == original_size - - -def test_mux_audio_replaces_file_on_success(dummy_video_path, tmp_path) -> None: - """_mux_audio calls os.replace with video_path as destination on success.""" - target_path = str(tmp_path / "video.mp4") - shutil.copy(dummy_video_path, target_path) - - success_result = MagicMock() - success_result.returncode = 0 - success_result.stderr = b"" - - with ( - patch("supervision.utils.video.shutil.which", return_value="/usr/bin/ffmpeg"), - patch("supervision.utils.video.subprocess.run", return_value=success_result), - patch("supervision.utils.video.os.replace") as mock_replace, - ): - _mux_audio(source_path=dummy_video_path, video_path=target_path) - - mock_replace.assert_called_once() - assert mock_replace.call_args[0][1] == target_path - - def test_get_video_frames_generator_with_start_end(dummy_video_path) -> None: """ Verify that get_video_frames_generator respects start and end frame indices. diff --git a/uv.lock b/uv.lock index 1b8bcca5..9c3a6e8a 100644 --- a/uv.lock +++ b/uv.lock @@ -123,6 +123,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "av" +version = "17.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/92/c9d0cea4f6f8f93f5b15a39f99d2d593f922484f22a2d98a8d482283e15b/av-17.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:19c84fd72af5ef81a20f18fbc6f9aedff9e1455e53a7062c1d4c95926d73da4e", size = 22622703, upload-time = "2026-06-07T05:51:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/dc/57/74399770aa103ee4b5ff6da1781440c91a41901d89abb2433fe88773246e/av-17.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:19264c9bb4bee404accc7ce9ec461f2044b7f577a70234d29aafde31ed17de46", size = 18273538, upload-time = "2026-06-07T05:51:43.078Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/27c85b12e9ffa8f3f6854358b3eabcd91f3c29c7dac36843fa1376e833f4/av-17.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22dff0ae582d10ef08c75c2150a4fd27cfc26653b54930c7c27b9f7b3aa20723", size = 34519101, upload-time = "2026-06-07T05:51:45.305Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/542d4bfd9f4aec5f3265985b9dbc6b259d45c2e668f9714e5f4e05b71e64/av-17.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:90c49bc9608377d01e82e747377505419a229464873341db18202d5dddecce5a", size = 36647600, upload-time = "2026-06-07T05:51:48.57Z" }, + { url = "https://files.pythonhosted.org/packages/63/1e/63bd5c59580f38109fa4c452b29b715a20c9a5eb3a078b3c447484593c40/av-17.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cc5a5247622cb77e24c342364eb68f88c1442ddfaab60c1f1f483359d3cc7879", size = 25786289, upload-time = "2026-06-07T05:51:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/70/30/78155cef0c9f8bc13f044130192c58bf962f2c9066982ff3593afe8d27f1/av-17.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff457ed419348e5b8e8c811d341389b052c5e4d5839da3794d019b125b9fe830", size = 35599848, upload-time = "2026-06-07T05:51:54.207Z" }, + { url = "https://files.pythonhosted.org/packages/76/cb/ae1d7a735a5ad9dc502dba864c51d605cbe932a769218352fd570254c38e/av-17.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1370b11a697eb3f2555906f8ab3519b0cfe48425d7830a3996ad42e6bffafda5", size = 26776479, upload-time = "2026-06-07T05:51:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/fb/40/128429b9eb0c4a2beb122ed8d04b189515df68967987c2654a2e262a5c43/av-17.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd41e53f53f9a3260751d9c3c11d34e93d70d61e506c81f13dbc1e3606e07b", size = 37763744, upload-time = "2026-06-07T05:51:59.222Z" }, + { url = "https://files.pythonhosted.org/packages/01/6a/5980e7bbeeadfd7a9db8e38e9f1140a3e0c392fccc31bd7b1e4a75cf5a96/av-17.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3453b06075c7bb973fdb6de52563f7692ff05cbc64c0bb45f4fd6e8709131f2f", size = 28126516, upload-time = "2026-06-07T05:52:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" }, + { url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/53a7cd34adb6a971d7e6d99663e74db286966c9db8afdca17472fdf0f98e/av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c", size = 28036393, upload-time = "2026-06-07T05:52:25.657Z" }, + { url = "https://files.pythonhosted.org/packages/66/47/cd9ae0edf2206351c1251bb94b5ec58728e42c5f6ee16c03c412f3a1bb3e/av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6", size = 21174601, upload-time = "2026-06-07T05:52:28.077Z" }, + { url = "https://files.pythonhosted.org/packages/36/90/b5668cddb3c401fcf22553bc495d5b0c6d8a01d118624b26f0db1d0b8653/av-17.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240", size = 22699499, upload-time = "2026-06-07T05:52:30.335Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7e/7be6bfddb823d045ff9fd5d4deb922ee3847605e162c3882e6c45b4c35ff/av-17.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa", size = 18366696, upload-time = "2026-06-07T05:52:32.645Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/391dcfa75c1ae1977efca44b753a11b929399b558826670c16a8808dd0e3/av-17.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c", size = 36582649, upload-time = "2026-06-07T05:52:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/fb/32/7312854868b318b9d1b1dcbd1bddb460aaaeac7d57f816e11efec3bef5b1/av-17.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63", size = 38479390, upload-time = "2026-06-07T05:52:37.968Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/af47f59b4458e81ca7d89f477698dbfb3d5a0cd8ae6c1e4441d01074af8a/av-17.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a", size = 27127432, upload-time = "2026-06-07T05:52:40.371Z" }, + { url = "https://files.pythonhosted.org/packages/88/85/c2e6861baf0f8c7d21c4ce811d4d424fedac915e3910d3570ce4377717dc/av-17.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19", size = 37406592, upload-time = "2026-06-07T05:52:43.215Z" }, + { url = "https://files.pythonhosted.org/packages/ba/40/3cc13125aea976101c0858af99ac47257c0654411aa199b5d8e81eea7002/av-17.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b", size = 28336228, upload-time = "2026-06-07T05:52:46.134Z" }, + { url = "https://files.pythonhosted.org/packages/a2/38/c7d9c3e746209a1a695c13e3aa7d817229e84a85d0a84271f313d1befdd3/av-17.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02", size = 39490680, upload-time = "2026-06-07T05:52:48.887Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/9d42da561b7b8f7dabdfaebba07b52977bee58c5c7e4285ac991abcfaa72/av-17.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f", size = 28355673, upload-time = "2026-06-07T05:52:51.614Z" }, + { url = "https://files.pythonhosted.org/packages/a8/41/562a61d5a61fba3ffb273a115e249f1d8471b9515c59fcc38b4b9deda238/av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4", size = 21324700, upload-time = "2026-06-07T05:52:53.972Z" }, +] + +[[package]] +name = "av" +version = "18.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" }, + { url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" }, + { url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" }, + { url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" }, + { url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" }, +] + [[package]] name = "babel" version = "2.17.0" @@ -3370,6 +3446,8 @@ name = "supervision" version = "0.30.0.dev0" source = { editable = "." } dependencies = [ + { name = "av", version = "17.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "defusedxml" }, { name = "matplotlib" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3423,6 +3501,7 @@ docs = [ [package.metadata] requires-dist = [ + { name = "av", specifier = ">=14.2.0" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "matplotlib", specifier = ">=3.6" }, { name = "numpy", specifier = ">=1.21.2" },