Files
ausboss-ComfyUI-AusBoss/tests/test_video_load_helpers.py
ausboss 5b8cad57d7 Loaders: take files ComfyUI made, as core Load Image does
Image Crop + Rotate + Pad, Load Image + Pad and Load Video refused a
picture named the way ComfyUI names a generated file
("photo.png [output]", "[temp]") with "The selected source is outside
ComfyUI's input folder." Core Load Image takes those names.

resolve_input_path now reads the name the same way the video nodes'
Outputs list already does (resolve_server_file): the tag picks the
input, output or temp folder, and the rest is a name below it. A whole
path, "~", "." and "..", and a network path are refused from the text
alone; a link that leads out of the folder is refused once resolved.
Both functions share that code. A whole path inside the input folder,
accepted before, is now refused too, as the security rule asks.

Load Video's player and the shared upstream preview stripped the tag
and asked /view for the input folder; they now keep the folder.

Tests cover input, [input], [output] and [temp] names through the
resolver and through ComfyUI's own validation next to core Load Image,
and the refused escapes. Help pages, SECURITY.md and CHANGELOG say so.
2026-10-21 14:21:55 -05:00

578 lines
24 KiB
Python

from __future__ import annotations
import asyncio
import inspect
import math
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,
effective_load_args,
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.
ComfyUI goes into CPU mode first. The decode loop checks for a cancel
through comfy.model_management, which claims the GPU as it is imported;
these tests need no card, so CPU mode keeps them off it and lets them run
with the GPU hidden (CUDA_VISIBLE_DEVICES="").
"""
try:
import comfy.cli_args
comfy.cli_args.args.cpu = True
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
# The timeline's windowSeconds (js/shared/timeline_math.mjs), transcribed:
# IN is the largest hundredth that still keeps `first`, OUT the smallest that
# still keeps `last` and drops the frame after it.
def frame_window_seconds(fps: float, count: int, first: int, last: int) -> tuple[float, float]:
epsilon = 1e-4
start = 0.0 if first == 0 else max(0.0, round(math.floor((first / fps + epsilon) / 0.01 + 1e-9) * 0.01, 6))
end = 0.0 if last >= count - 1 else round(math.ceil((last / fps + epsilon) / 0.01 - 1e-9) * 0.01, 6)
return start, end
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_names_the_caller_and_only_its_inputs(self):
# Load Video's wording is unchanged; another caller names itself and
# suggests its own inputs instead of custom_width/custom_height.
self.assertEqual(
memory_budget_error(3000, 1920, 1080, 16_000_000_000),
"Load Video would need about 74.6 GB for 3000 frames at 1920x1080, "
"but only 16.0 GB of memory is available. Trim a shorter start/end "
"window or set custom_width/custom_height to shrink the frames.",
)
message = memory_budget_error(
3000, 1920, 1080, 16_000_000_000, source="Clip", advice="Raise every_nth."
)
self.assertEqual(
message,
"Clip would need about 74.6 GB for 3000 frames at 1920x1080, but only "
"16.0 GB of memory is available. Raise every_nth.",
)
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)
def test_trim_errors_name_the_calling_node(self):
with self.assertRaisesRegex(ValueError, "^Load Video needs start_seconds smaller"):
trim_window(10.0, 5.0, 5.0)
with self.assertRaisesRegex(ValueError, "^Clip needs start_seconds smaller"):
trim_window(10.0, 5.0, 5.0, "Clip")
with self.assertRaisesRegex(ValueError, r"^Clip starts at 3\.00s"):
trim_window(2.0, 3.0, 0.0, "Clip")
class TrimFrameWindowTests(unittest.TestCase):
"""The timeline stores hundredth-grid seconds for a frame window; the
decode must keep exactly those frames."""
@classmethod
def setUpClass(cls):
cls._tmp = tempfile.TemporaryDirectory()
cls.video = Path(cls._tmp.name) / "window.mp4"
write_test_video(cls.video, with_audio=False)
whole, fps = decode_video_range(cls.video, 0.0, 0.0, 0, 0)
cls.whole, cls.fps = whole, fps
@classmethod
def tearDownClass(cls):
cls._tmp.cleanup()
def test_grid_seconds_keep_exactly_the_chosen_frames(self):
count = int(self.whole.shape[0])
self.assertEqual(count, FRAMES)
for first, last in [(0, 0), (0, 5), (1, 1), (3, 10), (7, 22), (11, 23), (23, 23), (5, 23)]:
with self.subTest(first=first, last=last):
start, end = frame_window_seconds(self.fps, count, first, last)
batch, _ = decode_video_range(self.video, start, end, 0, 0)
self.assertEqual(int(batch.shape[0]), last - first + 1)
self.assertTrue(torch.equal(batch[0], self.whole[first]))
self.assertTrue(torch.equal(batch[-1], self.whole[last]))
class EffectiveLoadArgsTests(unittest.TestCase):
def test_trim_mode_passes_through_sanitized(self):
self.assertEqual(effective_load_args(False, 2.5, 2, 10), (2.5, 2, 10))
self.assertEqual(effective_load_args(False, 0.0, 0, -3), (0.0, 1, 0))
def test_single_frame_overrides_to_a_one_frame_open_window(self):
self.assertEqual(effective_load_args(True, 7.0, 4, 99), (0.0, 1, 1))
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)
# The loader reads a file by its name in ComfyUI's input folder; the
# fixture's folder stands in for it.
from types import SimpleNamespace
from nodes import _media_helpers
root = cls._tmp.name
stand_in = SimpleNamespace(
get_input_directory=lambda: root,
get_annotated_filepath=lambda name: os.path.join(root, name),
)
cls._folders = patch.object(_media_helpers, "folder_paths", stand_in)
cls._folders.start()
@classmethod
def tearDownClass(cls):
cls._folders.stop()
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("clip.mp4", 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_single_frame_returns_one_frame_at_the_in_time(self):
# end_seconds deliberately sits before start_seconds: a stale trim
# window left over from trim mode must not affect a single-frame load.
result = run_node(
node_load_video.AusBossLoadVideo().load_video(
"clip.mp4", 0.5, 0.2, 0, 0, single_frame=True
)
)
frames, _audio, frame_count, fps, _width, _height, duration, _core = result
self.assertEqual(frame_count, 1)
self.assertEqual(int(frames.shape[0]), 1)
# The brightness ramp puts frame t=0.5 (index 6) at 60/255.
self.assertAlmostEqual(float(frames[0].mean()), 60 / 255, delta=0.08)
self.assertAlmostEqual(fps, FPS, places=3)
self.assertAlmostEqual(duration, 1 / FPS, delta=0.02)
def test_validation_reads_only_the_source(self):
# ComfyUI files a failed check once per input VALIDATE_INPUTS names,
# so naming the trim bounds too turned one missing file into four
# errors. The window is checked when the loader runs, where a wired
# bound has its value and single_frame applies.
node = node_load_video.AusBossLoadVideo
self.assertEqual(list(inspect.signature(node.VALIDATE_INPUTS).parameters), ["video"])
with patch.object(node_load_video, "resolve_input_path", lambda _name: self.video):
self.assertIs(node.VALIDATE_INPUTS("clip.mp4"), True)
gone = ValueError("The selected source file no longer exists.")
with patch.object(node_load_video, "resolve_input_path", side_effect=gone):
self.assertEqual(node.VALIDATE_INPUTS("gone.mp4"), "Load Video: The selected source file no longer exists.")
def test_a_backwards_trim_window_stops_the_loader_when_it_runs(self):
with self.assertRaisesRegex(ValueError, "Load Video needs start_seconds smaller than end_seconds"):
run_node(node_load_video.AusBossLoadVideo().load_video("clip.mp4", 1.5, 0.5, 0, 0))
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(
"clip.mp4", 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()