Co-authored-by: SolitaryThinker <wlsaidhi@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
411 lines
12 KiB
Python
411 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for shared raw V2A preprocessing infrastructure."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import sys
|
|
import types
|
|
from fractions import Fraction
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
from fastvideo.configs.configs import VideoLoaderType
|
|
from fastvideo.dataset.mmaudio_feature_dataset import _expand_cache_root
|
|
from fastvideo.dataset.v2a_feature_cache import V2AFeatureShardWriter
|
|
from fastvideo.dataset.vggsound import VGGSoundDataset
|
|
from fastvideo.fastvideo_args import WorkloadType
|
|
from fastvideo.pipelines.basic.mmaudio import stages as basic_mmaudio_stages
|
|
from fastvideo.pipelines.pipeline_registry import PipelineType, _PipelineRegistry
|
|
from fastvideo.pipelines.preprocess.mmaudio import stages as mmaudio_stages
|
|
from fastvideo.pipelines.preprocess.mmaudio import torio_media_reader
|
|
from fastvideo.pipelines.preprocess.mmaudio.torio_media_reader import (
|
|
MMAudioTorioRowPreprocessor,
|
|
PREPROCESSED_MEDIA_KEY,
|
|
PREPROCESS_ERROR_KEY,
|
|
preprocess_mmaudio_media_with_torio,
|
|
)
|
|
from fastvideo.workflow.preprocess.preprocess_workflow_v2a import (
|
|
PreprocessWorkflowV2A, V2AForwardBatchBuilder)
|
|
|
|
pytest.importorskip("tensordict")
|
|
|
|
|
|
class _GenericT2VPreprocessPipeline:
|
|
pass
|
|
|
|
|
|
class _FamilyPreprocessPipeline:
|
|
pass
|
|
|
|
|
|
def _write_fractional_fps_video(
|
|
path: Path,
|
|
*,
|
|
duration_s: float,
|
|
frame_rate: Fraction = Fraction(30_000, 1_001),
|
|
) -> None:
|
|
av = pytest.importorskip("av")
|
|
with av.open(str(path), mode="w") as container:
|
|
stream = container.add_stream("mpeg4", rate=frame_rate)
|
|
stream.width = 32
|
|
stream.height = 32
|
|
stream.pix_fmt = "yuv420p"
|
|
time_base = Fraction(frame_rate.denominator, frame_rate.numerator)
|
|
frame_count = math.ceil(duration_s * float(frame_rate))
|
|
for index in range(frame_count):
|
|
pixels = np.full((32, 32, 3), index % 255, dtype=np.uint8)
|
|
frame = av.VideoFrame.from_ndarray(pixels, format="rgb24")
|
|
frame.pts = index
|
|
frame.time_base = time_base
|
|
for packet in stream.encode(frame):
|
|
container.mux(packet)
|
|
for packet in stream.encode():
|
|
container.mux(packet)
|
|
|
|
|
|
def test_vggsound_dataset_maps_hf_metadata_to_clip_name(tmp_path: Path) -> None:
|
|
(tmp_path / "videos").mkdir()
|
|
(tmp_path / "vggsound.csv").write_text(
|
|
"abc123,7,a train sound,train\nxyz789,42,a test sound,test\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
dataset = VGGSoundDataset(tmp_path, split="train")
|
|
|
|
assert len(dataset) == 1
|
|
assert dataset[0] == {
|
|
"id": "abc123_000007",
|
|
"caption": "a train sound",
|
|
"video_path": str(tmp_path / "videos" / "abc123_000007.mp4"),
|
|
}
|
|
|
|
|
|
def test_vggsound_dataset_reads_filtered_caption_manifest(tmp_path: Path) -> None:
|
|
videos = tmp_path / "videos"
|
|
videos.mkdir()
|
|
(videos / "video-one_000010.mp4").touch()
|
|
manifest = tmp_path / "vgg-val-filtered-caption.tsv"
|
|
manifest.write_text(
|
|
"id\tlabel\n"
|
|
"video-one_000010\tA detailed description of the audible event.\n"
|
|
"missing_000020\tThis row has no corresponding video.\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
dataset = VGGSoundDataset(tmp_path, split="val", metadata_path=manifest)
|
|
|
|
assert len(dataset) == 1
|
|
assert dataset[0] == {
|
|
"id": "video-one_000010",
|
|
"caption": "A detailed description of the audible event.",
|
|
"video_path": str(videos / "video-one_000010.mp4"),
|
|
}
|
|
|
|
|
|
def test_mmaudio_audio_normalization_matches_split_contract(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
waveform = torch.tensor([[0.25, -0.5, 0.125, 0.0]], dtype=torch.float32)
|
|
monkeypatch.setattr(
|
|
mmaudio_stages.torchaudio,
|
|
"load",
|
|
lambda _path: (waveform.clone(), 44_100),
|
|
)
|
|
|
|
train_audio = mmaudio_stages.MMAudioFeatureExtractionStage._load_audio(
|
|
"sample.mp4",
|
|
target_sample_rate=44_100,
|
|
target_samples=4,
|
|
normalize_audio=True,
|
|
)
|
|
eval_audio = mmaudio_stages.MMAudioFeatureExtractionStage._load_audio(
|
|
"sample.mp4",
|
|
target_sample_rate=44_100,
|
|
target_samples=4,
|
|
normalize_audio=False,
|
|
)
|
|
|
|
torch.testing.assert_close(train_audio.abs().max(), torch.tensor(0.95))
|
|
torch.testing.assert_close(eval_audio, waveform[0])
|
|
|
|
|
|
def test_mmaudio_torio_loader_is_an_explicit_config_choice() -> None:
|
|
assert VideoLoaderType.from_string("torio") is VideoLoaderType.TORIO
|
|
|
|
|
|
def test_mmaudio_torio_reader_preserves_reference_media_contract(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[tuple[str, int, float | None]] = []
|
|
|
|
class _StreamInfo:
|
|
sample_rate = 44_100
|
|
|
|
class _FakeStreamingMediaDecoder:
|
|
def __init__(self, path: str) -> None:
|
|
assert path == "sample.mp4"
|
|
|
|
def add_basic_video_stream(
|
|
self,
|
|
*,
|
|
frames_per_chunk: int,
|
|
frame_rate: float,
|
|
format: str,
|
|
) -> None:
|
|
assert format == "rgb24"
|
|
calls.append(("video", frames_per_chunk, frame_rate))
|
|
|
|
def add_basic_audio_stream(self, *, frames_per_chunk: int) -> None:
|
|
calls.append(("audio", frames_per_chunk, None))
|
|
|
|
def fill_buffer(self) -> None:
|
|
pass
|
|
|
|
def pop_chunks(self):
|
|
clip = torch.arange(8 * 3 * 16 * 20, dtype=torch.uint8).reshape(8, 3, 16, 20)
|
|
sync = torch.arange(25 * 3 * 16 * 20, dtype=torch.uint8).reshape(25, 3, 16, 20)
|
|
audio = torch.stack([
|
|
torch.linspace(-0.5, 0.5, 44_100),
|
|
torch.linspace(-0.25, 0.25, 44_100),
|
|
], dim=1)
|
|
return clip, sync, audio
|
|
|
|
def get_out_stream_info(self, index: int) -> _StreamInfo:
|
|
assert index == 2
|
|
return _StreamInfo()
|
|
|
|
torio_module = types.ModuleType("torio")
|
|
torio_module.__path__ = []
|
|
io_module = types.ModuleType("torio.io")
|
|
io_module.StreamingMediaDecoder = _FakeStreamingMediaDecoder
|
|
torio_module.io = io_module
|
|
monkeypatch.setitem(sys.modules, "torio", torio_module)
|
|
monkeypatch.setitem(sys.modules, "torio.io", io_module)
|
|
|
|
audio, clip, sync, duration = preprocess_mmaudio_media_with_torio(
|
|
"sample.mp4",
|
|
duration_s=1.0,
|
|
target_sample_rate=44_100,
|
|
target_samples=44_100,
|
|
normalize_audio=True,
|
|
clip_size=32,
|
|
sync_size=24,
|
|
)
|
|
|
|
assert calls == [
|
|
("video", 8, 8.0),
|
|
("video", 25, 25.0),
|
|
("audio", 2**30, None),
|
|
]
|
|
assert audio.shape == (44_100,)
|
|
torch.testing.assert_close(audio.abs().max(), torch.tensor(0.95))
|
|
assert clip.shape == (8, 3, 32, 32)
|
|
assert sync.shape == (25, 3, 24, 24)
|
|
assert duration == 1.0
|
|
|
|
|
|
def test_mmaudio_torio_row_preprocessor_returns_worker_ready_media(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
video_path = tmp_path / "sample.mp4"
|
|
video_path.touch()
|
|
expected = (
|
|
torch.ones(16),
|
|
torch.ones(8, 3, 4, 4),
|
|
torch.ones(25, 3, 4, 4),
|
|
1.0,
|
|
)
|
|
monkeypatch.setattr(
|
|
torio_media_reader,
|
|
"preprocess_mmaudio_media_with_torio",
|
|
lambda *_args, **_kwargs: expected,
|
|
)
|
|
preprocessor = MMAudioTorioRowPreprocessor(
|
|
duration_s=1.0,
|
|
target_sample_rate=16,
|
|
target_samples=16,
|
|
normalize_audio=True,
|
|
clip_size=4,
|
|
sync_size=4,
|
|
)
|
|
|
|
output = preprocessor({
|
|
"id": "sample",
|
|
"caption": "sound",
|
|
"video_path": str(video_path),
|
|
})
|
|
|
|
assert PREPROCESS_ERROR_KEY not in output
|
|
media = output[PREPROCESSED_MEDIA_KEY]
|
|
assert media["audio"] is expected[0]
|
|
assert media["clip_frames"] is expected[1]
|
|
assert media["sync_frames"] is expected[2]
|
|
assert media["effective_duration"] == 1.0
|
|
|
|
|
|
def test_v2a_batch_builder_preserves_preprocessed_media_alignment() -> None:
|
|
media = {
|
|
"audio": torch.ones(1),
|
|
"clip_frames": torch.ones(1),
|
|
"sync_frames": torch.ones(1),
|
|
"effective_duration": 1.0,
|
|
}
|
|
rows = [{
|
|
"id": "good",
|
|
"caption": "good sound",
|
|
"video_path": "good.mp4",
|
|
PREPROCESSED_MEDIA_KEY: media,
|
|
}, {
|
|
"id": "bad",
|
|
"caption": "bad sound",
|
|
"video_path": "bad.mp4",
|
|
PREPROCESS_ERROR_KEY: "decode failed",
|
|
}]
|
|
|
|
batch = V2AForwardBatchBuilder(seed=123)(rows)
|
|
|
|
assert batch.extra[PREPROCESSED_MEDIA_KEY] == [media, None]
|
|
assert batch.extra[PREPROCESS_ERROR_KEY] == [None, "decode failed"]
|
|
|
|
|
|
def test_v2a_torio_thread_prefetch_preserves_batch_order() -> None:
|
|
workflow = object.__new__(PreprocessWorkflowV2A)
|
|
workflow.training_dataloader = [
|
|
[{"id": "0"}, {"id": "1"}],
|
|
[{"id": "2"}, {"id": "3"}],
|
|
[{"id": "4"}],
|
|
]
|
|
workflow.torio_decode_workers = 2
|
|
workflow.torio_row_preprocessor = lambda row: {
|
|
**row,
|
|
"decoded": True,
|
|
}
|
|
|
|
batches = list(workflow._iter_preprocessed_rows())
|
|
|
|
assert [[row["id"] for row in batch] for batch in batches] == [
|
|
["0", "1"],
|
|
["2", "3"],
|
|
["4"],
|
|
]
|
|
assert all(row["decoded"] for batch in batches for row in batch)
|
|
|
|
|
|
def test_mmaudio_ffmpeg_sampler_recovers_fractional_fps_boundary(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
video_path = tmp_path / "fractional-fps.mp4"
|
|
_write_fractional_fps_video(video_path, duration_s=10.0)
|
|
|
|
clip_frames, sync_frames = basic_mmaudio_stages._read_frames_with_fps_filters(
|
|
video_path,
|
|
(8.0, 25.0),
|
|
start_s=0.0,
|
|
end_s=8.0,
|
|
)
|
|
|
|
assert clip_frames.shape == (64, 32, 32, 3)
|
|
assert sync_frames.shape == (200, 32, 32, 3)
|
|
|
|
|
|
def test_mmaudio_ffmpeg_sampler_does_not_pad_short_video(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
video_path = tmp_path / "short.mp4"
|
|
_write_fractional_fps_video(video_path, duration_s=7.0)
|
|
|
|
clip_frames, sync_frames = basic_mmaudio_stages._read_frames_with_fps_filters(
|
|
video_path,
|
|
(8.0, 25.0),
|
|
start_s=0.0,
|
|
end_s=8.0,
|
|
)
|
|
|
|
assert clip_frames.shape[0] < 64
|
|
assert sync_frames.shape[0] < 200
|
|
|
|
|
|
def test_mmaudio_inference_keeps_timestamp_sampler_by_default(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[str] = []
|
|
sampled_frames = [
|
|
np.zeros((8, 8, 8, 3), dtype=np.uint8),
|
|
np.zeros((25, 8, 8, 3), dtype=np.uint8),
|
|
]
|
|
|
|
def timestamp_sampler(*_args, **_kwargs):
|
|
calls.append("timestamp")
|
|
return sampled_frames
|
|
|
|
def ffmpeg_sampler(*_args, **_kwargs):
|
|
calls.append("ffmpeg")
|
|
return sampled_frames
|
|
|
|
monkeypatch.setattr(
|
|
basic_mmaudio_stages,
|
|
"_read_frames_at_fps",
|
|
timestamp_sampler,
|
|
)
|
|
monkeypatch.setattr(
|
|
basic_mmaudio_stages,
|
|
"_read_frames_with_fps_filters",
|
|
ffmpeg_sampler,
|
|
)
|
|
|
|
basic_mmaudio_stages.preprocess_mmaudio_video(
|
|
"unused.mp4",
|
|
duration_s=1.0,
|
|
clip_size=8,
|
|
sync_size=8,
|
|
)
|
|
basic_mmaudio_stages.preprocess_mmaudio_video(
|
|
"unused.mp4",
|
|
duration_s=1.0,
|
|
clip_size=8,
|
|
sync_size=8,
|
|
use_ffmpeg_fps_filter=True,
|
|
)
|
|
|
|
assert calls == ["timestamp", "ffmpeg"]
|
|
|
|
|
|
def test_v2a_writer_creates_discoverable_resumable_shards(tmp_path: Path) -> None:
|
|
writer = V2AFeatureShardWriter(tmp_path, rank=0, samples_per_shard=1)
|
|
features = {
|
|
"mean": torch.randn(1, 4, 3),
|
|
"std": torch.rand(1, 4, 3),
|
|
"clip_features": torch.randn(1, 6, 8),
|
|
"sync_features": torch.randn(1, 9, 10),
|
|
"text_features": torch.randn(1, 5, 7),
|
|
}
|
|
writer.append(features, [{"id": "sample-1", "caption": "sound"}])
|
|
writer.close()
|
|
|
|
shards = _expand_cache_root(tmp_path)
|
|
assert shards == [tmp_path / "worker_00000" / "shard_000000"]
|
|
metadata = json.loads((shards[0] / "samples.jsonl").read_text().strip())
|
|
assert metadata["id"] == "sample-1"
|
|
|
|
resumed = V2AFeatureShardWriter(tmp_path, rank=0, samples_per_shard=1)
|
|
assert resumed.contains("sample-1")
|
|
resumed.append(features, [{"id": "sample-1", "caption": "duplicate"}])
|
|
resumed.close()
|
|
assert len(_expand_cache_root(tmp_path)) == 1
|
|
|
|
|
|
def test_family_preprocessing_is_enabled_only_for_v2a() -> None:
|
|
registry = _PipelineRegistry({
|
|
PipelineType.PREPROCESS.value: {
|
|
"PreprocessPipelineT2V": _GenericT2VPreprocessPipeline,
|
|
"ExamplePreprocessPipeline": _FamilyPreprocessPipeline,
|
|
}
|
|
})
|
|
|
|
assert registry._load_preprocess_pipeline_cls("ExamplePipeline", WorkloadType.V2A) is _FamilyPreprocessPipeline
|
|
assert registry._load_preprocess_pipeline_cls("ExamplePipeline", WorkloadType.T2V) is _GenericT2VPreprocessPipeline
|