mirror of
https://github.com/deepfakes/faceswap.git
synced 2026-10-17 05:21:10 +08:00
0a0d1fabf0
- ffmpeg writer: Exit audio buffer when no audio available - convert: correctly apply y-offset - Fix color adjust when "none" selected
828 lines
32 KiB
Python
828 lines
32 KiB
Python
#!/usr/bin python3
|
|
"""Utilities for working with videos"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import typing as T
|
|
|
|
from collections import deque
|
|
from fractions import Fraction
|
|
from math import ceil
|
|
|
|
import av
|
|
import av.error
|
|
import av.filter
|
|
import av.logging
|
|
import ffmpeg
|
|
import numpy as np
|
|
from tqdm import tqdm
|
|
|
|
from lib.logger import parse_class_init
|
|
from lib.utils import convert_to_secs, FaceswapError, get_module_objects
|
|
|
|
|
|
if T.TYPE_CHECKING:
|
|
from av.container import InputContainer, OutputContainer
|
|
import numpy.typing as npt
|
|
|
|
logger = logging.getLogger(__name__)
|
|
av.logging.set_level(av.logging.VERBOSE)
|
|
logging.getLogger("libav").setLevel(logger.getEffectiveLevel())
|
|
|
|
|
|
VIDEO_EXTENSIONS = [".avi", ".flv", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm", ".wmv",
|
|
".ts", ".vob"]
|
|
"""List of lowercase valid Video extensions with preceding period"""
|
|
|
|
|
|
def check_for_video(input_location: str) -> bool:
|
|
"""Check whether the given input is a video file or a folder
|
|
|
|
Parameters
|
|
----------
|
|
input_location
|
|
Full path to an input file
|
|
|
|
Returns
|
|
-------
|
|
bool: 'True' if input is a video 'False' if it is a folder.
|
|
|
|
Raises
|
|
------
|
|
FaceswapError
|
|
If the given location is a file and does not have a valid video extension.
|
|
"""
|
|
if not isinstance(input_location, str) or os.path.isdir(input_location):
|
|
retval = False
|
|
elif os.path.splitext(input_location)[1].lower() in VIDEO_EXTENSIONS:
|
|
retval = True
|
|
else:
|
|
raise FaceswapError(f"The input file '{input_location}' is not a valid video")
|
|
logger.debug("Input '%s' is_video: %s", input_location, retval)
|
|
return retval
|
|
|
|
|
|
def validate_video_file(file_path: str) -> str:
|
|
"""Validates that a given file exists and is a valid video format
|
|
|
|
Parameters
|
|
----------
|
|
file_path
|
|
The full path to the video file to validate
|
|
|
|
Returns
|
|
-------
|
|
The full expanded video file path
|
|
|
|
Raises
|
|
------
|
|
FaceswapError
|
|
If the given video file is not valid
|
|
"""
|
|
file_path = os.path.expanduser(os.path.abspath(file_path))
|
|
if not os.path.isfile(file_path):
|
|
raise FaceswapError(f"Video file '{file_path}' does not exist")
|
|
if os.path.splitext(file_path)[-1].lower() not in VIDEO_EXTENSIONS:
|
|
raise FaceswapError(f"File '{file_path}' is not a valid video file")
|
|
return file_path
|
|
|
|
|
|
# TODO look for instances of this and see if we can roll it into VideoInfo
|
|
def count_frames(filename, fast=False):
|
|
""" Count the number of frames in a video file
|
|
|
|
There is no guaranteed accurate way to get a count of video frames without iterating through
|
|
a video and decoding every frame.
|
|
|
|
:func:`count_frames` can return an accurate count (albeit fairly slowly) or a possibly less
|
|
accurate count, depending on the :attr:`fast` parameter. A progress bar is displayed.
|
|
|
|
Parameters
|
|
----------
|
|
filename: str
|
|
Full path to the video to return the frame count from.
|
|
fast: bool, optional
|
|
Whether to count the frames without decoding them. This is significantly faster but
|
|
accuracy is not guaranteed. Default: ``False``.
|
|
|
|
Returns
|
|
-------
|
|
int:
|
|
The number of frames in the given video file.
|
|
|
|
Example
|
|
-------
|
|
>>> filename = "/path/to/video.mp4"
|
|
>>> frame_count = count_frames(filename)
|
|
"""
|
|
logger.debug("filename: %s, fast: %s", filename, fast)
|
|
assert isinstance(filename, str), "Video path must be a string"
|
|
cmd = [str(ffmpeg.FFMPEG_PATH), "-i", filename, "-map", "0:v:0"]
|
|
if fast:
|
|
cmd.extend(["-c", "copy"])
|
|
cmd.extend(["-f", "null", "-"])
|
|
|
|
logger.debug("FFMPEG Command: '%s'", " ".join(cmd))
|
|
process = subprocess.Popen(cmd,
|
|
stderr=subprocess.STDOUT,
|
|
stdout=subprocess.PIPE,
|
|
universal_newlines=True, encoding="utf8")
|
|
p_bar = None
|
|
duration = None
|
|
update = 0
|
|
frames = 0
|
|
stdout = process.stdout
|
|
assert stdout is not None
|
|
while True:
|
|
|
|
output = stdout.readline().strip()
|
|
if output == "" and process.poll() is not None:
|
|
break
|
|
|
|
if output.startswith("Duration:"):
|
|
logger.debug("Duration line: %s", output)
|
|
idx = output.find("Duration:") + len("Duration:")
|
|
duration = int(convert_to_secs(*output[idx:].split(",", 1)[0].strip().split(":")))
|
|
logger.debug("duration: %s", duration)
|
|
if output.startswith("frame="):
|
|
logger.debug("frame line: %s", output)
|
|
if p_bar is None:
|
|
logger.debug("Initializing tqdm")
|
|
p_bar = tqdm(desc="Analyzing Video", leave=False, total=duration, unit="secs")
|
|
time_idx = output.find("time=") + len("time=")
|
|
frame_idx = output.find("frame=") + len("frame=")
|
|
frames = int(output[frame_idx:].strip().split(" ")[0].strip())
|
|
vid_time = int(convert_to_secs(*output[time_idx:].split(" ")[0].strip().split(":")))
|
|
logger.debug("frames: %s, vid_time: %s", frames, vid_time)
|
|
prev_update = update
|
|
update = vid_time
|
|
p_bar.update(update - prev_update)
|
|
if p_bar is not None:
|
|
p_bar.close()
|
|
return_code = process.poll()
|
|
logger.debug("Return code: %s, frames: %s", return_code, frames)
|
|
return frames
|
|
|
|
|
|
class VideoInfo:
|
|
"""Collects and stores information about video files
|
|
|
|
Parameters
|
|
----------
|
|
video_file
|
|
Full path to a video file
|
|
fast_count
|
|
Whether to obtain the count of frames quickly, but inaccurately or slowly but accurately.
|
|
If pts and keyframes are provided then the count will be derived from the provided pts
|
|
file. Default: ``True``
|
|
stream_index
|
|
The stream index to select from the video file. Default: 0
|
|
pts
|
|
The Presentation Timestamps if available or ``None`` to retrieve from the video.
|
|
Default: ``None``
|
|
keyframes
|
|
The keyframe frame indices if available or ``None`` to retrieve from the video.
|
|
Default: ``None``
|
|
"""
|
|
def __init__(self,
|
|
video_file: str,
|
|
fast_count: bool = True,
|
|
stream_index: int = 0,
|
|
pts: list[int] | None = None,
|
|
keyframes: list[int] | None = None) -> None:
|
|
logger.debug(parse_class_init(locals()))
|
|
self._video_file = validate_video_file(video_file)
|
|
self._fast_count = fast_count
|
|
self._stream_index = stream_index
|
|
self._pts = None if pts is None else np.array(pts, dtype=np.int64)
|
|
self._keyframes = None if keyframes is None else np.array(keyframes, dtype=np.int64)
|
|
self._num_keyframes = -1
|
|
|
|
self._duration = self._get_duration()
|
|
self._count: int | None = None
|
|
|
|
def __repr__(self) -> str:
|
|
"""Pretty print for logging"""
|
|
params = {k[1:]: v.tolist() if isinstance(v, np.ndarray) else v
|
|
for k, v in self.__dict__.items()
|
|
if k in ("_video_file",
|
|
"_fast_count",
|
|
"_stream_index",
|
|
"_pts",
|
|
"_keyframes")}
|
|
s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items())
|
|
return f"{self.__class__.__name__}({s_params})"
|
|
|
|
@property
|
|
def duration(self) -> int:
|
|
"""The duration of the video file in seconds"""
|
|
return self._duration
|
|
|
|
@property
|
|
def count(self) -> int:
|
|
"""The number of frames in the video"""
|
|
if self._count is not None:
|
|
return self._count
|
|
if self._pts is not None:
|
|
self._count = len(self._pts)
|
|
return self._count
|
|
if self._fast_count:
|
|
self._count = count_frames(self._video_file, fast=True)
|
|
return self._count
|
|
self._count = len(self.pts)
|
|
return self._count
|
|
|
|
@property
|
|
def keyframes_count(self) -> int:
|
|
"""The number of keyframes that exist in the video"""
|
|
if self._num_keyframes < 0:
|
|
self._num_keyframes = len(self.keyframes)
|
|
return self._num_keyframes
|
|
|
|
@property
|
|
def pts(self) -> npt.NDArray[np.int64]:
|
|
"""The Presentation Time Stamp for each frame in the video"""
|
|
if self._pts is None:
|
|
self._get_pts_and_keyframes()
|
|
assert self._pts is not None
|
|
return self._pts
|
|
|
|
@property
|
|
def keyframes(self) -> npt.NDArray[np.int64]:
|
|
"""The frame index of each key frame in the video"""
|
|
if self._keyframes is None:
|
|
self._get_pts_and_keyframes()
|
|
assert self._keyframes is not None
|
|
return self._keyframes
|
|
|
|
def _get_stream(self, container: InputContainer) -> av.VideoStream:
|
|
"""Obtain the first video stream from the given container and set threading
|
|
|
|
Parameters
|
|
----------
|
|
container
|
|
The opened video container
|
|
|
|
Returns
|
|
-------
|
|
stream
|
|
The first video stream within the container with AUTO threading mode set
|
|
|
|
Raises
|
|
------
|
|
FaceswapError
|
|
If time_base is not stored within the stream
|
|
"""
|
|
stream = container.streams.video[self._stream_index]
|
|
stream.thread_type = "AUTO"
|
|
if stream.time_base is None:
|
|
raise FaceswapError(f"Video file '{self._video_file}' cannot be processed. Missing "
|
|
"duration metadata")
|
|
return stream
|
|
|
|
def _get_duration(self) -> int:
|
|
"""Obtain the duration of the video, in seconds. First attempt to obtain it from the
|
|
stream. If this does not exist attempt to obtain it from the container. If this also
|
|
does not exist, raise an error
|
|
|
|
Parameters
|
|
----------
|
|
stream
|
|
The stream to attempt to obtain the duration from
|
|
|
|
Returns
|
|
-------
|
|
The duration of the stream in seconds
|
|
|
|
Raises
|
|
------
|
|
FaceswapError
|
|
If the duration of the video could not be obtained
|
|
"""
|
|
with av.open(self._video_file, "r") as container:
|
|
stream = self._get_stream(container)
|
|
if stream.duration is not None and stream.time_base is not None:
|
|
duration = int(stream.duration * stream.time_base)
|
|
logger.debug("[%s] '%s' duration from stream: %s",
|
|
self.__class__.__name__, self._video_file, duration)
|
|
elif container.duration is None:
|
|
raise FaceswapError(f"Video file '{self._video_file}' cannot be processed. "
|
|
"Missing duration metadata")
|
|
else:
|
|
duration = int(container.duration / 1000000)
|
|
logger.debug("[%s] '%s' duration from container: %s",
|
|
self.__class__.__name__, self._video_file, duration)
|
|
return duration
|
|
|
|
def _get_pts_and_keyframes(self) -> None:
|
|
"""Parse the video for Presentation Time Stamps and keyframes and populate to :attr:`_pts`
|
|
and :attr:`_keyframes"""
|
|
logger.debug("[%s] Parsing video for PTS and keyframes: '%s'",
|
|
self.__class__.__name__, self._video_file)
|
|
pts: list[int] = []
|
|
keyframes: list[int] = []
|
|
with av.open(self._video_file, "r") as container:
|
|
stream = self._get_stream(container)
|
|
assert stream.time_base is not None
|
|
|
|
p_bar = tqdm(desc="Analyzing Video", leave=False, total=self.duration, unit="secs")
|
|
i = last_update = offset = 0
|
|
decoder = container.decode(stream)
|
|
while True:
|
|
try:
|
|
frame = next(decoder)
|
|
except StopIteration:
|
|
break
|
|
except av.error.InvalidDataError:
|
|
logger.warning("Invalid data encountered at frame %s in video '%s'",
|
|
i, self._video_file)
|
|
continue
|
|
assert frame.pts is not None
|
|
if i == 0:
|
|
offset = frame.pts
|
|
pts.append(frame.pts)
|
|
if frame.key_frame: # pyright:ignore[reportAttributeAccessIssue]
|
|
keyframes.append(i)
|
|
cur_sec = int((frame.pts - offset) * stream.time_base)
|
|
i += 1
|
|
if cur_sec == last_update:
|
|
continue
|
|
p_bar.update(cur_sec - last_update)
|
|
last_update = cur_sec
|
|
self._pts = np.array(pts, dtype=np.int64)
|
|
self._keyframes = np.array(keyframes, dtype=np.int64)
|
|
logger.debug("[%s] '%s' frame_pts: %s, keyframes: %s, frame_count: %s",
|
|
self.__class__.__name__, self._video_file, pts, keyframes, len(pts))
|
|
|
|
|
|
class VideoReader:
|
|
"""A wrapper around pyAV that allows obtaining frames by frame index and iterating video files
|
|
|
|
Parameters
|
|
----------
|
|
video_file
|
|
Full path to a video file
|
|
fast_count
|
|
Whether to obtain the count of frames quickly, but inaccurately or slowly but accurately.
|
|
If pts and keyframes are provided then the count will be derived from the provided pts
|
|
file. Default: ``True``
|
|
stream_index
|
|
The stream index to select from the video file. Default: 0
|
|
pts
|
|
The Presentation Timestamps if available or ``None`` to retrieve from the video.
|
|
Default: ``None``
|
|
keyframes
|
|
The keyframe frame indices if available or ``None`` to retrieve from the video.
|
|
Default: ``None``
|
|
"""
|
|
def __init__(self,
|
|
video_file: str,
|
|
fast_count: bool = True,
|
|
stream_index: int = 0,
|
|
pts: list[int] | None = None,
|
|
keyframes: list[int] | None = None) -> None:
|
|
logger.debug(parse_class_init(locals()))
|
|
self._video_file = validate_video_file(video_file)
|
|
self._stream_index = stream_index
|
|
self._info = VideoInfo(self._video_file,
|
|
fast_count,
|
|
self._stream_index,
|
|
pts,
|
|
keyframes)
|
|
|
|
self._container = av.open(self._video_file, "r")
|
|
self._stream = self._container.streams.video[stream_index]
|
|
self._stream.thread_type = "AUTO"
|
|
self._decoder = self._container.decode(self._stream)
|
|
|
|
self._count: int | None = None
|
|
self._current_pts = 0
|
|
self._current_index = 0
|
|
"""The index of the next frame to be returned from the frame iterator"""
|
|
|
|
@property
|
|
def info(self) -> VideoInfo:
|
|
"""The metadata information for the video file"""
|
|
return self._info
|
|
|
|
def __iter__(self) -> T.Self:
|
|
""" This is an iterator """
|
|
return self
|
|
|
|
def __repr__(self) -> str:
|
|
""" Pretty print for logging """
|
|
pts = self._info._pts
|
|
keyframes = self._info._keyframes
|
|
params = {"video_file": self._video_file,
|
|
"fast_count": self._info._fast_count,
|
|
"stream_index": self._stream_index,
|
|
"pts": pts if pts is None else pts.tolist(),
|
|
"keyframes": keyframes if keyframes is None else keyframes.tolist()}
|
|
s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items())
|
|
return f"{self.__class__.__name__}({s_params})"
|
|
|
|
def __len__(self) -> int:
|
|
"""The number of frames in the video file. Either inaccurate (if fast_count is ``True``)
|
|
or accurate (if fast_count is ``False`` or pts and keyframes were provided)"""
|
|
return self._info.count
|
|
|
|
def close(self) -> None:
|
|
"""Shut down the AV Container object"""
|
|
logger.debug("[%s] '%s' Closing container", self.__class__.__name__, self._video_file)
|
|
self._container.close()
|
|
|
|
def __next__(self) -> av.VideoFrame:
|
|
"""Obtain the next video frame object
|
|
|
|
Returns
|
|
-------
|
|
The next available video frame object
|
|
"""
|
|
frame = None
|
|
while True:
|
|
try:
|
|
frame = next(self._decoder)
|
|
break
|
|
except StopIteration:
|
|
break
|
|
except av.error.InvalidDataError:
|
|
logger.warning("Invalid data encountered at frame %s. Skipping.",
|
|
self._current_index)
|
|
continue
|
|
if frame is None:
|
|
logger.debug("[%s] Closing Frame Iterator", self.__class__.__name__)
|
|
self.close()
|
|
raise StopIteration
|
|
self._current_index += 1
|
|
return frame
|
|
|
|
def _get_previous_keyframe(self, index: int) -> int:
|
|
"""Obtain the keyframe that appears directly prior to the given frame index
|
|
|
|
Parameters
|
|
----------
|
|
index
|
|
The target frame that is being navigated to
|
|
|
|
Returns
|
|
The keyframe that appears directly prior to the given target frame
|
|
"""
|
|
if index in self._info.keyframes:
|
|
logger.trace("[%s] Index is keyframe: %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, index)
|
|
return index
|
|
keyframe_index = np.searchsorted(self._info.keyframes, index, side="left") - 1
|
|
keyframe = int(self._info.keyframes[keyframe_index])
|
|
logger.trace("[%s] Previous keyframe for frame %s: %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, index, keyframe)
|
|
return keyframe
|
|
|
|
def _jump_to_keyframe(self, index: int, target_pts: int) -> None:
|
|
"""Jump the iterator to the first keyframe prior to the requested frame, or leave it where
|
|
it is if the next requested frame is before the next keyframe. If we are seeking we always
|
|
replace our iterator with a new one due to possible internal pyAV logic getting scrambled
|
|
|
|
Parameters
|
|
----------
|
|
index
|
|
The frame index of the requested frame to retrieve
|
|
target_pts
|
|
The Presentation Timestamp of the requested frame
|
|
"""
|
|
if index == self._current_index:
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Requested frame is next queued. Not seeking: %s",
|
|
self.__class__.__name__, index)
|
|
return
|
|
|
|
if index < self._current_index: # Moving backwards
|
|
logger.trace("[%s] Seeking backwards from %s to %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, self._current_index, index)
|
|
self._container.seek(target_pts, backward=True, any_frame=False, stream=self._stream)
|
|
self._decoder = self._container.decode(self._stream)
|
|
self._current_index = self._get_previous_keyframe(index)
|
|
return
|
|
|
|
next_key_index = np.searchsorted(self._info.keyframes, self._current_index, side="right")
|
|
next_keyframe = self._info.keyframes[next_key_index]
|
|
|
|
if next_keyframe > index:
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Next keyframe is past target. Not seeking: %s",
|
|
self.__class__.__name__, next_keyframe)
|
|
return
|
|
|
|
next_keyframe = self._get_previous_keyframe(index)
|
|
logger.trace("[%s] Seeking forwards to %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, next_keyframe)
|
|
self._container.seek(target_pts, backward=True, any_frame=False, stream=self._stream)
|
|
self._decoder = self._container.decode(self._stream)
|
|
self._current_index = next_keyframe
|
|
|
|
def get(self, index: int) -> av.VideoFrame:
|
|
"""Obtain the video frame at the given frame index
|
|
|
|
Parameters
|
|
----------
|
|
index
|
|
The index number of the frame to retrieve
|
|
|
|
Returns
|
|
-------
|
|
The pyAV frame object for the given index
|
|
"""
|
|
target_pts = int(self._info.pts[index])
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Requested frame: %s, current frame: %s, target pts: %s",
|
|
self.__class__.__name__, index, self._current_index, target_pts)
|
|
self._jump_to_keyframe(index, target_pts)
|
|
frame = next(self)
|
|
assert frame.pts is not None
|
|
current_pts = frame.pts
|
|
while current_pts < target_pts:
|
|
frame = next(self)
|
|
assert frame.pts is not None
|
|
current_pts = frame.pts
|
|
logger.trace("[%s] Returning frame: %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, frame)
|
|
return frame
|
|
|
|
|
|
class VideoMux: # pylint:disable=too-many-instance-attributes
|
|
"""A basic muxer for muxing converted faceswap frames to a video file using the original video
|
|
as a reference
|
|
|
|
Parameters
|
|
----------
|
|
source_video
|
|
The path to the source video to use as a reference for Audio and FPS
|
|
destination_video
|
|
The full path to save the final video to
|
|
codec
|
|
The codec to use to encode the video
|
|
codec_parameters
|
|
The options to use for the codec
|
|
mux_audio
|
|
``True`` to mux order from the source video to the output
|
|
"""
|
|
def __init__(self,
|
|
source_video: str,
|
|
destination_video: str,
|
|
codec: T.Literal["libx264", "libx265"],
|
|
codec_parameters: dict[str, str],
|
|
mux_audio: bool = True) -> None:
|
|
logger.debug(parse_class_init(locals()))
|
|
self._source_video = validate_video_file(source_video)
|
|
self._destination_video = destination_video
|
|
self._codec = codec
|
|
self._codec_parameters = codec_parameters
|
|
self._mux_audio = mux_audio
|
|
|
|
self._containers: dict[T.Literal["src", "dst"], InputContainer | OutputContainer] = {
|
|
"src": av.open(self._source_video, "r"),
|
|
"dst": av.open(self._destination_video, "w")
|
|
}
|
|
|
|
self._next_audio_packet: av.Packet | None = None
|
|
self._audio_packets, self._fps = self._analyze_source()
|
|
self._video_packets: deque[av.Packet] = deque()
|
|
self._streams = self._set_output_streams()
|
|
|
|
self._graph: av.filter.Graph | None = None
|
|
self._initialized = False
|
|
self._frame_index = 0
|
|
|
|
def __repr__(self) -> str:
|
|
""" Pretty print for logging """
|
|
opts = ["_source_video", "_destination_video", "_codec", "_codec_parameters", "_mux_audio"]
|
|
params = {k[1:]: v for k, v in self.__dict__.items() if k in opts}
|
|
s_params = ", ".join(f"{k}={repr(v)}" for k, v in params.items())
|
|
return f"{self.__class__.__name__}({s_params})"
|
|
|
|
def _analyze_source(self) -> tuple[T.Generator[av.Packet, None, None] | None, Fraction]:
|
|
"""Analyze the source to obtain the audio packets and the frame rate
|
|
|
|
Returns
|
|
-------
|
|
audio_packets
|
|
A generator containing audio packets from the source video, if audio is to be muxed
|
|
otherwise ``None``
|
|
fps
|
|
The framerate of the original video
|
|
"""
|
|
src = T.cast("InputContainer", self._containers["src"])
|
|
fps = src.streams.video[0].average_rate
|
|
assert fps is not None
|
|
logger.debug("[%s] Source fps: %s", self.__class__.__name__, fps)
|
|
|
|
if not self._mux_audio:
|
|
logger.debug("[%s] Not muxing audio due to input parameters", self.__class__.__name__)
|
|
return None, fps
|
|
|
|
audio = next((s for s in src.streams if s.type == "audio"), None)
|
|
if audio is None:
|
|
logger.warning("No audio stream could be found in the source video '%s'. Audio mux "
|
|
"will be disabled.", self._source_video)
|
|
self._mux_audio = False
|
|
return None, fps
|
|
|
|
packets = (p for p in src.demux(audio) if p.dts is not None)
|
|
logger.debug("[%s] Muxing audio from source: %s", self.__class__.__name__, packets)
|
|
self._next_audio_packet = next(packets)
|
|
logger.debug("[%s] Queued first audio packet: %s",
|
|
self.__class__.__name__, self._next_audio_packet)
|
|
return packets, fps
|
|
|
|
def _set_output_streams(self) -> dict[T.Literal["audio", "video"],
|
|
av.AudioStream | av.VideoStream]:
|
|
"""Set the output audio and video streams
|
|
|
|
Returns
|
|
-------
|
|
The output streams. Audio stream is only included if muxing audio is selected and supported
|
|
"""
|
|
retval: dict[T.Literal["audio", "video"], av.AudioStream | av.VideoStream] = {}
|
|
dst = T.cast("OutputContainer", self._containers["dst"])
|
|
video = dst.add_stream(self._codec, rate=self._fps, options=self._codec_parameters)
|
|
assert isinstance(video, av.VideoStream)
|
|
video.thread_type = "AUTO"
|
|
video.pix_fmt = "yuv420p"
|
|
retval["video"] = video
|
|
|
|
if self._mux_audio:
|
|
src = self._containers["src"]
|
|
src_audio = next(s for s in src.streams if s.type == "audio")
|
|
audio = dst.add_stream_from_template(src_audio)
|
|
assert isinstance(audio, av.AudioStream)
|
|
retval["audio"] = audio
|
|
logger.debug("[%s] Added output streams: %s", self.__class__.__name__, retval)
|
|
return retval
|
|
|
|
def _add_rescale_filter(self,
|
|
input_dimensions: tuple[int, int],
|
|
output_dimensions: tuple[int, int],
|
|
pixel_format: str) -> None:
|
|
"""Add a rescale filter if the input dimensions are not divisible by 16
|
|
|
|
Parameters
|
|
----------
|
|
input_dimensions
|
|
The (W, H) size of the input frames to the video
|
|
output_dimensions
|
|
The (W, H) size of the output video
|
|
pixel_format
|
|
The pixel format of the output video
|
|
"""
|
|
if input_dimensions == output_dimensions:
|
|
return
|
|
self._graph = av.filter.Graph()
|
|
str_dims = f"{output_dimensions[0]}:{output_dimensions[1]}"
|
|
filters = [self._graph.add_buffer(width=input_dimensions[0],
|
|
height=input_dimensions[1],
|
|
format=av.VideoFormat(pixel_format),
|
|
time_base=Fraction(1, self._fps)),
|
|
self._graph.add("scale", f"{str_dims}:force_original_aspect_ratio=1"),
|
|
self._graph.add("pad", f"{str_dims}:(ow-iw)/2:(oh-ih)/2"),
|
|
self._graph.add("buffersink")]
|
|
for i in range(len(filters) - 1):
|
|
filters[i].link_to(filters[i + 1])
|
|
self._graph.configure()
|
|
logger.debug("[%s] Created scale filter: %s", self.__class__.__name__, self._graph)
|
|
|
|
def _initialize_video(self, image: npt.NDArray[np.uint8]) -> None:
|
|
"""Initialize the video dimensions based on the first frame seen. We scale dimensions to be
|
|
divisible by 16 due to macro-blocking.
|
|
|
|
Parameters
|
|
----------
|
|
image
|
|
The first frame passed into the muxer
|
|
"""
|
|
vid = T.cast(av.VideoStream, self._streams["video"])
|
|
input_dimensions = (image.shape[1], image.shape[0])
|
|
output_dimensions = (int(ceil(input_dimensions[0] / 16) * 16),
|
|
int(ceil(input_dimensions[1] / 16) * 16))
|
|
vid.width = output_dimensions[0]
|
|
vid.height = output_dimensions[1]
|
|
logger.debug("[%s] Set video dimensions for first frame input: %s output: %s (%s)",
|
|
self.__class__.__name__, input_dimensions, output_dimensions, vid)
|
|
self._add_rescale_filter(input_dimensions, output_dimensions, T.cast(str, vid.pix_fmt))
|
|
|
|
logger.debug("[%s] Initialized video stream", self.__class__.__name__)
|
|
self._initialized = True
|
|
|
|
def _encode_frame(self, image: npt.NDArray[np.uint8]) -> None:
|
|
"""Encode the frame into packets and add the packets to the list of encoded packets to be
|
|
muxed
|
|
|
|
Parameters
|
|
----------
|
|
image
|
|
The image to be encoded
|
|
"""
|
|
vid = T.cast(av.VideoStream, self._streams["video"])
|
|
frame = av.VideoFrame.from_ndarray(image, format="bgr24")
|
|
frame.pts = self._frame_index
|
|
frame.time_base = Fraction(1, self._fps)
|
|
|
|
if self._graph is not None:
|
|
# Need to convert to output format before running through filter graph
|
|
self._graph.push(frame.reformat(format=vid.pix_fmt))
|
|
frame = T.cast(av.VideoFrame, self._graph.pull())
|
|
|
|
logger.trace("[%s] Encoded frame of shape %s to: %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, image.shape, frame)
|
|
|
|
packets = vid.encode(frame)
|
|
self._video_packets.extend(packets)
|
|
logger.trace("[%s] Added video packets: %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, packets)
|
|
self._frame_index += 1
|
|
|
|
def _timestamp(self, packet: av.Packet) -> float:
|
|
"""Obtain the standardized time stamp for the given packet
|
|
|
|
Parameters
|
|
----------
|
|
packet
|
|
The packet to obtain the timestamp for
|
|
|
|
Returns
|
|
-------
|
|
The standardized timestamp
|
|
"""
|
|
assert packet.pts is not None
|
|
return float(packet.pts * packet.time_base)
|
|
|
|
def _get_audio_packet(self, timestamp: float) -> av.Packet | None:
|
|
"""Obtain the next audio packet if it should be output prior to the current timestamp and
|
|
queue the next audio packet for output
|
|
|
|
Parameters
|
|
----------
|
|
timestamp
|
|
The timestamp of the next video packet to be output
|
|
"""
|
|
if self._next_audio_packet is None:
|
|
return None
|
|
next_ts = self._timestamp(self._next_audio_packet)
|
|
if next_ts >= timestamp:
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Next audio timestamp %s >= video timestamp %s. No audio to stream",
|
|
self.__class__.__name__, next_ts, timestamp)
|
|
return None
|
|
|
|
assert self._audio_packets is not None
|
|
retval = self._next_audio_packet
|
|
self._next_audio_packet = next((self._audio_packets), None)
|
|
logger.trace( # type:ignore[attr-defined]
|
|
"[%s] Returning audio packet %s for timestamp %s < video timestamp: %s. Next queued "
|
|
"packet: %s",
|
|
self.__class__.__name__, retval, next_ts, timestamp, self._next_audio_packet)
|
|
retval.stream = self._streams["audio"]
|
|
return retval
|
|
|
|
def _mux(self) -> None:
|
|
"""Mux any audio and video packets that are ready to be output"""
|
|
out = T.cast("OutputContainer", self._containers["dst"])
|
|
while self._video_packets:
|
|
video = self._video_packets.popleft()
|
|
if self._mux_audio:
|
|
while True:
|
|
audio = self._get_audio_packet(self._timestamp(video))
|
|
if audio is None:
|
|
break
|
|
logger.trace("[%s] Muxing audio: %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, audio)
|
|
out.mux(audio)
|
|
logger.trace("[%s] Muxing video: %s", # type:ignore[attr-defined]
|
|
self.__class__.__name__, video)
|
|
out.mux(video)
|
|
|
|
def encode(self, image: npt.NDArray[np.uint8] | None) -> None:
|
|
"""Encode a frame to the video
|
|
|
|
Parameters
|
|
----------
|
|
image
|
|
The 3 channel BGR UINT8 image to encode to the video or ``None`` to finalize the video
|
|
"""
|
|
if image is None:
|
|
logger.debug("[%s] EOF Received. Flushing", self.__class__.__name__)
|
|
self._video_packets.extend(self._streams["video"].encode())
|
|
self._mux()
|
|
for container in self._containers.values():
|
|
container.close()
|
|
return
|
|
|
|
if not self._initialized:
|
|
self._initialize_video(image)
|
|
|
|
self._encode_frame(image)
|
|
self._mux()
|
|
|
|
|
|
get_module_objects(__name__)
|