160 lines
5.7 KiB
Python
160 lines
5.7 KiB
Python
"""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)
|