Automated PR - 2026-01-05
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""Video I/O utilities using PyAV.
|
||||
This module provides functions for reading and writing video files using PyAV,
|
||||
with optional audio support.
|
||||
"""
|
||||
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def get_video_frame_count(video_path: str | Path) -> int:
|
||||
"""Get the number of frames in a video file.
|
||||
Args:
|
||||
video_path: Path to the video file
|
||||
Returns:
|
||||
Number of frames in the video
|
||||
"""
|
||||
with av.open(str(video_path)) as container:
|
||||
video_stream = container.streams.video[0]
|
||||
frame_count = video_stream.frames
|
||||
if frame_count == 0:
|
||||
# Fallback: count frames by decoding
|
||||
frame_count = sum(1 for _ in container.decode(video=0))
|
||||
return frame_count
|
||||
|
||||
|
||||
def read_video(video_path: str | Path, max_frames: int | None = None) -> tuple[Tensor, float]:
|
||||
"""Load frames from a video file using PyAV.
|
||||
Args:
|
||||
video_path: Path to the video file
|
||||
max_frames: Maximum number of frames to read. If None, reads all frames.
|
||||
Returns:
|
||||
Video tensor with shape [F, C, H, W] in range [0, 1] and frames per second (fps).
|
||||
"""
|
||||
with av.open(str(video_path)) as container:
|
||||
video_stream = container.streams.video[0]
|
||||
fps = float(video_stream.average_rate or video_stream.base_rate or 24)
|
||||
|
||||
frames = []
|
||||
for frame in container.decode(video=0):
|
||||
if max_frames is not None and len(frames) >= max_frames:
|
||||
break
|
||||
frames.append(frame.to_ndarray(format="rgb24"))
|
||||
|
||||
frames_np = np.stack(frames, axis=0) # [F, H, W, C]
|
||||
video = torch.from_numpy(frames_np).float().div(255.0) # [F, H, W, C] in [0, 1]
|
||||
return video.permute(0, 3, 1, 2), fps # [F, C, H, W]
|
||||
|
||||
|
||||
def save_video(
|
||||
video_tensor: torch.Tensor,
|
||||
output_path: Path | str,
|
||||
fps: float = 24.0,
|
||||
audio: torch.Tensor | None = None,
|
||||
audio_sample_rate: int | None = None,
|
||||
) -> None:
|
||||
"""Save a video tensor to a file using PyAV, optionally with audio.
|
||||
Args:
|
||||
video_tensor: Video tensor of shape [C, F, H, W] or [F, C, H, W] in range [0, 1] or [0, 255]
|
||||
output_path: Path to save the video
|
||||
fps: Frames per second for the output video
|
||||
audio: Optional audio tensor of shape [C, samples] or [samples, C] in range [-1, 1]
|
||||
audio_sample_rate: Sample rate for the audio (required if audio is provided)
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Normalize to [F, H, W, C] uint8 numpy array
|
||||
video_np = _prepare_video_array(video_tensor)
|
||||
_, height, width, _ = video_np.shape
|
||||
|
||||
with av.open(str(output_path), mode="w") as container:
|
||||
# Setup video stream
|
||||
video_stream = container.add_stream("libx264", rate=int(fps))
|
||||
video_stream.width = width
|
||||
video_stream.height = height
|
||||
video_stream.pix_fmt = "yuv420p"
|
||||
video_stream.options = {"crf": "18"}
|
||||
|
||||
# Setup audio stream if needed
|
||||
if audio is not None:
|
||||
if audio_sample_rate is None:
|
||||
raise ValueError("audio_sample_rate must be provided when audio is given")
|
||||
audio_stream = container.add_stream("aac", rate=audio_sample_rate)
|
||||
audio_stream.layout = "stereo"
|
||||
audio_stream.time_base = Fraction(1, audio_sample_rate)
|
||||
|
||||
# Write video frames
|
||||
for frame_array in video_np:
|
||||
frame = av.VideoFrame.from_ndarray(frame_array, format="rgb24")
|
||||
for packet in video_stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in video_stream.encode():
|
||||
container.mux(packet)
|
||||
|
||||
# Write audio if provided
|
||||
if audio is not None:
|
||||
_write_audio(container, audio_stream, audio, audio_sample_rate)
|
||||
|
||||
|
||||
def _prepare_video_array(video_tensor: torch.Tensor) -> np.ndarray:
|
||||
"""Convert video tensor to [F, H, W, C] uint8 numpy array."""
|
||||
# Handle [C, F, H, W] vs [F, C, H, W] format
|
||||
if video_tensor.shape[0] == 3 and video_tensor.shape[1] > 3:
|
||||
video_tensor = video_tensor.permute(1, 0, 2, 3) # [C, F, H, W] -> [F, C, H, W]
|
||||
|
||||
# Normalize to [0, 255] uint8
|
||||
if video_tensor.max() <= 1.0:
|
||||
video_tensor = video_tensor * 255
|
||||
|
||||
# [F, C, H, W] -> [F, H, W, C]
|
||||
return video_tensor.permute(0, 2, 3, 1).to(torch.uint8).cpu().numpy()
|
||||
|
||||
|
||||
def _write_audio(
|
||||
container: av.container.Container,
|
||||
audio_stream: av.audio.AudioStream,
|
||||
audio: torch.Tensor,
|
||||
sample_rate: int,
|
||||
) -> None:
|
||||
"""Write audio tensor to container as stereo AAC."""
|
||||
audio = audio.cpu().float()
|
||||
|
||||
# Normalize to [samples, 2] stereo format
|
||||
if audio.ndim == 1:
|
||||
audio = audio.unsqueeze(1).repeat(1, 2) # Mono -> stereo
|
||||
elif audio.shape[0] == 2 and audio.shape[1] != 2:
|
||||
audio = audio.T # [2, samples] -> [samples, 2]
|
||||
if audio.shape[1] == 1:
|
||||
audio = audio.repeat(1, 2) # Mono -> stereo
|
||||
|
||||
# Convert to int16 interleaved: [samples, 2] -> [1, samples*2]
|
||||
audio_int16 = (audio.clamp(-1, 1) * 32767).to(torch.int16)
|
||||
audio_interleaved = audio_int16.contiguous().view(1, -1).numpy()
|
||||
|
||||
# Create audio frame
|
||||
frame = av.AudioFrame.from_ndarray(audio_interleaved, format="s16", layout="stereo")
|
||||
frame.sample_rate = sample_rate
|
||||
|
||||
# Resample to encoder format and write
|
||||
resampler = av.audio.resampler.AudioResampler(
|
||||
format=audio_stream.codec_context.format,
|
||||
layout=audio_stream.codec_context.layout,
|
||||
rate=sample_rate,
|
||||
)
|
||||
|
||||
pts = 0
|
||||
for resampled_frame in resampler.resample(frame):
|
||||
resampled_frame.pts = pts
|
||||
pts += resampled_frame.samples
|
||||
for packet in audio_stream.encode(resampled_frame):
|
||||
container.mux(packet)
|
||||
|
||||
for packet in audio_stream.encode():
|
||||
container.mux(packet)
|
||||
Reference in New Issue
Block a user