C3: keep the soundtrack LTX generates, and stop reporting every file as silent

LTX-2.x is the one video family here with an audio VAE and a vocoder, so its
output is LTX2PipelineOutput(frames=..., audio=...) -- audio generated jointly
with the frames and therefore actually in step with them. Only `frames` was
read, so every LTX render came out silent and the only way to get sound was to
generate some separately with another model and mux that on top, which does not
match the picture. The generated track is now muxed in BEFORE the other
post-processing steps, because every one of them re-encodes the frames and the
track belongs to these exact ones.

`add_audio` becomes tri-state so "say nothing" and "say no" differ: unset keeps
what the model generated, false gives a silent video, true runs the older
separate-generation path. It defaulted to False, which cannot be told from an
explicit false -- so a straight default would have dropped the soundtrack. None
is falsy everywhere False was, so existing callers are unaffected.

Found while testing it: _has_audio tried `import av` under a bare
`except: return False`, and PyAV is in no main requirements file -- only the
isolated LongCat venv's. On an install without it EVERY file answered "no
audio", and because it gates _mux_original_audio, a clip's own sound was
silently dropped from every upscale and interpolate. The audio was there,
nothing could see it, and nothing said so. ffprobe cannot be missing the way
PyAV can, since ffmpeg is what does the muxing, so it is the fallback, and a
failure of both is logged instead of reported as silence.

Also: one of my own tests asserted a minimum number of ffmpeg progress reports.
`-progress` emits on a time interval, so a small encode can finish having
emitted a single block -- it passed alone and failed under the full suite. It
now asserts what is guaranteed: frames counted, counted upward.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T3rZozi1Ehk96NG1WesgC
parent 96a84b69
......@@ -217,12 +217,41 @@ def _encode_mp4_pyav(frames, fps: int, crf: int) -> bytes:
def _has_audio(path: str) -> bool:
"""True when ``path`` carries at least one audio stream."""
"""True when ``path`` carries at least one audio stream.
PyAV first, ffprobe second. PyAV is in no main requirements file -- only
the isolated LongCat venv's -- so on an install without it ``import av``
raised and the bare except turned EVERY file into "no audio". This gates
_mux_original_audio, so the effect was that a clip's own sound was
silently dropped from every upscale and interpolate: the audio was there,
nothing could see it, and nothing said so.
ffprobe cannot be missing here the way PyAV can -- ffmpeg is what does the
muxing in the first place -- so it is the answer that can be trusted, and a
failure of BOTH is logged rather than quietly reported as silence.
"""
try:
import av
with av.open(path) as c:
return bool(c.streams.audio)
except ImportError:
pass # expected without PyAV; ffprobe below
except Exception:
pass # unreadable by PyAV; let ffprobe try
import logging
_log = logging.getLogger(__name__)
try:
import subprocess as _sp
r = _sp.run(["ffprobe", "-v", "error", "-select_streams", "a",
"-show_entries", "stream=index", "-of", "csv=p=0", path],
capture_output=True, timeout=120)
if r.returncode == 0:
return bool((r.stdout or b"").strip())
_log.warning("cannot tell whether %s has audio (ffprobe rc=%d): %s",
path, r.returncode,
(r.stderr or b"").decode(errors="replace")[:200])
except Exception as exc:
_log.warning("cannot tell whether %s has audio: %s", path, exc)
return False
......@@ -2442,7 +2471,8 @@ def _run_handoff(pipe, kw: dict, request, h: dict):
return _frames_from_result(result)
def _run_pipeline_split(pipe, kw: dict, request, comp: dict):
def _run_pipeline_split(pipe, kw: dict, request, comp: dict,
audio_out: Optional[dict] = None):
"""The head's side: encode where the text encoder is, denoise up to the
expert boundary here, hand the latents to the low-noise machine, decode
where the VAE is. Returns frames, or the far machine's finished answer."""
......@@ -2463,7 +2493,7 @@ def _run_pipeline_split(pipe, kw: dict, request, comp: dict):
k = None # every step is low-noise: nothing to do here
vae_remote = comp.get('vae')
if k is None and not vae_remote:
return _run_pipeline(pipe, kw)
return _run_pipeline(pipe, kw, audio_out)
# Run here up to the boundary (or the whole loop), keeping latents.
user_cb = kw.get('callback_on_step_end')
......@@ -2534,8 +2564,154 @@ def _frames_from_result(result):
return list(frames_raw)
def _run_pipeline(pipe, kw: dict):
def _pipeline_audio(result, pipe):
"""The soundtrack a pipeline generated with the picture, or ``None``.
LTX-2.x is the one video family here with an audio VAE and a vocoder, so
its output is ``LTX2PipelineOutput(frames=..., audio=...)`` -- a real
waveform, generated jointly with the frames and therefore actually in step
with them. It used to be dropped on the floor: only ``frames`` was read, so
every LTX render came out silent and the only way to get sound was to
generate some separately with another model and mux that on top, which is
not the same thing and does not match the picture.
Returns ``(waveform, sample_rate)`` with the waveform shaped
``(samples,)`` or ``(channels, samples)``.
"""
audio = getattr(result, "audio", None)
if audio is None and isinstance(result, (tuple, list)) and len(result) > 1:
audio = result[1]
if audio is None:
return None
rate = getattr(pipe, "audio_sampling_rate", None)
if not rate:
vae = getattr(pipe, "audio_vae", None)
rate = getattr(getattr(vae, "config", None), "sample_rate", None)
try:
rate = int(rate or 0)
except Exception:
rate = 0
if rate <= 0:
print(" [video] a soundtrack was generated but its sample rate is "
"unknown; keeping the video silent rather than guessing",
flush=True)
return None
try:
import numpy as _np
import torch as _torch
if isinstance(audio, _torch.Tensor):
audio = audio.detach().to("cpu", _torch.float32).numpy()
audio = _np.asarray(audio, dtype="float32")
while audio.ndim > 2:
audio = audio[0] # drop batch dimensions
if audio.size == 0:
return None
except Exception as exc:
print(f" [video] could not read the generated soundtrack ({exc}); "
f"keeping the video silent", flush=True)
return None
return audio, rate
def _write_wav(audio, rate: int, temps: list) -> Optional[str]:
"""The waveform as a 16-bit PCM wav file, or None."""
import wave
import numpy as _np
a = _np.asarray(audio, dtype="float32")
if a.ndim == 2:
# (channels, samples) -> interleaved (samples, channels)
if a.shape[0] <= 8 and a.shape[0] < a.shape[1]:
a = a.T
channels = a.shape[1]
else:
channels = 1
peak = float(_np.max(_np.abs(a))) if a.size else 0.0
if peak > 1.0:
a = a / peak # the vocoder is not bound to [-1, 1]
pcm = _np.clip(a, -1.0, 1.0)
pcm = (pcm * 32767.0).astype("<i2")
path = _tmp_write(b"", ".wav")
temps.append(path)
try:
with wave.open(path, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2)
wf.setframerate(int(rate))
wf.writeframes(pcm.tobytes())
except Exception as exc:
print(f" [video] could not write the generated soundtrack ({exc})",
flush=True)
return None
return path
def _mux_generated_audio(video_path: str, audio, rate: int,
temps: list) -> str:
"""``video_path`` with the generated soundtrack on it.
Returns the path unchanged on any failure: a silent video is a worse
result than a loud one, but it is far better than no video at all, and the
frames are the part the caller asked for.
"""
wav = _write_wav(audio, rate, temps)
if not wav:
return video_path
out = _tmp_write(b"", ".mp4")
temps.append(out)
try:
import subprocess as _sp
from codai.compose import media as _media
cmd = [_media.ffmpeg_bin(), "-hide_banner", "-nostdin", "-y",
"-i", video_path, "-i", wav,
# The picture is already encoded; copying it keeps this a remux
# rather than a second generation loss.
"-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy",
"-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
"-shortest", "-movflags", "+faststart", out]
r = _sp.run(cmd, capture_output=True, timeout=900)
if r.returncode != 0 or not os.path.exists(out) or os.path.getsize(out) == 0:
tail = (r.stderr or b"").decode(errors="replace").strip().splitlines()[-4:]
print(f" [video] muxing the generated soundtrack failed: "
f"{' | '.join(tail)}", flush=True)
return video_path
except Exception as exc:
print(f" [video] muxing the generated soundtrack failed ({exc})",
flush=True)
return video_path
print(f" [video] generated soundtrack muxed in ({rate} Hz)", flush=True)
return out
def _mux_generated_audio_bytes(mp4_bytes: bytes, audio, rate: int,
temps: list) -> bytes:
"""``_mux_generated_audio`` over an mp4 blob rather than a path."""
src = _tmp_write(mp4_bytes, ".mp4")
temps.append(src)
out = _mux_generated_audio(src, audio, rate, temps)
if out == src:
return mp4_bytes
try:
with open(out, "rb") as fh:
return fh.read()
except Exception:
return mp4_bytes
def _run_pipeline(pipe, kw: dict, audio_out: Optional[dict] = None):
"""The frames a pipeline produced.
``audio_out``, when given, is filled with ``{"audio", "sample_rate"}`` if
the pipeline generated a soundtrack alongside the picture (LTX-2.x does).
Passed in rather than returned so the call sites keep their shape.
"""
result = pipe(**kw)
if audio_out is not None:
got = _pipeline_audio(result, pipe)
if got is not None:
audio_out["audio"], audio_out["sample_rate"] = got
# NB: `getattr(result, 'frames', None) or result[0]` is WRONG — when .frames
# is a numpy array, `bool(array)` raises "truth value of an array ... is
# ambiguous". Check for None explicitly instead of using `or`.
......@@ -2857,18 +3033,21 @@ def _generate_video(pipe, request: VideoGenerationRequest):
# Parts of this pipeline on other machines (codai/cluster/components.py),
# or this machine doing ITS part of someone else's run.
_handoff = getattr(request, '_handoff', None)
# Filled when the pipeline generates a soundtrack with the picture
# (LTX-2.x). Carried out with the frames so the encoder can mux it.
_gen_audio: dict = {}
if isinstance(_handoff, dict) and _handoff.get('op'):
frames = _run_handoff(pipe, kw, request, _handoff)
else:
from codai.cluster.components import components_for as _components_for
_comp = _components_for(getattr(request, 'model', '') or '')
if _comp and type(pipe).__name__ in ('WanPipeline', 'WanImageToVideoPipeline'):
frames = _run_pipeline_split(pipe, kw, request, _comp)
frames = _run_pipeline_split(pipe, kw, request, _comp, _gen_audio)
else:
if _comp:
print(f" [video][components] {type(pipe).__name__} cannot be split "
f"across machines; running here", flush=True)
frames = _run_pipeline(pipe, kw)
frames = _run_pipeline(pipe, kw, _gen_audio)
except TaskCancelled:
_vid_progress_done()
raise # global handler finishes the task (cancelled) + returns HTTP 499
......@@ -2885,7 +3064,7 @@ def _generate_video(pipe, request: VideoGenerationRequest):
frames = frames[_vace_trim:]
except Exception:
pass
return frames, fps
return frames, fps, _gen_audio
# =============================================================================
......@@ -5116,8 +5295,9 @@ async def video_generations(request: VideoGenerationRequest,
if _is_sdcpp_video:
frames, fps = await _loop.run_in_executor(
None, _generate_sdcpp_video, pipe, request, _model_cfg)
_gen_audio = {}
else:
frames, fps = await _loop.run_in_executor(
frames, fps, _gen_audio = await _loop.run_in_executor(
None, _generate_video, pipe, request)
_last_err = None
break # success → return to client
......@@ -5159,8 +5339,9 @@ async def video_generations(request: VideoGenerationRequest,
if _is_sdcpp_video:
frames, fps = await _loop.run_in_executor(
None, _generate_sdcpp_video, pipe, request, _model_cfg)
_gen_audio = {}
else:
frames, fps = await _loop.run_in_executor(
frames, fps, _gen_audio = await _loop.run_in_executor(
None, _generate_video, pipe, request)
_last_err = None
break # success → return to client
......@@ -5223,6 +5404,17 @@ async def video_generations(request: VideoGenerationRequest,
if _identity_note:
gen_warnings.append(_identity_note)
try:
# A soundtrack the MODEL generated with the picture (LTX-2.x) goes on
# before anything else, because it belongs to these exact frames and
# every later step re-encodes them. `add_audio: false` is how a caller
# asks for the video silent -- it defaults to None, which means "keep
# what was generated"; only an explicit false suppresses it. A
# separately generated track (add_audio truthy + an audio model) is a
# different feature and still runs in _postprocess_video below.
if _gen_audio.get("audio") is not None and request.add_audio is None:
mp4_bytes = await asyncio.get_event_loop().run_in_executor(
None, _mux_generated_audio_bytes, mp4_bytes,
_gen_audio["audio"], _gen_audio["sample_rate"], temps)
needs_post = any([
request.upscale_output,
request.interpolate_output,
......
......@@ -155,7 +155,22 @@ class VideoGenerationRequest(BaseModel):
loras: Optional[List[VideoLoraConfig]] = Field(None, description="Per-request LoRA adapters (e.g. trained per-character identity LoRAs) fused into the pipeline.")
# ── Audio generation / manipulation ──────────────────────────────────
add_audio: Optional[bool] = Field(False, description="Add an audio track to the generated video.")
#: Tri-state on purpose, so "say nothing" and "say no" are different.
#: None (default) - keep whatever the MODEL generated with the picture.
#: LTX-2.x has an audio VAE and returns a soundtrack in
#: step with its own frames; dropping it by default
#: would throw away the only audio that actually
#: matches the video.
#: False - a silent video, even when one was generated.
#: True - generate a track separately with an audio model and
#: mux that instead (the older post-processing path).
#: `False` was the default, and None is falsy in the same places, so every
#: existing caller behaves exactly as before.
add_audio: Optional[bool] = Field(
None, description="Audio track: leave unset to keep a soundtrack the "
"model generated with the video (LTX-2.x), false "
"for a silent video, true to generate one "
"separately with an audio model.")
audio_type: Optional[str] = Field(None, description="Audio to generate/add: 'music', 'speech', 'sfx' or 'ambient'.")
audio_prompt: Optional[str] = Field(None, description="Prompt for generated music/sfx.")
audio_file: Optional[str] = Field(None, description="Existing audio to add (base64/URL).")
......
......@@ -122,7 +122,12 @@ def test_run_reports_frames_written(tmp_path):
on_frame=seen.append, timeout=120)
assert seen, "ffmpeg wrote frames but none were reported"
assert seen == sorted(seen), f"the frame count went backwards: {seen}"
assert seen[-1] >= 10, f"only reached {seen[-1]} frames of ~20"
# Only that frames were counted, and counted upward. NOT how many reports
# arrive or how far they get: `-progress` emits on a time interval, so a
# small encode can finish having emitted a single block, and asserting a
# minimum made this pass alone and fail under the full suite -- a flake in
# the test, not in the hook.
assert seen[-1] >= 1, f"the reported frame count never advanced: {seen}"
@needs_ffmpeg
......
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""C3: the soundtrack LTX generates with the picture must reach the MP4.
LTX-2.x is the one video family here with an audio VAE and a vocoder, so its
output is `LTX2PipelineOutput(frames=..., audio=...)` -- audio generated
jointly with the frames and therefore actually in step with them. Only
`frames` was read, so every LTX render came out silent and the only way to get
sound was to generate some separately with another model and mux that on top,
which does not match the picture.
`add_audio` is tri-state so that "say nothing" and "say no" differ: unset keeps
what the model generated, false gives a silent video, true runs the older
separate-generation path.
"""
import os
import subprocess
import pytest
from codai.api import video as V
def _ffmpeg_ok():
try:
from codai.compose import media
subprocess.run([media.ffmpeg_bin(), "-version"],
capture_output=True, timeout=30)
return True
except Exception:
return False
needs_ffmpeg = pytest.mark.skipif(not _ffmpeg_ok(), reason="ffmpeg not available")
np = pytest.importorskip("numpy")
# ------------------------------------------------------- reading the output
class _Pipe:
audio_sampling_rate = 44100
class _Out:
def __init__(self, frames, audio):
self.frames = frames
self.audio = audio
def test_a_pipeline_that_generated_audio_hands_it_over():
got = V._pipeline_audio(_Out("frames", np.zeros(1000, dtype="float32")),
_Pipe())
assert got is not None
audio, rate = got
assert rate == 44100 and audio.shape == (1000,)
def test_a_tuple_result_works_too():
"""`return_dict=False` gives (video, audio)."""
got = V._pipeline_audio(("frames", np.zeros(8, dtype="float32")), _Pipe())
assert got is not None and got[1] == 44100
def test_a_pipeline_with_no_audio_hands_over_nothing():
"""Every other video family: there is no soundtrack to carry."""
class _NoAudio:
frames = "frames"
assert V._pipeline_audio(_NoAudio(), _Pipe()) is None
def test_an_unknown_sample_rate_is_refused_rather_than_guessed():
"""A wrong rate plays the soundtrack at the wrong speed, which is worse
than no soundtrack."""
class _NoRate:
pass
assert V._pipeline_audio(_Out("f", np.zeros(8, dtype="float32")),
_NoRate()) is None
def test_the_rate_falls_back_to_the_audio_vae_config():
class _Vae:
class config:
sample_rate = 16000
class _P:
audio_vae = _Vae()
got = V._pipeline_audio(_Out("f", np.zeros(4, dtype="float32")), _P())
assert got is not None and got[1] == 16000
def test_batch_dimensions_are_dropped():
audio = np.zeros((1, 2, 500), dtype="float32")
got = V._pipeline_audio(_Out("f", audio), _Pipe())
assert got is not None and got[0].shape == (2, 500)
def test_an_empty_waveform_is_not_muxed():
got = V._pipeline_audio(_Out("f", np.zeros((0,), dtype="float32")), _Pipe())
assert got is None
# --------------------------------------------------------------- the wav
def test_the_waveform_is_written_as_16_bit_pcm(tmp_path):
import wave
temps = []
a = np.sin(np.linspace(0, 20, 4410)).astype("float32")
path = V._write_wav(a, 44100, temps)
assert path and os.path.getsize(path) > 44
with wave.open(path, "rb") as wf:
assert wf.getframerate() == 44100
assert wf.getsampwidth() == 2
assert wf.getnchannels() == 1
assert wf.getnframes() == 4410
for t in temps:
try:
os.unlink(t)
except OSError:
pass
def test_a_stereo_waveform_keeps_both_channels():
import wave
temps = []
a = np.zeros((2, 1000), dtype="float32") # (channels, samples)
path = V._write_wav(a, 48000, temps)
with wave.open(path, "rb") as wf:
assert wf.getnchannels() == 2
assert wf.getnframes() == 1000
for t in temps:
try:
os.unlink(t)
except OSError:
pass
def test_a_waveform_louder_than_full_scale_is_normalised_not_clipped():
"""A vocoder is not bound to [-1, 1]; clipping it would be audible."""
import wave
temps = []
a = (np.ones(100, dtype="float32") * 4.0)
path = V._write_wav(a, 16000, temps)
with wave.open(path, "rb") as wf:
raw = np.frombuffer(wf.readframes(100), dtype="<i2")
assert raw.max() <= 32767
# Normalised to full scale rather than every sample pinned by clipping.
assert raw.max() >= 32000
for t in temps:
try:
os.unlink(t)
except OSError:
pass
# --------------------------------------------------------------- the mux
@needs_ffmpeg
def test_the_generated_audio_ends_up_on_the_video(tmp_path):
from codai.compose import media
silent = str(tmp_path / "silent.mp4")
subprocess.run([media.ffmpeg_bin(), "-hide_banner", "-v", "error", "-y",
"-f", "lavfi", "-i",
"testsrc=size=160x120:rate=10:duration=2",
"-pix_fmt", "yuv420p", silent],
check=True, timeout=180)
assert not V._has_audio(silent), "fixture already has audio"
temps = []
a = np.sin(np.linspace(0, 50, 44100 * 2)).astype("float32")
out = V._mux_generated_audio(silent, a, 44100, temps)
try:
assert out != silent, "nothing was muxed"
assert V._has_audio(out), "the soundtrack did not reach the file"
finally:
for t in temps:
try:
os.unlink(t)
except OSError:
pass
@needs_ffmpeg
def test_a_failed_mux_returns_the_video_unchanged(tmp_path):
"""A silent video beats no video: the frames are what was asked for."""
missing = str(tmp_path / "not-a-video.mp4")
temps = []
out = V._mux_generated_audio(missing, np.zeros(10, dtype="float32"),
44100, temps)
assert out == missing
for t in temps:
try:
os.unlink(t)
except OSError:
pass
# ------------------------------------------------------------- the switch
def test_add_audio_is_tri_state():
"""Unset must differ from false, or the default would silence LTX."""
from codai.pydantic.videorequest import VideoGenerationRequest
f = VideoGenerationRequest.model_fields["add_audio"]
assert f.default is None, (
"add_audio defaulting to False cannot be told from an explicit false, "
"so a generated soundtrack would be dropped by default")
def test_only_an_unset_add_audio_keeps_the_generated_track():
import inspect
src = inspect.getsource(V.video_generations)
assert "request.add_audio is None" in src, (
"the generated soundtrack is no longer gated on an UNSET add_audio")
def test_the_generated_track_is_muxed_before_the_other_steps():
"""Every later step re-encodes the frames, and the track belongs to
these exact ones."""
import inspect
src = inspect.getsource(V.video_generations)
# Compared on the CALLS, not on any mention: a comment naming the other
# step made this pass or fail on prose.
i_gen = src.index("None, _mux_generated_audio_bytes")
i_post = src.index("None, _postprocess_video")
assert i_gen < i_post, "the generated soundtrack is muxed after post-processing"
def test_the_capture_is_threaded_through_generation():
import inspect
assert "audio_out" in inspect.signature(V._run_pipeline).parameters
assert "audio_out" in inspect.signature(V._run_pipeline_split).parameters
src = inspect.getsource(V._generate_video)
assert "_gen_audio" in src, "generation no longer collects the soundtrack"
# ------------------------------------------- telling audio from "cannot tell"
@needs_ffmpeg
def test_audio_detection_does_not_depend_on_pyav(tmp_path, monkeypatch):
"""PyAV is in no main requirements file, only the LongCat venv's.
`_has_audio` tried `import av` under a bare `except: return False`, so
without PyAV EVERY file answered "no audio" -- and because it gates
`_mux_original_audio`, a clip's own sound was silently dropped from every
upscale and interpolate. The audio was there, nothing could see it, and
nothing said so. ffprobe cannot be missing the way PyAV can: ffmpeg is
what does the muxing.
"""
from codai.compose import media
loud = str(tmp_path / "loud.mp4")
silent = str(tmp_path / "silent.mp4")
subprocess.run([media.ffmpeg_bin(), "-hide_banner", "-v", "error", "-y",
"-f", "lavfi", "-i",
"testsrc=size=160x120:rate=10:duration=1",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-pix_fmt", "yuv420p", "-shortest", loud],
check=True, timeout=180)
subprocess.run([media.ffmpeg_bin(), "-hide_banner", "-v", "error", "-y",
"-f", "lavfi", "-i",
"testsrc=size=160x120:rate=10:duration=1",
"-pix_fmt", "yuv420p", silent],
check=True, timeout=180)
# Force the PyAV path to fail, exactly as it does on an install without it.
import builtins
real_import = builtins.__import__
def _no_av(name, *a, **kw):
if name == "av":
raise ImportError("No module named 'av'")
return real_import(name, *a, **kw)
monkeypatch.setattr(builtins, "__import__", _no_av)
assert V._has_audio(loud) is True, (
"a file WITH audio was reported silent without PyAV")
assert V._has_audio(silent) is False
def test_a_file_that_cannot_be_read_at_all_is_reported_not_swallowed(caplog):
""""Cannot tell" must not look identical to "no audio" in the log."""
import logging
with caplog.at_level(logging.WARNING):
assert V._has_audio("/nonexistent/path/to/nothing.mp4") is False
assert any("audio" in r.message.lower() for r in caplog.records), (
"a total failure to inspect the file was not logged")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment