mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-27 18:36:28 +08:00
fix: recover bounded defects from PR backlog
This commit is contained in:
94
tests/tools/test_audio_mixer_target_duration.py
Normal file
94
tests/tools/test_audio_mixer_target_duration.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import math
|
||||
import struct
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.audio.audio_mixer import AudioMixer
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
|
||||
reason="ffmpeg and ffprobe are required",
|
||||
)
|
||||
|
||||
|
||||
def _tone(path: Path, frequency: int, duration: float) -> None:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-f", "lavfi", "-i",
|
||||
f"sine=frequency={frequency}:duration={duration}", str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _duration(path: Path) -> float:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", str(path),
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def _tail_rms(path: Path, start_seconds: float, end_seconds: float) -> float:
|
||||
with wave.open(str(path), "rb") as handle:
|
||||
sample_width = handle.getsampwidth()
|
||||
assert sample_width == 2
|
||||
frame_rate = handle.getframerate()
|
||||
handle.setpos(int(start_seconds * frame_rate))
|
||||
raw = handle.readframes(int((end_seconds - start_seconds) * frame_rate))
|
||||
samples = struct.unpack(f"<{len(raw) // 2}h", raw)
|
||||
return math.sqrt(sum(sample * sample for sample in samples) / len(samples))
|
||||
|
||||
|
||||
def test_full_mix_can_pin_the_composition_length(tmp_path) -> None:
|
||||
speech = tmp_path / "speech.wav"
|
||||
music = tmp_path / "music.wav"
|
||||
output = tmp_path / "mix.wav"
|
||||
_tone(speech, 440, 1)
|
||||
_tone(music, 220, 3)
|
||||
|
||||
result = AudioMixer().execute({
|
||||
"operation": "full_mix",
|
||||
"tracks": [
|
||||
{"path": str(speech), "role": "speech"},
|
||||
{"path": str(music), "role": "music"},
|
||||
],
|
||||
"ducking": {"enabled": True},
|
||||
"normalize": False,
|
||||
"target_duration": 3,
|
||||
"output_path": str(output),
|
||||
})
|
||||
|
||||
assert result.success, result.error
|
||||
assert result.data["target_duration"] == 3
|
||||
assert _duration(output) == pytest.approx(3.0, abs=0.15)
|
||||
assert _tail_rms(output, 2.0, 2.5) > 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", [0, -1, "not-a-number"])
|
||||
def test_full_mix_rejects_invalid_target_duration(tmp_path, target) -> None:
|
||||
tone = tmp_path / "tone.wav"
|
||||
_tone(tone, 440, 0.25)
|
||||
|
||||
result = AudioMixer().execute({
|
||||
"operation": "full_mix",
|
||||
"tracks": [{"path": str(tone), "role": "speech"}],
|
||||
"target_duration": target,
|
||||
"output_path": str(tmp_path / "mix.wav"),
|
||||
})
|
||||
|
||||
assert result.success is False
|
||||
assert "target_duration" in result.error
|
||||
28
tests/tools/test_bg_remove_api.py
Normal file
28
tests/tools/test_bg_remove_api.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def test_bg_remove_selects_model_through_rembg_session(monkeypatch, tmp_path) -> None:
|
||||
fake_rembg = MagicMock()
|
||||
fake_rembg.new_session.return_value = "selected-session"
|
||||
fake_rembg.remove.side_effect = lambda image, **kwargs: image.convert("RGBA")
|
||||
monkeypatch.setitem(sys.modules, "rembg", fake_rembg)
|
||||
|
||||
input_path = tmp_path / "input.png"
|
||||
Image.new("RGB", (8, 8), (10, 20, 30)).save(input_path)
|
||||
|
||||
from tools.enhancement.bg_remove import BgRemove
|
||||
|
||||
result = BgRemove().execute({
|
||||
"input_path": str(input_path),
|
||||
"output_path": str(tmp_path / "output.png"),
|
||||
"model": "isnet-general-use",
|
||||
})
|
||||
|
||||
assert result.success, result.error
|
||||
fake_rembg.new_session.assert_called_once_with("isnet-general-use")
|
||||
kwargs = fake_rembg.remove.call_args.kwargs
|
||||
assert kwargs["session"] == "selected-session"
|
||||
assert "model_name" not in kwargs
|
||||
177
tests/tools/test_cinematic_remotion_adapter.py
Normal file
177
tests/tools/test_cinematic_remotion_adapter.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.video.video_compose import VideoCompose
|
||||
|
||||
|
||||
def test_cinematic_cut_adapter_builds_a_sequential_timeline() -> None:
|
||||
scenes = VideoCompose._cuts_to_cinematic_scenes([
|
||||
{
|
||||
"id": "v1",
|
||||
"source": "clip.mp4",
|
||||
"in_seconds": 2,
|
||||
"out_seconds": 6,
|
||||
"transition_in": "cut",
|
||||
"transition_out": "none",
|
||||
},
|
||||
{
|
||||
"id": "title",
|
||||
"source": "",
|
||||
"type": "hero_title",
|
||||
"text": "The signal arrives",
|
||||
"in_seconds": 0,
|
||||
"out_seconds": 3,
|
||||
},
|
||||
])
|
||||
|
||||
assert scenes[0] == {
|
||||
"id": "v1",
|
||||
"startSeconds": 0.0,
|
||||
"durationSeconds": 4.0,
|
||||
"kind": "video",
|
||||
"src": "clip.mp4",
|
||||
"trimBeforeSeconds": 2.0,
|
||||
"trimAfterSeconds": 6.0,
|
||||
"playbackRate": 1.0,
|
||||
"fadeInFrames": 0,
|
||||
"fadeOutFrames": 0,
|
||||
}
|
||||
assert scenes[1]["kind"] == "title"
|
||||
assert scenes[1]["startSeconds"] == 4.0
|
||||
assert scenes[1]["text"] == "The signal arrives"
|
||||
|
||||
|
||||
def test_cinematic_cut_adapter_preserves_playback_speed() -> None:
|
||||
scenes = VideoCompose._cuts_to_cinematic_scenes([
|
||||
{
|
||||
"id": "fast",
|
||||
"source": "clip.mp4",
|
||||
"in_seconds": 2,
|
||||
"out_seconds": 6,
|
||||
"speed": 2,
|
||||
}
|
||||
])
|
||||
|
||||
assert scenes[0]["durationSeconds"] == 2
|
||||
assert scenes[0]["playbackRate"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uri_style", ["standard", "legacy_windows"])
|
||||
def test_remotion_media_staging_decodes_file_uris(tmp_path, uri_style) -> None:
|
||||
source = tmp_path / "clip with space.mp4"
|
||||
source.write_bytes(b"video")
|
||||
public_dir = tmp_path / "public"
|
||||
uri = source.as_uri()
|
||||
if uri_style == "legacy_windows" and len(source.drive) == 2:
|
||||
uri = f"file://{source.drive}{source.as_posix()[2:]}".replace(" ", "%20")
|
||||
props = {"scenes": [{"src": uri}]}
|
||||
|
||||
staged_count = VideoCompose._stage_remotion_media(props, public_dir)
|
||||
|
||||
assert staged_count == 1
|
||||
assert props["scenes"][0]["src"] != uri
|
||||
assert (public_dir / props["scenes"][0]["src"]).read_bytes() == b"video"
|
||||
|
||||
|
||||
def test_remotion_render_adapts_cuts_and_stages_local_video(monkeypatch, tmp_path) -> None:
|
||||
source = tmp_path / "source.mp4"
|
||||
source.write_bytes(b"not-a-real-video")
|
||||
output = tmp_path / "render.mp4"
|
||||
captured = {}
|
||||
|
||||
def fake_run_command(self, command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["timeout"] = kwargs["timeout"]
|
||||
props_arg = next(arg for arg in command if arg.startswith("--props="))
|
||||
captured["props"] = json.loads(Path(props_arg.split("=", 1)[1]).read_text())
|
||||
public_arg = next(arg for arg in command if arg.startswith("--public-dir="))
|
||||
public_dir = Path(public_arg.split("=", 1)[1])
|
||||
captured["staged_exists_during_render"] = (
|
||||
public_dir / captured["props"]["scenes"][0]["src"]
|
||||
).exists()
|
||||
output.write_bytes(b"rendered")
|
||||
|
||||
monkeypatch.setattr(VideoCompose, "run_command", fake_run_command)
|
||||
|
||||
result = VideoCompose()._remotion_render({
|
||||
"edit_decisions": {
|
||||
"renderer_family": "cinematic-trailer",
|
||||
"cuts": [
|
||||
{
|
||||
"id": "v1",
|
||||
"source": str(source),
|
||||
"in_seconds": 0,
|
||||
"out_seconds": 2,
|
||||
}
|
||||
],
|
||||
},
|
||||
"output_path": str(output),
|
||||
})
|
||||
|
||||
assert result.success, result.error
|
||||
assert "cuts" not in captured["props"]
|
||||
assert captured["props"]["scenes"][0]["kind"] == "video"
|
||||
assert captured["staged_exists_during_render"] is True
|
||||
assert result.data["staged_media_count"] == 1
|
||||
|
||||
|
||||
def test_remotion_timeout_scales_with_scene_count(monkeypatch, tmp_path) -> None:
|
||||
output = tmp_path / "render.mp4"
|
||||
captured = {}
|
||||
|
||||
def fake_run_command(self, command, **kwargs):
|
||||
captured["timeout"] = kwargs["timeout"]
|
||||
output.write_bytes(b"rendered")
|
||||
|
||||
monkeypatch.setattr(VideoCompose, "run_command", fake_run_command)
|
||||
cuts = [
|
||||
{
|
||||
"id": f"title-{index}",
|
||||
"source": "",
|
||||
"type": "hero_title",
|
||||
"text": str(index),
|
||||
"in_seconds": 0,
|
||||
"out_seconds": 1,
|
||||
}
|
||||
for index in range(50)
|
||||
]
|
||||
|
||||
result = VideoCompose()._remotion_render({
|
||||
"edit_decisions": {"renderer_family": "cinematic-trailer", "cuts": cuts},
|
||||
"output_path": str(output),
|
||||
})
|
||||
|
||||
assert result.success, result.error
|
||||
assert captured["timeout"] == 750
|
||||
|
||||
|
||||
def test_remotion_render_preserves_direct_cinematic_scenes(monkeypatch, tmp_path) -> None:
|
||||
output = tmp_path / "render.mp4"
|
||||
captured = {}
|
||||
|
||||
def fake_run_command(self, command, **kwargs):
|
||||
props_arg = next(arg for arg in command if arg.startswith("--props="))
|
||||
captured["props"] = json.loads(Path(props_arg.split("=", 1)[1]).read_text())
|
||||
output.write_bytes(b"rendered")
|
||||
|
||||
monkeypatch.setattr(VideoCompose, "run_command", fake_run_command)
|
||||
scene = {
|
||||
"id": "authored",
|
||||
"kind": "title",
|
||||
"text": "Keep me",
|
||||
"startSeconds": 0,
|
||||
"durationSeconds": 1,
|
||||
}
|
||||
|
||||
result = VideoCompose()._remotion_render({
|
||||
"composition_data": {
|
||||
"renderer_family": "cinematic-trailer",
|
||||
"scenes": [scene],
|
||||
},
|
||||
"output_path": str(output),
|
||||
})
|
||||
|
||||
assert result.success, result.error
|
||||
assert captured["props"]["scenes"] == [scene]
|
||||
59
tests/tools/test_corpus_builder_total_failure.py
Normal file
59
tests/tools/test_corpus_builder_total_failure.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.video.stock_sources as stock_sources
|
||||
from tools.video.corpus_builder import CorpusBuilder
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Candidate:
|
||||
clip_id: str
|
||||
|
||||
|
||||
class _Source:
|
||||
name = "fake"
|
||||
|
||||
def __init__(self, count: int) -> None:
|
||||
self.count = count
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def search(self, query, filters):
|
||||
return [_Candidate(f"clip-{index}") for index in range(self.count)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run_builder(monkeypatch, tmp_path):
|
||||
def run(count: int, processor):
|
||||
monkeypatch.setattr(stock_sources, "available_sources", lambda: [_Source(count)])
|
||||
monkeypatch.setattr(stock_sources, "source_summary", lambda: {})
|
||||
monkeypatch.setattr(CorpusBuilder, "_process_candidate", processor)
|
||||
return CorpusBuilder().execute({
|
||||
"corpus_dir": str(tmp_path / f"corpus-{count}"),
|
||||
"queries": [{"query": "city at night"}],
|
||||
"max_new_clips": 50,
|
||||
})
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def test_all_candidate_failures_fail_closed_with_diagnostics(run_builder) -> None:
|
||||
def broken_clip_stack(*args, **kwargs):
|
||||
raise AttributeError("BaseModelOutput has no attribute norm")
|
||||
|
||||
result = run_builder(4, broken_clip_stack)
|
||||
|
||||
assert result.success is False
|
||||
assert result.data["candidates_seen"] == 4
|
||||
assert result.data["clips_failed"] == 4
|
||||
assert "corpus index is empty" in result.error
|
||||
assert "BaseModelOutput" in result.error
|
||||
|
||||
|
||||
def test_no_candidates_is_a_valid_empty_search(run_builder) -> None:
|
||||
result = run_builder(0, lambda *args, **kwargs: None)
|
||||
|
||||
assert result.success is True
|
||||
assert result.data["candidates_seen"] == 0
|
||||
97
tests/tools/test_google_vertex_backends.py
Normal file
97
tests/tools/test_google_vertex_backends.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_blank_google_location_uses_documented_default(monkeypatch) -> None:
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "")
|
||||
from tools.google_credentials import resolve_google_location
|
||||
|
||||
assert resolve_google_location() == "us-central1"
|
||||
|
||||
|
||||
def test_google_music_requests_the_global_vertex_location(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
|
||||
import tools.google_credentials as credentials
|
||||
|
||||
captured = {}
|
||||
|
||||
def stop_before_network(http_options=None, location=None):
|
||||
captured["location"] = location
|
||||
raise RuntimeError("stop before network")
|
||||
|
||||
monkeypatch.setattr(credentials, "get_genai_client", stop_before_network)
|
||||
|
||||
from tools.audio.google_music import GoogleMusic
|
||||
|
||||
result = GoogleMusic().execute({
|
||||
"prompt": "solo piano",
|
||||
"output_path": str(tmp_path / "music.mp3"),
|
||||
})
|
||||
|
||||
assert result.success is False
|
||||
assert captured["location"] == "global"
|
||||
|
||||
|
||||
class _VideoAsset:
|
||||
uri = None
|
||||
|
||||
def __init__(self, video_bytes):
|
||||
self.video_bytes = video_bytes
|
||||
|
||||
def save(self, path):
|
||||
Path(path).write_bytes(self.video_bytes or b"")
|
||||
|
||||
|
||||
class _Models:
|
||||
def __init__(self, video_bytes):
|
||||
self.video_bytes = video_bytes
|
||||
|
||||
def generate_videos(self, **kwargs):
|
||||
asset = _VideoAsset(self.video_bytes)
|
||||
generated = SimpleNamespace(video=asset)
|
||||
response = SimpleNamespace(generated_videos=[generated])
|
||||
return SimpleNamespace(done=True, error=None, response=response)
|
||||
|
||||
|
||||
class _VertexClient:
|
||||
vertexai = True
|
||||
|
||||
def __init__(self, video_bytes):
|
||||
self.models = _Models(video_bytes)
|
||||
self.files = SimpleNamespace(
|
||||
download=lambda **kwargs: pytest.fail("Vertex must not call files.download")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("video_bytes", "expected_success"),
|
||||
[(b"VIDEO_BYTES", True), (None, False)],
|
||||
)
|
||||
def test_veo_accepts_vertex_and_requires_inline_bytes(
|
||||
monkeypatch, tmp_path, video_bytes, expected_success
|
||||
) -> None:
|
||||
monkeypatch.setenv("GOOGLE_API_KEY", "test-key")
|
||||
import tools.google_credentials as credentials
|
||||
|
||||
monkeypatch.setattr(
|
||||
credentials,
|
||||
"get_genai_client",
|
||||
lambda http_options=None, location=None: _VertexClient(video_bytes),
|
||||
)
|
||||
|
||||
from tools.video.veo_video import VeoVideo
|
||||
|
||||
output = tmp_path / "video.mp4"
|
||||
result = VeoVideo().execute({
|
||||
"backend": "google",
|
||||
"prompt": "wind moving across grassland",
|
||||
"output_path": str(output),
|
||||
})
|
||||
|
||||
assert result.success is expected_success
|
||||
if expected_success:
|
||||
assert output.read_bytes() == b"VIDEO_BYTES"
|
||||
else:
|
||||
assert "without inline bytes" in result.error
|
||||
@@ -250,6 +250,11 @@ def test_runtime_check_succeeds_when_npm_resolves(monkeypatch):
|
||||
"_resolve_npm_package",
|
||||
classmethod(lambda cls: {"version": "0.4.5"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
HyperFramesCompose,
|
||||
"_probe_cli",
|
||||
classmethod(lambda cls: {"status": "ok"}),
|
||||
)
|
||||
rc = HyperFramesCompose()._runtime_check()
|
||||
# Local binaries must still pass for this to go green.
|
||||
if rc["node_major"] is None or not rc["ffmpeg_available"] or not rc["npx_available"]:
|
||||
@@ -259,6 +264,31 @@ def test_runtime_check_succeeds_when_npm_resolves(monkeypatch):
|
||||
assert rc["reasons"] == []
|
||||
|
||||
|
||||
def test_runtime_check_fails_when_published_cli_crashes(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
HyperFramesCompose,
|
||||
"_resolve_npm_package",
|
||||
classmethod(lambda cls: {"version": "0.7.89"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
HyperFramesCompose,
|
||||
"_probe_cli",
|
||||
classmethod(
|
||||
lambda cls: {
|
||||
"error": 'doctor failed: The "file" argument must be of type string'
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
rc = HyperFramesCompose()._runtime_check()
|
||||
|
||||
if rc["node_major"] is None or not rc["ffmpeg_available"] or not rc["npx_available"]:
|
||||
pytest.skip("Local runtime floor not met on this machine")
|
||||
assert rc["runtime_available"] is False
|
||||
assert rc["cli_probe_error"] is not None
|
||||
assert any("not executable" in reason for reason in rc["reasons"])
|
||||
|
||||
|
||||
def test_video_compose_render_engines_follow_hyperframes_runtime_check(monkeypatch):
|
||||
"""Regression: `video_compose.get_info()['render_engines']['hyperframes']`
|
||||
must track the true availability, not just the local-binary floor.
|
||||
|
||||
@@ -213,8 +213,9 @@ def test_upscale_build_upsampler_uses_signature_guard(monkeypatch):
|
||||
|
||||
# Build a fake RealESRGANer whose __init__ DOES accept device=
|
||||
class FakeRealESRGANer:
|
||||
def __init__(self, *, scale, model_path, model, dni_weight, half, device=None):
|
||||
def __init__(self, *, scale, model_path, model, dni_weight, half, tile=0, tile_pad=10, device=None):
|
||||
self.called_with_device = device
|
||||
self.called_with_tile = tile
|
||||
fake_realesrganer_cls = FakeRealESRGANer
|
||||
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
@@ -239,6 +240,7 @@ def test_upscale_build_upsampler_uses_signature_guard(monkeypatch):
|
||||
tool = upscale.Upscale()
|
||||
result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False)
|
||||
assert result.called_with_device == "device(mps)"
|
||||
assert result.called_with_tile == 256
|
||||
|
||||
|
||||
def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch):
|
||||
@@ -252,8 +254,9 @@ def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch):
|
||||
|
||||
# Build a fake RealESRGANer whose __init__ does NOT accept device=
|
||||
class FakeRealESRGANerNoDevice:
|
||||
def __init__(self, *, scale, model_path, model, dni_weight, half):
|
||||
def __init__(self, *, scale, model_path, model, dni_weight, half, tile=0, tile_pad=10):
|
||||
self.called_with_device = None # no device param
|
||||
self.called_with_tile = tile
|
||||
fake_realesrganer_cls = FakeRealESRGANerNoDevice
|
||||
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
@@ -279,6 +282,7 @@ def test_upscale_build_upsampler_skips_device_when_unsupported(monkeypatch):
|
||||
# Should NOT raise TypeError about unexpected keyword argument 'device'
|
||||
result = tool._build_upsampler(scale=4, model_name="RealESRGAN_x4plus", denoise_strength=0.5, face_enhance=False)
|
||||
assert result.called_with_device is None
|
||||
assert result.called_with_tile == 256
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
59
tests/tools/test_remotion_audio_mux.py
Normal file
59
tests/tools/test_remotion_audio_mux.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.video.video_compose import VideoCompose
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
|
||||
reason="ffmpeg and ffprobe are required",
|
||||
)
|
||||
|
||||
|
||||
def _run(command: list[str]) -> None:
|
||||
subprocess.run(command, capture_output=True, check=True, timeout=30)
|
||||
|
||||
|
||||
def test_external_audio_mux_adds_audible_stream_without_changing_video_length(tmp_path) -> None:
|
||||
video = tmp_path / "video.mp4"
|
||||
audio = tmp_path / "audio.wav"
|
||||
_run([
|
||||
"ffmpeg", "-y", "-f", "lavfi", "-i",
|
||||
"color=c=red:s=320x180:d=2:r=30",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(video),
|
||||
])
|
||||
_run([
|
||||
"ffmpeg", "-y", "-f", "lavfi", "-i",
|
||||
"sine=frequency=440:duration=1", str(audio),
|
||||
])
|
||||
|
||||
result = VideoCompose()._mux_external_audio(video, audio)
|
||||
|
||||
assert result.success, result.error
|
||||
streams = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error", "-show_entries",
|
||||
"stream=codec_type", "-of", "csv=p=0", str(video),
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
).stdout.splitlines()
|
||||
duration = float(subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", str(video),
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
).stdout.strip())
|
||||
|
||||
assert "video" in streams
|
||||
assert "audio" in streams
|
||||
assert duration == pytest.approx(2.0, abs=0.15)
|
||||
83
tests/tools/test_transcriber_device_selection.py
Normal file
83
tests/tools/test_transcriber_device_selection.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tools.analysis.transcriber import Transcriber
|
||||
|
||||
|
||||
class _Info:
|
||||
language = "en"
|
||||
duration = 1.0
|
||||
|
||||
|
||||
def test_transcriber_uses_ctranslate2_cuda_without_torch(monkeypatch, tmp_path) -> None:
|
||||
devices = []
|
||||
|
||||
class FakeWhisperModel:
|
||||
def __init__(self, model_size, *, device, compute_type):
|
||||
devices.append((device, compute_type))
|
||||
|
||||
def transcribe(self, *args, **kwargs):
|
||||
return iter(()), _Info()
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"faster_whisper",
|
||||
SimpleNamespace(WhisperModel=FakeWhisperModel),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"ctranslate2",
|
||||
SimpleNamespace(
|
||||
get_cuda_device_count=lambda: 1,
|
||||
get_supported_compute_types=lambda device: {"float16", "float32"},
|
||||
),
|
||||
)
|
||||
input_path = tmp_path / "audio.wav"
|
||||
input_path.write_bytes(b"fake")
|
||||
|
||||
result = Transcriber().execute({"input_path": str(input_path), "output_dir": str(tmp_path)})
|
||||
|
||||
assert result.success, result.error
|
||||
assert devices == [("cuda", "float16")]
|
||||
assert result.data["device"] == "cuda"
|
||||
|
||||
|
||||
def test_transcriber_falls_back_when_cuda_fails_during_iteration(monkeypatch, tmp_path) -> None:
|
||||
devices = []
|
||||
|
||||
class FakeWhisperModel:
|
||||
def __init__(self, model_size, *, device, compute_type):
|
||||
self.device = device
|
||||
devices.append((device, compute_type))
|
||||
|
||||
def transcribe(self, *args, **kwargs):
|
||||
if self.device == "cuda":
|
||||
def broken_iterator():
|
||||
raise RuntimeError("cublas64_12.dll not found")
|
||||
yield
|
||||
|
||||
return broken_iterator(), _Info()
|
||||
return iter(()), _Info()
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"faster_whisper",
|
||||
SimpleNamespace(WhisperModel=FakeWhisperModel),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"ctranslate2",
|
||||
SimpleNamespace(
|
||||
get_cuda_device_count=lambda: 1,
|
||||
get_supported_compute_types=lambda device: {"float16"},
|
||||
),
|
||||
)
|
||||
input_path = tmp_path / "audio.wav"
|
||||
input_path.write_bytes(b"fake")
|
||||
|
||||
result = Transcriber().execute({"input_path": str(input_path), "output_dir": str(tmp_path)})
|
||||
|
||||
assert result.success, result.error
|
||||
assert devices == [("cuda", "float16"), ("cpu", "int8")]
|
||||
assert result.data["device"] == "cpu"
|
||||
assert "cublas64_12.dll" in result.data["gpu_fallback_reason"]
|
||||
Reference in New Issue
Block a user