Files
ausboss-ComfyUI-AusBoss/tests/test_video_load_helpers.py
T
ausboss c15c6c9317 Implement the pack audit batch: on-node pad editors, feature gaps, removals
The audit compared every node against ComfyUI core and the major packs;
this lands its outcomes in one branch.

On-node visual editing (the old Codex-pack interaction, rebuilt):
- New Load Image + Pad: image loader with whole-edge pad dragging on the
  node canvas, align-to-multiple rounding, and a megapixel target that
  rescales the source before padding so the mask seam stays binary.
- Pad Image gains the same handle canvas, fed by an execution-time
  snapshot of its input; wireframe with diagnostic empty states before
  the first run. Widgets, outputs, and saved workflows unchanged.
- Image Crop + Rotate + Pad's compact panel is now a live stage - crop,
  pad, and rotate handles on the node, one shared implementation with
  the full-screen editor so the two surfaces cannot drift.

Feature gaps closed against the ecosystem:
- Crop For Inpaint: target_megapixels, selectable rescale_algorithm
  (recorded in the stitcher for the paste-back), and extend_* inputs
  that grow the frame itself for outpainting.
- Save Video: format choice - mp4 h264, mp4 h265, webm vp9 with Opus
  (resampled to 48 kHz); CRF drives all three.
- Load Video: every_nth thinning (fps output divides to match) and a
  max_frames cap that ends the decode early.
- LoRA Loader: saved stack templates behind a bar button, and a console
  warning when a LoRA's base-model family disagrees with the checkpoint.
- LM Studio Chat: history in/out for multi-turn chains and a json_schema
  widget for structured replies.
- Drop Shadow: multiply blend and a shadow_mask output. Align Image:
  offset outputs for un-aligning after sampling. Color Match:
  first_frame reference mode as a one-node video flicker fix.

Removed (backed up in git history): Select Frame Range and the Video
Bundle family - core ImageFromBatch and the core VIDEO wire now cover
both jobs. Dead NODE_ID module variables cleaned out of every node file.

455 Python tests, 216 JS tests, validate_nodes.py green on 19 node files.
2026-08-17 10:49:55 -05:00

447 lines
17 KiB
Python

from __future__ import annotations
import asyncio
import inspect
import os
import tempfile
import unittest
from fractions import Fraction
from pathlib import Path
from unittest.mock import patch
import sys
import numpy as np
import torch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
import av
from nodes._video_load_helpers import (
LazyAudio,
core_trim_args,
core_trimmed_video,
decode_audio_range,
decode_video_range,
lazy_audio_range,
memory_budget_error,
output_size,
trim_window,
)
from nodes import _video_load_helpers, node_load_video
def core_video_api_available() -> bool:
"""True when ComfyUI's comfy_api video types import in this interpreter."""
try:
import comfy_api.input_impl # noqa: F401
except Exception:
return False
return True
def ensure_core_video_api(case: unittest.TestCase | None = None) -> bool:
"""Make comfy_api importable when AUSBOSS_COMFY_ROOT points at a ComfyUI
checkout; appended (never prepended) so ComfyUI's top-level nodes.py can
never shadow this pack's nodes package.
``case`` registers the cleanup that takes the entry back out again. Left
on sys.path it changes the answer to "is ComfyUI importable?" for every
test that runs afterwards, which silently flips each `except ImportError`
fail-soft seam in the pack onto its other branch - the branch the offline
assertions are not written for.
"""
if core_video_api_available():
return True
root = os.environ.get("AUSBOSS_COMFY_ROOT", "")
if root and (Path(root) / "comfy_api").is_dir() and root not in sys.path:
sys.path.append(root)
if case is not None:
case.addClassCleanup(_drop_from_sys_path, root)
return core_video_api_available()
def _drop_from_sys_path(entry: str) -> None:
while entry in sys.path:
sys.path.remove(entry)
def run_node(result):
"""Run a node FUNCTION result that may be sync or a coroutine."""
return asyncio.run(result) if asyncio.iscoroutine(result) else result
class FakeInterrupt(BaseException):
"""Mirrors ComfyUI's InterruptProcessingException: not an Exception."""
FPS = 12
FRAMES = 24
WIDTH, HEIGHT = 64, 48
AUDIO_RATE = 32000
def write_test_video(path: Path, with_audio: bool) -> None:
with av.open(str(path), "w") as container:
video = container.add_stream("mpeg4", rate=FPS)
video.width, video.height = WIDTH, HEIGHT
video.pix_fmt = "yuv420p"
audio = container.add_stream("aac", rate=AUDIO_RATE) if with_audio else None
for index in range(FRAMES):
level = min(255, index * 10)
array = np.full((HEIGHT, WIDTH, 3), level, dtype=np.uint8)
frame = av.VideoFrame.from_ndarray(array, format="rgb24")
for packet in video.encode(frame):
container.mux(packet)
for packet in video.encode():
container.mux(packet)
if audio is not None:
samples = AUDIO_RATE * FRAMES // FPS
tone = (0.2 * np.sin(np.linspace(0, 880 * np.pi, samples))).astype(np.float32)
audio_frame = av.AudioFrame.from_ndarray(tone.reshape(1, -1), format="flt", layout="mono")
audio_frame.sample_rate = AUDIO_RATE
audio_frame.time_base = Fraction(1, AUDIO_RATE)
audio_frame.pts = 0
for packet in audio.encode(audio_frame):
container.mux(packet)
for packet in audio.encode():
container.mux(packet)
class VideoLoadHelperTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory()
cls.video = Path(cls._tmp.name) / "clip.mp4"
cls.silent = Path(cls._tmp.name) / "silent.mp4"
write_test_video(cls.video, with_audio=True)
write_test_video(cls.silent, with_audio=False)
@classmethod
def tearDownClass(cls):
cls._tmp.cleanup()
def test_full_decode_shape_fps_and_brightness_ramp(self):
frames, fps = decode_video_range(self.video, 0.0, 0.0, 0, 0)
self.assertEqual(tuple(frames.shape), (FRAMES, HEIGHT, WIDTH, 3))
self.assertAlmostEqual(fps, FPS, places=3)
self.assertLess(float(frames[0].mean()), float(frames[-1].mean()))
def test_trim_window_selects_the_middle_second(self):
frames, fps = decode_video_range(self.video, 0.5, 1.5, 0, 0)
self.assertEqual(frames.shape[0], FPS)
# Frame at t=0.5 is index 6 (brightness 60/255); allow codec loss.
self.assertAlmostEqual(float(frames[0].mean()), 60 / 255, delta=0.08)
def test_every_nth_thins_the_batch(self):
frames, fps = decode_video_range(self.video, 0.0, 0.0, 0, 0, every_nth=2)
self.assertEqual(frames.shape[0], FRAMES // 2)
# The helper reports the source rate; the node divides it.
self.assertAlmostEqual(fps, FPS, places=3)
# Kept frames are the even source indices: brightness steps by 20/255.
self.assertAlmostEqual(
float(frames[1].mean()) - float(frames[0].mean()), 20 / 255, delta=0.05
)
def test_max_frames_caps_the_decode(self):
frames, _fps = decode_video_range(self.video, 0.0, 0.0, 0, 0, max_frames=5)
self.assertEqual(frames.shape[0], 5)
def test_every_nth_and_cap_compose(self):
frames, _fps = decode_video_range(
self.video, 0.0, 0.0, 0, 0, every_nth=3, max_frames=4
)
self.assertEqual(frames.shape[0], 4)
def test_single_custom_dimension_preserves_aspect(self):
frames, _fps = decode_video_range(self.video, 0.0, 0.0, 32, 0)
self.assertEqual((frames.shape[2], frames.shape[1]), (32, 24))
def test_audio_window_matches_duration(self):
result = decode_audio_range(self.video, 0.5, 1.5)
waveform, rate = result["waveform"], result["sample_rate"]
self.assertEqual(rate, AUDIO_RATE)
self.assertEqual(waveform.ndim, 3)
self.assertAlmostEqual(waveform.shape[-1] / rate, 1.0, delta=0.05)
self.assertGreater(float(waveform.abs().max()), 0.0)
def test_silent_fallback_for_video_without_audio_track(self):
result = decode_audio_range(self.silent, 0.0, 2.0)
self.assertEqual(float(result["waveform"].abs().max()), 0.0)
self.assertAlmostEqual(
result["waveform"].shape[-1] / result["sample_rate"], 2.0, delta=0.01
)
def test_output_size_rules(self):
self.assertEqual(output_size(640, 480, 0, 0), (640, 480))
self.assertEqual(output_size(640, 480, 320, 0), (320, 240))
self.assertEqual(output_size(640, 480, 0, 240), (320, 240))
self.assertEqual(output_size(640, 480, 100, 100), (100, 100))
def test_lazy_audio_defers_the_loader_until_first_key_read(self):
calls = []
def loader():
calls.append(1)
return {"waveform": torch.zeros((1, 1, 4)), "sample_rate": 22050}
audio = LazyAudio(loader)
self.assertEqual(calls, []) # construction must not decode
self.assertEqual(audio["sample_rate"], 22050)
self.assertEqual(len(calls), 1)
self.assertEqual(audio.get("sample_rate"), 22050)
self.assertEqual(set(audio), {"waveform", "sample_rate"})
self.assertEqual(len(audio), 2)
self.assertEqual(len(calls), 1) # cached: still a single decode
def test_lazy_audio_range_matches_the_eager_decode(self):
lazy = lazy_audio_range(self.video, 0.5, 1.5)
eager = decode_audio_range(self.video, 0.5, 1.5)
self.assertEqual(lazy["sample_rate"], eager["sample_rate"])
self.assertTrue(torch.equal(lazy["waveform"], eager["waveform"]))
def test_memory_budget_allows_batches_inside_the_budget(self):
# 24 frames at 64x48 = 24 * 48 * 64 * 12 bytes, far under 1 GB.
self.assertIsNone(memory_budget_error(24, 64, 48, 1_000_000_000))
def test_memory_budget_blocks_oversized_batches_with_a_clear_error(self):
# 3000 frames at 1920x1080 needs ~74.6 GB float32.
message = memory_budget_error(3000, 1920, 1080, 16_000_000_000)
self.assertIsNotNone(message)
self.assertIn("3000", message)
self.assertIn("74.6 GB", message)
self.assertIn("16.0 GB", message)
self.assertIn("custom_width", message)
self.assertIn("shorter", message)
def test_memory_budget_applies_the_safety_factor(self):
needed = 10 * 8 * 8 * 3 * 4
self.assertIsNone(memory_budget_error(10, 8, 8, needed * 2))
self.assertIsNotNone(memory_budget_error(10, 8, 8, needed)) # 0.8 * needed < needed
def test_memory_budget_fails_soft_without_availability_data(self):
self.assertIsNone(memory_budget_error(10**9, 4096, 4096, None))
self.assertIsNone(memory_budget_error(10**9, 4096, 4096, 0))
self.assertIsNone(memory_budget_error(0, 4096, 4096, 1))
self.assertIsNone(memory_budget_error(10, 0, 0, 1))
def test_trim_validation_errors(self):
with self.assertRaisesRegex(ValueError, "smaller than"):
trim_window(10.0, 5.0, 5.0)
with self.assertRaisesRegex(ValueError, "only"):
trim_window(2.0, 3.0, 0.0)
class CoreTrimArgsTests(unittest.TestCase):
def test_zeroes_pass_through_as_no_trim(self):
self.assertEqual(core_trim_args(0.0, 0.0), (0.0, 0.0))
def test_start_and_end_become_start_and_duration(self):
self.assertEqual(core_trim_args(0.5, 1.5), (0.5, 1.0))
def test_end_zero_means_until_the_end(self):
self.assertEqual(core_trim_args(2.0, 0.0), (2.0, 0.0))
def test_negative_start_clamps_to_zero(self):
self.assertEqual(core_trim_args(-1.0, 3.0), (0.0, 3.0))
def test_returns_none_when_the_core_api_is_unavailable(self):
# Forced, not inferred from the interpreter: reading the real import
# state made this assertion depend on whether an earlier class had
# already put ComfyUI on sys.path, so it self-skipped under any
# runner that reordered the classes and covered nothing.
with patch.dict(sys.modules, {"comfy_api.input_impl": None}):
self.assertIsNone(core_trimmed_video(Path("/tmp/never.mp4"), 0.0, 0.0))
class CoreVideoAdapterIntegrationTests(unittest.TestCase):
"""Constructs the core VIDEO adapter against a tiny generated clip.
Runs only when comfy_api is importable — either natively or via
AUSBOSS_COMFY_ROOT pointing at a ComfyUI checkout."""
@classmethod
def setUpClass(cls):
if not ensure_core_video_api(cls):
raise unittest.SkipTest(
"comfy_api is not importable; set AUSBOSS_COMFY_ROOT to a ComfyUI checkout"
)
cls._tmp = tempfile.TemporaryDirectory()
cls.video = Path(cls._tmp.name) / "clip.mp4"
write_test_video(cls.video, with_audio=True)
@classmethod
def tearDownClass(cls):
cls._tmp.cleanup()
def test_trimmed_adapter_reads_metadata_without_decoding_frames(self):
video = core_trimmed_video(self.video, 0.5, 1.5)
self.assertIsNotNone(video)
# get_components materializes every frame; blocking it proves the
# metadata reads stay decode-free.
with patch.object(
type(video), "get_components", side_effect=AssertionError("decoded all frames")
):
self.assertEqual(video.get_dimensions(), (WIDTH, HEIGHT))
self.assertAlmostEqual(video.get_duration(), 1.0, delta=0.15)
self.assertAlmostEqual(float(video.get_frame_rate()), FPS, delta=0.01)
def test_trimmed_adapter_components_cover_the_requested_window(self):
video = core_trimmed_video(self.video, 0.5, 1.5)
components = video.get_components()
self.assertAlmostEqual(int(components.images.shape[0]), FPS, delta=2)
self.assertEqual(
(int(components.images.shape[2]), int(components.images.shape[1])),
(WIDTH, HEIGHT),
)
self.assertAlmostEqual(float(components.frame_rate), FPS, delta=0.01)
def test_untrimmed_adapter_spans_the_whole_clip(self):
video = core_trimmed_video(self.video, 0.0, 0.0)
self.assertAlmostEqual(video.get_duration(), FRAMES / FPS, delta=0.15)
class LoadVideoNodeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory()
cls.video = Path(cls._tmp.name) / "clip.mp4"
write_test_video(cls.video, with_audio=True)
@classmethod
def tearDownClass(cls):
cls._tmp.cleanup()
def test_video_output_is_appended_after_the_original_seven(self):
node = node_load_video.AusBossLoadVideo
self.assertEqual(
node.RETURN_TYPES,
("IMAGE", "AUDIO", "INT", "FLOAT", "INT", "INT", "FLOAT", "VIDEO"),
)
self.assertEqual(
node.RETURN_NAMES,
("frames", "audio", "frame_count", "fps", "width", "height", "duration", "video"),
)
self.assertEqual(len(node.OUTPUT_TOOLTIPS), len(node.RETURN_TYPES))
def test_node_returns_the_trimmed_window_plus_a_core_video(self):
result = run_node(
node_load_video.AusBossLoadVideo().load_video(str(self.video), 0.5, 1.5, 0, 0)
)
self.assertEqual(len(result), 8)
frames, _audio, frame_count, fps, width, height, duration, core_video = result
self.assertEqual(frame_count, int(frames.shape[0]))
self.assertEqual((width, height), (WIDTH, HEIGHT))
self.assertAlmostEqual(fps, FPS, places=3)
self.assertAlmostEqual(duration, 1.0, delta=0.2)
if core_video_api_available():
self.assertAlmostEqual(core_video.get_duration(), 1.0, delta=0.15)
else:
self.assertIsNone(core_video)
def test_the_node_function_is_a_coroutine(self):
node = node_load_video.AusBossLoadVideo
self.assertTrue(inspect.iscoroutinefunction(getattr(node, node.FUNCTION)))
def test_the_event_loop_keeps_running_while_the_decode_blocks(self):
ticks = 0
async def drive():
nonlocal ticks
async def heartbeat():
nonlocal ticks
while True:
ticks += 1
await asyncio.sleep(0)
beat = asyncio.ensure_future(heartbeat())
try:
return await node_load_video.AusBossLoadVideo().load_video(
str(self.video), 0.0, 0.0, 0, 0
)
finally:
beat.cancel()
with patch.object(node_load_video, "resolve_input_path", lambda _name: self.video):
result = asyncio.run(drive())
self.assertEqual(int(result[2]), FRAMES)
# A blocking decode inside the coroutine would have starved the
# heartbeat entirely; off the loop it keeps being scheduled.
self.assertGreater(ticks, 10)
class RecordingBar:
def __init__(self):
self.updates = []
def update_absolute(self, value, total=None, preview=None):
self.updates.append((value, total, preview))
class DecodeLoopTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory()
cls.video = Path(cls._tmp.name) / "clip.mp4"
write_test_video(cls.video, with_audio=False)
@classmethod
def tearDownClass(cls):
cls._tmp.cleanup()
def test_every_decoded_frame_advances_the_progress_bar(self):
bar = RecordingBar()
with patch.object(_video_load_helpers, "frame_progress", lambda _total: bar):
frames, _fps = decode_video_range(self.video, 0.0, 0.0, 0, 0)
count = int(frames.shape[0])
self.assertEqual(count, FRAMES)
self.assertEqual([update[0] for update in bar.updates], list(range(1, count + 1)))
self.assertTrue(all(update[1] >= count for update in bar.updates))
# The in-node player already previews; progress carries no images.
self.assertTrue(all(update[2] is None for update in bar.updates))
def test_an_unknown_frame_total_skips_tracking_without_a_bar(self):
totals = []
with patch.object(
_video_load_helpers, "frame_progress", lambda total: totals.append(total) or None
):
frames, _fps = decode_video_range(self.video, 0.0, 0.0, 0, 0)
self.assertEqual(int(frames.shape[0]), FRAMES)
self.assertEqual(len(totals), 1)
def test_a_broken_progress_bar_never_breaks_the_decode(self):
class BrokenBar:
def update_absolute(self, *_args, **_kwargs):
raise RuntimeError("the websocket went away")
with patch.object(_video_load_helpers, "frame_progress", lambda _total: BrokenBar()):
frames, _fps = decode_video_range(self.video, 0.0, 0.0, 0, 0)
self.assertEqual(int(frames.shape[0]), FRAMES)
def test_the_decode_loop_aborts_promptly_on_an_interrupt(self):
calls = []
def interrupt_on_the_third_frame():
calls.append(1)
if len(calls) > 2:
raise FakeInterrupt()
with patch.object(
_video_load_helpers, "raise_if_interrupted", interrupt_on_the_third_frame
):
with self.assertRaises(FakeInterrupt):
decode_video_range(self.video, 0.0, 0.0, 0, 0)
# Stopped on the third frame instead of decoding all 24.
self.assertEqual(len(calls), 3)
if __name__ == "__main__":
unittest.main()