Files
Chris 768e9ce011 feat: force-rate frame resampling core (resample_frame_indices)
Adds a comfy-free helper that retimes a source frame sequence to a
target fps while preserving real duration (VideoHelperSuite-style
force_rate) — 0 means off, dropping/duplicating frames otherwise.
Needed by the upcoming Multi Video Loader nodes.
2026-08-14 18:59:14 +02:00

186 lines
5.6 KiB
Python

# Multi Loader core tests — part of ComfyUI-AI2Go-Utils. GPL-3.0.
import json
import pytest
from nodes.multi_loader_core import MAX_FILES, parse_files
def test_parse_valid_list():
raw = json.dumps([
{"name": "fox.png", "subfolder": "", "type": "input"},
{"name": "city.jpg"}, # subfolder/type optional
])
files = parse_files(raw)
assert files == [
{"name": "fox.png", "subfolder": "", "type": "input", "enabled": True},
{"name": "city.jpg", "subfolder": "", "type": "input", "enabled": True},
]
def test_parse_enabled_defaults_true():
files = parse_files(json.dumps([{"name": "a.png"}]))
assert files[0]["enabled"] is True
def test_parse_enabled_false_preserved():
files = parse_files(json.dumps([{"name": "a.png", "enabled": False}]))
assert files[0]["enabled"] is False
def test_parse_enabled_non_bool_raises():
with pytest.raises(ValueError, match="File #1"):
parse_files(json.dumps([{"name": "a.png", "enabled": "true"}]))
def test_parse_keeps_subfolder():
files = parse_files(json.dumps([{"name": "a.png", "subfolder": "refs"}]))
assert files[0]["subfolder"] == "refs"
@pytest.mark.parametrize("raw", ["", " ", "[]"])
def test_parse_empty_raises(raw):
with pytest.raises(ValueError, match="No files loaded"):
parse_files(raw)
def test_parse_malformed_json_raises():
with pytest.raises(ValueError, match="Malformed files_json"):
parse_files("[{oops")
def test_parse_non_list_raises():
with pytest.raises(ValueError, match="JSON array"):
parse_files('{"name": "a.png"}')
@pytest.mark.parametrize("entry", [42, "a.png", {}, {"name": ""}, {"name": 3}])
def test_parse_bad_entry_raises(entry):
with pytest.raises(ValueError, match="File #1"):
parse_files(json.dumps([entry]))
def test_parse_over_ceiling_raises():
raw = json.dumps([{"name": f"f{i}.png"} for i in range(MAX_FILES + 1)])
with pytest.raises(ValueError, match="at most 8"):
parse_files(raw)
from nodes.multi_loader_core import downscale_size
def test_downscale_off_untouched():
assert downscale_size(2400, 1200, "off", 1200) == ((2400, 1200), None)
def test_downscale_below_threshold_untouched():
for mode in ("keep aspect ratio", "crop to square", "stretch to square"):
assert downscale_size(800, 600, mode, 1200) == ((800, 600), None)
def test_downscale_exactly_max_untouched():
assert downscale_size(1200, 1200, "keep aspect ratio", 1200) == ((1200, 1200), None)
def test_fit_caps_longest_edge():
assert downscale_size(2400, 1200, "keep aspect ratio", 1200) == ((1200, 600), None)
assert downscale_size(1200, 2400, "keep aspect ratio", 1200) == ((600, 1200), None)
def test_fit_never_returns_zero():
(w, h), box = downscale_size(5000, 2, "keep aspect ratio", 1200)
assert (w, h) == (1200, 1) and box is None
def test_crop_centers_square_then_caps():
# shorter edge 1200 -> centered 1200x1200 box, already <= max
assert downscale_size(2400, 1200, "crop to square", 1200) == ((1200, 1200), (600, 0, 1800, 1200))
# shorter edge 800 < max -> square of 800, no upscale
assert downscale_size(2400, 800, "crop to square", 1200) == ((800, 800), (800, 0, 1600, 800))
# shorter edge 2000 > max -> crop 2000-box, downscale to 1200
assert downscale_size(3000, 2000, "crop to square", 1200) == ((1200, 1200), (500, 0, 2500, 2000))
def test_stretch_forces_square():
assert downscale_size(2400, 1200, "stretch to square", 1200) == ((1200, 1200), None)
def test_unknown_mode_raises():
import pytest
with pytest.raises(ValueError, match="downscale_mode"):
downscale_size(100, 100, "zoom", 1200)
from nodes.multi_loader_core import remove_file
def _files(*names):
return [{"name": n, "subfolder": "", "type": "input"} for n in names]
def test_remove_middle_shifts_up():
new, moved = remove_file(_files("a.png", "b.png", "c.png"), 1)
assert [f["name"] for f in new] == ["a.png", "c.png"]
assert moved == ["c.png"]
def test_remove_last_moves_nothing():
new, moved = remove_file(_files("a.png", "b.png"), 1)
assert [f["name"] for f in new] == ["a.png"]
assert moved == []
def test_remove_does_not_mutate_input():
original = _files("a.png", "b.png")
remove_file(original, 0)
assert len(original) == 2
def test_remove_bad_index_raises():
import pytest
with pytest.raises(IndexError):
remove_file(_files("a.png"), 5)
from nodes.multi_loader_core import resample_frame_indices
def test_resample_upsample_24_to_30_preserves_duration():
# 240 frames @ 24fps = 10s; retimed to 30fps -> 300 frames, still ending at the last
# source frame (duration preserved, not frame count).
idx = resample_frame_indices(240, 24, 30)
assert len(idx) == 300
assert idx[-1] == 239
def test_resample_downsample_60_to_30_steps_by_two():
idx = resample_frame_indices(600, 60, 30)
assert len(idx) == 300
assert idx == [i * 2 for i in range(300)]
def test_resample_upsample_24_to_48_duplicates_each_frame():
idx = resample_frame_indices(240, 24, 48)
assert len(idx) == 480
for i in range(240):
assert idx[2 * i] == i
assert idx[2 * i + 1] == i
def test_resample_identity_when_rates_equal():
idx = resample_frame_indices(240, 24, 24)
assert idx == list(range(240))
def test_resample_target_zero_means_no_resampling():
assert resample_frame_indices(240, 24, 0) is None
def test_resample_src_count_zero_yields_empty():
assert resample_frame_indices(0, 24, 30) == []
def test_resample_src_fps_zero_raises():
with pytest.raises(ValueError):
resample_frame_indices(240, 0, 30)