Signed-off-by: MosCloud <bwzhang1991@163.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
271 lines
9.8 KiB
Python
271 lines
9.8 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||
"""Video/audio muxing utilities using PyAV (no ffmpeg binary dependency)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import io
|
||
from collections.abc import Iterable
|
||
from fractions import Fraction
|
||
from typing import Any, cast
|
||
|
||
import av
|
||
import numpy as np
|
||
|
||
|
||
class FragmentedMP4Muxer:
|
||
"""Incrementally mux video frames into one fragmented MP4 byte stream."""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
width: int,
|
||
height: int,
|
||
fps: float = 25.0,
|
||
video_codec: str = "h264",
|
||
crf: str = "18",
|
||
video_codec_options: dict[str, str] | None = None,
|
||
) -> None:
|
||
self._buf = io.BytesIO()
|
||
self._closed = False
|
||
self._container = av.open(
|
||
self._buf,
|
||
mode="w",
|
||
format="mp4",
|
||
options={"movflags": "+frag_every_frame+empty_moov+default_base_moof"},
|
||
)
|
||
|
||
self._stream: av.VideoStream = cast(
|
||
av.VideoStream,
|
||
self._container.add_stream(video_codec, rate=Fraction(fps).limit_denominator(10000)),
|
||
)
|
||
self._stream.width = width
|
||
self._stream.height = height
|
||
self._stream.pix_fmt = "yuv420p"
|
||
|
||
options: dict[str, object] = {"crf": str(crf)}
|
||
if video_codec_options:
|
||
options.update(video_codec_options)
|
||
self._stream.options = options
|
||
|
||
try:
|
||
self._stream.codec_context.max_b_frames = 0
|
||
except AttributeError:
|
||
pass
|
||
|
||
def mux_video_frames(self, video_frames: np.ndarray) -> bytes:
|
||
"""Mux a batch of ``uint8`` RGB frames and return newly written MP4 bytes."""
|
||
if self._closed:
|
||
raise RuntimeError("Cannot mux frames after FragmentedMP4Muxer.close().")
|
||
if video_frames.ndim != 4 or video_frames.shape[-1] != 3:
|
||
raise ValueError("video_frames must have shape (T, H, W, 3).")
|
||
if video_frames.dtype != np.uint8:
|
||
raise ValueError("video_frames must be uint8.")
|
||
if video_frames.shape[1] != self._stream.height or video_frames.shape[2] != self._stream.width:
|
||
raise ValueError("All fragmented MP4 chunks in a session must use the same frame size.")
|
||
|
||
for frame_data in video_frames:
|
||
frame = av.VideoFrame.from_ndarray(frame_data, format="rgb24")
|
||
for packet in self._stream.encode(frame):
|
||
self._container.mux(packet)
|
||
return self._read_new_bytes()
|
||
|
||
def close(self) -> bytes:
|
||
"""Flush delayed encoder packets, close the container, and return final bytes."""
|
||
if self._closed:
|
||
return b""
|
||
for packet in self._stream.encode():
|
||
self._container.mux(packet)
|
||
self._container.close()
|
||
self._closed = True
|
||
return self._read_new_bytes()
|
||
|
||
def _read_new_bytes(self) -> bytes:
|
||
"""Return newly muxed bytes in the current video container,
|
||
then clear the buffer to prepare for the next chunk."""
|
||
chunk = self._buf.getvalue()
|
||
self._buf.seek(0)
|
||
self._buf.truncate()
|
||
return chunk
|
||
|
||
|
||
def finalize_streaming_video_bytes(
|
||
video_bytes: bytes,
|
||
*,
|
||
input_format: str,
|
||
fps: float = 25.0,
|
||
video_codec_options: dict[str, str] | None = None,
|
||
) -> bytes:
|
||
"""Convert streamed video bytes into a progressive MP4 for local playback."""
|
||
if not video_bytes:
|
||
return video_bytes
|
||
|
||
normalized_format = input_format.lower()
|
||
if normalized_format == "m4s":
|
||
demux_format = "mp4"
|
||
else:
|
||
raise ValueError(f"Unsupported streaming video format: {input_format}")
|
||
|
||
try:
|
||
with cast(Any, av.open(io.BytesIO(video_bytes), format=demux_format)) as container:
|
||
stream = container.streams.video[0]
|
||
frame_arrays = [frame.to_ndarray(format="rgb24") for frame in container.decode(stream)]
|
||
except Exception:
|
||
return video_bytes
|
||
|
||
if not frame_arrays:
|
||
return video_bytes
|
||
|
||
frames_u8 = np.ascontiguousarray(np.stack(frame_arrays, axis=0), dtype=np.uint8)
|
||
return mux_video_audio_bytes(
|
||
frames_u8,
|
||
None,
|
||
fps=float(fps),
|
||
video_codec_options=video_codec_options,
|
||
)
|
||
|
||
|
||
def mux_video_audio_bytes(
|
||
video_frames: np.ndarray,
|
||
audio_waveform: np.ndarray | None = None,
|
||
*,
|
||
fps: float = 25.0,
|
||
audio_sample_rate: int = 44100,
|
||
video_codec: str = "h264",
|
||
audio_codec: str = "aac",
|
||
crf: str = "18",
|
||
video_codec_options: dict[str, str] | None = None,
|
||
) -> bytes:
|
||
"""Mux video frames and optional audio waveform into MP4 bytes.
|
||
|
||
Args:
|
||
video_frames: uint8 array of shape ``(T, H, W, 3)`` (RGB).
|
||
audio_waveform: float32 array – mono ``(N,)`` or ``(N, C)`` / ``(C, N)``.
|
||
fps: Video frame rate.
|
||
audio_sample_rate: Audio sample rate in Hz.
|
||
video_codec: Video codec name.
|
||
audio_codec: Audio codec name.
|
||
crf: Constant rate factor for the video encoder.
|
||
|
||
Returns:
|
||
Raw MP4 bytes ready to be written to disk or streamed.
|
||
"""
|
||
buf = io.BytesIO()
|
||
container = av.open(buf, mode="w", format="mp4")
|
||
|
||
v_stream = cast(av.VideoStream, container.add_stream(video_codec, rate=Fraction(fps).limit_denominator(10000)))
|
||
v_stream.width = video_frames.shape[2]
|
||
v_stream.height = video_frames.shape[1]
|
||
v_stream.pix_fmt = "yuv420p"
|
||
|
||
options: dict[str, object] = {"crf": str(crf)}
|
||
if video_codec_options:
|
||
options.update(video_codec_options)
|
||
v_stream.options = options
|
||
|
||
a_stream: av.AudioStream | None = None
|
||
samples: np.ndarray | None = None
|
||
layout: str | None = None
|
||
if audio_waveform is not None:
|
||
samples = audio_waveform.astype(np.float32)
|
||
if samples.ndim == 1:
|
||
samples = samples.reshape(1, -1)
|
||
elif samples.ndim == 2 and samples.shape[0] > samples.shape[1]:
|
||
samples = np.ascontiguousarray(samples.T)
|
||
num_channels = samples.shape[0]
|
||
layout = "stereo" if num_channels >= 2 else "mono"
|
||
a_stream = cast(av.AudioStream, container.add_stream(audio_codec, rate=audio_sample_rate))
|
||
a_stream.layout = layout
|
||
|
||
for frame_data in video_frames:
|
||
frame = av.VideoFrame.from_ndarray(frame_data, format="rgb24")
|
||
for packet in v_stream.encode(frame):
|
||
container.mux(packet)
|
||
for packet in v_stream.encode():
|
||
container.mux(packet)
|
||
|
||
if a_stream is not None and audio_waveform is not None:
|
||
if samples is None or layout is None:
|
||
raise ValueError("Audio samples were not prepared for muxing.")
|
||
audio_frame = av.AudioFrame.from_ndarray(samples, format="fltp", layout=layout)
|
||
audio_frame.sample_rate = audio_sample_rate
|
||
# AAC has a one-frame encoder delay. Mark the input waveform as
|
||
# starting at t=0 so the MP4 muxer writes the corresponding negative
|
||
# priming timestamp instead of exposing the delay as leading silence.
|
||
audio_frame.pts = 0
|
||
audio_frame.time_base = Fraction(1, audio_sample_rate)
|
||
for packet in a_stream.encode(audio_frame):
|
||
container.mux(packet)
|
||
for packet in a_stream.encode():
|
||
container.mux(packet)
|
||
|
||
container.close()
|
||
return buf.getvalue()
|
||
|
||
|
||
def mux_av_video_audio_bytes(
|
||
video_frames: Iterable[av.VideoFrame],
|
||
width: int,
|
||
height: int,
|
||
audio_waveform: np.ndarray | None = None,
|
||
*,
|
||
fps: float = 25.0,
|
||
audio_sample_rate: int = 44100,
|
||
video_codec: str = "h264",
|
||
audio_codec: str = "aac",
|
||
crf: str = "18",
|
||
video_codec_options: dict[str, str] | None = None,
|
||
) -> bytes:
|
||
"""Mux preconstructed video frames and optional audio into MP4 bytes."""
|
||
buf = io.BytesIO()
|
||
with cast(Any, av.open(buf, mode="w", format="mp4")) as container:
|
||
v_stream = cast(
|
||
av.VideoStream,
|
||
container.add_stream(video_codec, rate=Fraction(fps).limit_denominator(10000)),
|
||
)
|
||
v_stream.width = width
|
||
v_stream.height = height
|
||
v_stream.pix_fmt = "yuv420p"
|
||
|
||
options: dict[str, object] = {"crf": str(crf)}
|
||
if video_codec_options:
|
||
options.update(video_codec_options)
|
||
v_stream.options = options
|
||
|
||
a_stream: av.AudioStream | None = None
|
||
samples: np.ndarray | None = None
|
||
layout: str | None = None
|
||
if audio_waveform is not None:
|
||
samples = audio_waveform.astype(np.float32)
|
||
if samples.ndim == 1:
|
||
samples = samples.reshape(1, -1)
|
||
elif samples.ndim == 2 and samples.shape[0] > samples.shape[1]:
|
||
samples = np.ascontiguousarray(samples.T)
|
||
num_channels = samples.shape[0]
|
||
layout = "stereo" if num_channels >= 2 else "mono"
|
||
a_stream = cast(av.AudioStream, container.add_stream(audio_codec, rate=audio_sample_rate))
|
||
a_stream.layout = layout
|
||
|
||
for frame in video_frames:
|
||
for packet in v_stream.encode(frame):
|
||
container.mux(packet)
|
||
for packet in v_stream.encode():
|
||
container.mux(packet)
|
||
|
||
if a_stream is not None and audio_waveform is not None:
|
||
if samples is None or layout is None:
|
||
raise ValueError("Audio samples were not prepared for muxing.")
|
||
audio_frame = av.AudioFrame.from_ndarray(samples, format="fltp", layout=layout)
|
||
audio_frame.sample_rate = audio_sample_rate
|
||
# AAC has a one-frame encoder delay. Mark the input waveform as
|
||
# starting at t=0 so the MP4 muxer writes the corresponding negative
|
||
# priming timestamp instead of exposing the delay as leading silence.
|
||
audio_frame.pts = 0
|
||
audio_frame.time_base = Fraction(1, audio_sample_rate)
|
||
for packet in a_stream.encode(audio_frame):
|
||
container.mux(packet)
|
||
for packet in a_stream.encode():
|
||
container.mux(packet)
|
||
|
||
return buf.getvalue()
|