* feat(forks): upgrade Koolook Director to stable v1.3.9 * docs(forks): clarify Director upgrade compatibility
476 lines
16 KiB
Python
476 lines
16 KiB
Python
"""Pure-function coverage for scripts/loop_audio.py.
|
|
|
|
Anchored by the PR #185 multi-agent review which surfaced three
|
|
silent-failure HIGHs on the audio-source-derivation path:
|
|
|
|
* derive_audio_state collapsed missing-Director and unwired-VAE
|
|
into the same label;
|
|
* extract_multilines used dict-insertion order, so a shorter needle
|
|
declared first could silently shadow a longer match;
|
|
* parse_timeline returned segments verbatim, so a string-valued
|
|
`start` field would crash the renderer at `start / fps` or quietly
|
|
break `<` comparisons in video_segment_has_audio.
|
|
|
|
These tests pin each fix plus the surrounding helpers so future edits
|
|
can't silently regress.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Load the script directly — it lives under scripts/, not under a package.
|
|
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "loop_audio.py"
|
|
_spec = importlib.util.spec_from_file_location("loop_audio", _SCRIPT)
|
|
assert _spec is not None and _spec.loader is not None
|
|
loop_audio = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(loop_audio)
|
|
|
|
|
|
# --- helpers used by multiple tests -----------------------------------------
|
|
|
|
|
|
def _director(
|
|
*,
|
|
node_type: str = "LTXDirector__koolook",
|
|
use_custom_audio: bool = False,
|
|
audio_vae_link: int | None = None,
|
|
timeline: dict | None = None,
|
|
epsilon: float = 0.001,
|
|
fps: int = 24,
|
|
) -> dict:
|
|
"""Build a minimal Koolook Director node dict matching the saved
|
|
widget order (DIRECTOR_WIDX). Only fields the helpers under test
|
|
actually read are populated; the rest are placeholders."""
|
|
import json as _json
|
|
timeline_str = _json.dumps(timeline) if timeline is not None else ""
|
|
wv = [
|
|
"", # 0 global_prompt
|
|
120, # 1 duration_frames
|
|
5, # 2 duration_seconds
|
|
timeline_str, # 3 timeline_data
|
|
"", # 4 local_prompts
|
|
"120", # 5 segment_lengths
|
|
epsilon, # 6 epsilon
|
|
"1.00", # 7 guide_strength
|
|
use_custom_audio, # 8 use_custom_audio
|
|
fps, # 9 frame_rate
|
|
"seconds", # 10 display_mode
|
|
0, # 11 custom_width
|
|
0, # 12 custom_height
|
|
"maintain aspect ratio", # 13 resize_method
|
|
32, # 14 divisible_by
|
|
18, # 15 img_compression
|
|
"", # 16 relay_overrides
|
|
]
|
|
return {
|
|
"type": node_type,
|
|
"widgets_values": wv,
|
|
"inputs": [
|
|
{"name": "audio_vae", "link": audio_vae_link},
|
|
{"name": "use_custom_audio", "link": None},
|
|
],
|
|
}
|
|
|
|
|
|
# --- extract_director — stable ID plus legacy alias -------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"node_type",
|
|
["LTXDirector__koolook", "LTXDirector__koolook_v1_3_2"],
|
|
)
|
|
def test_extract_director_accepts_stable_and_legacy_ids(node_type):
|
|
node = _director(node_type=node_type)
|
|
assert loop_audio.extract_director([{"type": "LTXDirector"}, node]) is node
|
|
|
|
|
|
# --- derive_audio_state — 5 distinct states --------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"director_node, timeline, expected",
|
|
[
|
|
# (no director) — split off so a missing-Director workflow can't
|
|
# silently look identical to a director-present-but-VAE-unwired
|
|
# one (PR #185 review HIGH-1).
|
|
(None, {"segments": [], "audioSegments": []}, "(no director)"),
|
|
# off (no VAE) — Director present, audio_vae socket unwired.
|
|
(
|
|
_director(audio_vae_link=None),
|
|
{"segments": [], "audioSegments": []},
|
|
"off (no VAE)",
|
|
),
|
|
# model-gen — VAE wired, use_custom_audio=False. audioSegments
|
|
# are ignored on this path.
|
|
(
|
|
_director(audio_vae_link=42, use_custom_audio=False),
|
|
{"segments": [], "audioSegments": []},
|
|
"model-gen",
|
|
),
|
|
# custom — VAE wired, use_custom_audio=True, audioSegments
|
|
# non-empty.
|
|
(
|
|
_director(audio_vae_link=42, use_custom_audio=True),
|
|
{"segments": [], "audioSegments": [{"start": 0, "length": 100}]},
|
|
"custom",
|
|
),
|
|
# custom (empty) — VAE wired, use_custom_audio=True, but no
|
|
# audioSegments uploaded.
|
|
(
|
|
_director(audio_vae_link=42, use_custom_audio=True),
|
|
{"segments": [], "audioSegments": []},
|
|
"custom (empty)",
|
|
),
|
|
],
|
|
)
|
|
def test_derive_audio_state_all_five_states(director_node, timeline, expected):
|
|
assert loop_audio.derive_audio_state(director_node, timeline) == expected
|
|
|
|
|
|
# --- extract_multilines — longest-needle-wins guards future drift ----------
|
|
|
|
|
|
def _multiline(title: str, body: str) -> dict:
|
|
return {
|
|
"type": "Text Multiline",
|
|
"title": title,
|
|
"widgets_values": [body],
|
|
}
|
|
|
|
|
|
def test_extract_multilines_keeps_all_hits_for_one_needle():
|
|
"""Working_Folder_PATH legitimately appears twice on the canvas
|
|
(project mount + local mirror). Both bodies must come back so the
|
|
caller can pick by reachability."""
|
|
nodes = [
|
|
_multiline("Working_Folder_PATH", "W:/projects/foo"),
|
|
_multiline("Working_Folder_PATH", "e:/local/foo"),
|
|
]
|
|
out = loop_audio.extract_multilines(nodes, ["working_folder"])
|
|
assert out["working_folder"] == ["W:/projects/foo", "e:/local/foo"]
|
|
|
|
|
|
def test_extract_multilines_longest_needle_wins():
|
|
"""If a future config adds a short substring that's contained in
|
|
an existing longer one, the longer match should win — regardless
|
|
of declaration order. The pre-fix loop iterated in dict-insertion
|
|
order and would have routed both nodes to the shorter needle."""
|
|
nodes = [
|
|
_multiline("RELAY_OVERRIDES", "{video_strength: 10}"),
|
|
]
|
|
# Shorter "relay" declared FIRST in the config — pre-fix this would
|
|
# have shadowed "relay_overrides". Longest-first ordering routes
|
|
# the node to the more specific needle.
|
|
out = loop_audio.extract_multilines(
|
|
nodes, ["relay", "relay_overrides"]
|
|
)
|
|
assert out["relay_overrides"] == ["{video_strength: 10}"]
|
|
assert out["relay"] == []
|
|
|
|
|
|
def test_extract_multilines_first_match_wins_per_node():
|
|
"""A node's title can match multiple needles. The loop should
|
|
record at most one match per node (the longest) to avoid
|
|
double-counting."""
|
|
nodes = [_multiline("NAME overlay - info combined", "one node")]
|
|
out = loop_audio.extract_multilines(
|
|
nodes, ["name", "overlay - info"]
|
|
)
|
|
# Both needles match the title, but the longer one wins and the
|
|
# per-node break keeps the same node from being counted twice.
|
|
assert out["name"] == []
|
|
assert out["overlay - info"] == ["one node"]
|
|
|
|
|
|
def test_extract_multilines_ignores_non_text_multiline_nodes():
|
|
nodes = [
|
|
{"type": "LTXDirector__koolook_v1_3_2", "title": "NAME"},
|
|
_multiline("NAME", "real"),
|
|
]
|
|
out = loop_audio.extract_multilines(nodes, ["name"])
|
|
assert out["name"] == ["real"]
|
|
|
|
|
|
# --- parse_timeline — coerce numeric segment fields ------------------------
|
|
|
|
|
|
def test_parse_timeline_coerces_string_numerics():
|
|
"""The Comfy frontend sometimes saves numeric segment fields as
|
|
strings. parse_timeline must coerce them so downstream arithmetic
|
|
in the renderer (`start / fps`, `<` comparisons) stays type-safe
|
|
instead of crashing or doing lexical comparisons."""
|
|
node = _director(timeline={
|
|
"segments": [
|
|
{"id": "a", "start": "0", "length": "120", "prompt": "x"},
|
|
],
|
|
"audioSegments": [],
|
|
})
|
|
tl = loop_audio.parse_timeline(node)
|
|
seg = tl["segments"][0]
|
|
assert seg["start"] == 0
|
|
assert seg["length"] == 120
|
|
assert isinstance(seg["start"], int)
|
|
assert isinstance(seg["length"], int)
|
|
# Non-numeric fields pass through.
|
|
assert seg["prompt"] == "x"
|
|
|
|
|
|
def test_parse_timeline_coerces_float_to_int():
|
|
node = _director(timeline={
|
|
"segments": [{"start": 1.7, "length": 12.4}],
|
|
"audioSegments": [],
|
|
})
|
|
tl = loop_audio.parse_timeline(node)
|
|
# int(float(...)) truncates toward 0, matching the upstream
|
|
# Director's _build_combined_audio coercion behavior.
|
|
assert tl["segments"][0]["start"] == 1
|
|
assert tl["segments"][0]["length"] == 12
|
|
|
|
|
|
def test_parse_timeline_collapses_bad_values_to_zero():
|
|
"""A malformed segment shouldn't crash the whole loop — the
|
|
offending field collapses to 0 and we keep going."""
|
|
node = _director(timeline={
|
|
"segments": [{"start": "not a number", "length": 100}],
|
|
"audioSegments": [],
|
|
})
|
|
tl = loop_audio.parse_timeline(node)
|
|
assert tl["segments"][0]["start"] == 0
|
|
assert tl["segments"][0]["length"] == 100
|
|
|
|
|
|
def test_parse_timeline_handles_malformed_json():
|
|
"""Invalid JSON in timeline_data must not raise — the helper
|
|
returns empty lists so the renderer falls back to "(N=0)" and
|
|
proceeds."""
|
|
bad = _director()
|
|
bad["widgets_values"][3] = "{not json"
|
|
assert loop_audio.parse_timeline(bad) == {
|
|
"segments": [], "audioSegments": [],
|
|
}
|
|
|
|
|
|
def test_parse_timeline_handles_missing_director():
|
|
assert loop_audio.parse_timeline(None) == {
|
|
"segments": [], "audioSegments": [],
|
|
}
|
|
|
|
|
|
def test_parse_timeline_drops_non_dict_segments():
|
|
node = _director(timeline={
|
|
"segments": [
|
|
{"start": 0, "length": 60}, # kept
|
|
"not a dict", # dropped
|
|
None, # dropped
|
|
],
|
|
"audioSegments": [],
|
|
})
|
|
tl = loop_audio.parse_timeline(node)
|
|
assert len(tl["segments"]) == 1
|
|
|
|
|
|
# --- video_segment_has_audio — overlap boundaries --------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"video, audio_segs, expected",
|
|
[
|
|
# Same range — overlaps.
|
|
({"start": 0, "length": 100}, [{"start": 0, "length": 100}], True),
|
|
# Audio starts inside video — overlaps.
|
|
({"start": 0, "length": 100}, [{"start": 50, "length": 50}], True),
|
|
# Audio ends inside video — overlaps.
|
|
({"start": 50, "length": 100}, [{"start": 0, "length": 75}], True),
|
|
# Audio strictly before video — no overlap.
|
|
({"start": 100, "length": 50}, [{"start": 0, "length": 100}], False),
|
|
# Audio strictly after video — no overlap.
|
|
({"start": 0, "length": 50}, [{"start": 100, "length": 50}], False),
|
|
# Audio exactly touches video end — half-open intervals
|
|
# (a_start < v_end) treats this as NOT overlapping.
|
|
({"start": 0, "length": 50}, [{"start": 50, "length": 50}], False),
|
|
# Multiple audio segs — any one overlap is enough.
|
|
(
|
|
{"start": 0, "length": 50},
|
|
[{"start": 100, "length": 10}, {"start": 25, "length": 10}],
|
|
True,
|
|
),
|
|
# No audio segs — false.
|
|
({"start": 0, "length": 50}, [], False),
|
|
],
|
|
)
|
|
def test_video_segment_has_audio_boundaries(video, audio_segs, expected):
|
|
assert loop_audio.video_segment_has_audio(video, audio_segs) is expected
|
|
|
|
|
|
# --- parse_feedback — score 0 must round-trip (PR #185 review MEDIUM-9) ----
|
|
|
|
|
|
def test_parse_feedback_extracts_scores_with_lines():
|
|
body = (
|
|
"Looking solid overall\n"
|
|
"Sync drifts in the last second\n"
|
|
"motion: 4/5\n"
|
|
"sync: 3/5\n"
|
|
"sharpness: 5/5\n"
|
|
)
|
|
scores, lines = loop_audio.parse_feedback(body)
|
|
assert scores == {"motion": 4, "sync": 3, "sharp": 5}
|
|
assert lines == ["Looking solid overall", "Sync drifts in the last second"]
|
|
|
|
|
|
def test_parse_feedback_preserves_zero_as_score():
|
|
"""0 is a legitimate score. Pre-fix the log row coerced it to '?'
|
|
via `or '?'`; parse_feedback itself stores 0 correctly — this test
|
|
pins that contract so a future refactor can't subtly inject `or 0`
|
|
semantics."""
|
|
scores, _ = loop_audio.parse_feedback("motion: 0/5\nsync: 0\nsharp: 0\n")
|
|
assert scores == {"motion": 0, "sync": 0, "sharp": 0}
|
|
|
|
|
|
def test_render_log_row_preserves_zero_scores():
|
|
row = loop_audio.render_log_row(
|
|
3,
|
|
_director(),
|
|
"",
|
|
"model-gen",
|
|
{"segments": [], "audioSegments": []},
|
|
{"motion": 0, "sync": 0, "sharp": 0},
|
|
[],
|
|
)
|
|
assert "M0·S0·Sh0" in row
|
|
|
|
|
|
def test_parse_feedback_accepts_sharpness_alias():
|
|
"""Both 'sharp' and 'sharpness' are accepted axis names. Both map
|
|
to the 'sharp' key."""
|
|
scores, _ = loop_audio.parse_feedback("sharpness: 4\n")
|
|
assert scores["sharp"] == 4
|
|
|
|
|
|
def test_parse_feedback_case_insensitive():
|
|
scores, _ = loop_audio.parse_feedback("MOTION: 3\nSync 4\n")
|
|
assert scores["motion"] == 3
|
|
assert scores["sync"] == 4
|
|
|
|
|
|
def test_parse_feedback_empty_body_returns_blank_scores():
|
|
scores, lines = loop_audio.parse_feedback("")
|
|
assert scores == {"motion": None, "sync": None, "sharp": None}
|
|
assert lines == []
|
|
|
|
|
|
# --- is_input_wired — None vs False semantics matter for derive_audio_state ---
|
|
|
|
|
|
def test_is_input_wired_none_when_director_missing():
|
|
assert loop_audio.is_input_wired(None, "audio_vae") is None
|
|
|
|
|
|
def test_is_input_wired_true_when_link_set():
|
|
node = {"inputs": [{"name": "audio_vae", "link": 42}]}
|
|
assert loop_audio.is_input_wired(node, "audio_vae") is True
|
|
|
|
|
|
def test_is_input_wired_false_when_link_null():
|
|
node = {"inputs": [{"name": "audio_vae", "link": None}]}
|
|
assert loop_audio.is_input_wired(node, "audio_vae") is False
|
|
|
|
|
|
def test_is_input_wired_none_when_input_socket_absent():
|
|
"""An older Director schema might not have the named socket at
|
|
all — same outcome as 'unwired' at runtime (no audio latent
|
|
produced), but a distinct value here so callers can tell."""
|
|
node = {"inputs": [{"name": "model", "link": 1}]}
|
|
assert loop_audio.is_input_wired(node, "audio_vae") is None
|
|
|
|
|
|
# --- wrap_path — long mount paths shouldn't bleed past the card edge -------
|
|
|
|
|
|
def test_wrap_path_breaks_on_separator():
|
|
out = loop_audio.wrap_path(
|
|
"e:/G-Drive-BaconX/Jobs/Jeep_Animals/ComfyUI_LTX23/Phase2",
|
|
max_chars=30,
|
|
)
|
|
# Joined with backslashes, never split mid-segment.
|
|
for line in out:
|
|
assert "/" not in line # normalised to backslash separators
|
|
joined = "".join(out)
|
|
assert joined == "e:\\G-Drive-BaconX\\Jobs\\Jeep_Animals\\ComfyUI_LTX23\\Phase2"
|
|
|
|
|
|
def test_wrap_path_returns_single_empty_string_for_empty_input():
|
|
assert loop_audio.wrap_path("") == [""]
|
|
|
|
|
|
def test_wrap_path_handles_single_segment_longer_than_max():
|
|
"""A directory name longer than max_chars still gets its own
|
|
line — we never split mid-name."""
|
|
out = loop_audio.wrap_path("verylongsingledirectoryname", max_chars=10)
|
|
assert len(out) == 1
|
|
assert out[0] == "verylongsingledirectoryname"
|
|
|
|
|
|
# --- pick_existing_path — picks reachable path, falls back to first ------
|
|
|
|
|
|
def test_pick_existing_path_prefers_real_directory(tmp_path):
|
|
real = tmp_path / "real"
|
|
real.mkdir()
|
|
out = loop_audio.pick_existing_path([
|
|
"Z:/never-exists",
|
|
str(real),
|
|
"Y:/also-never",
|
|
])
|
|
assert out == str(real)
|
|
|
|
|
|
def test_pick_existing_path_falls_back_to_first_nonempty():
|
|
out = loop_audio.pick_existing_path(["Z:/never-exists", "Y:/also-never"])
|
|
assert out == "Z:/never-exists"
|
|
|
|
|
|
def test_pick_existing_path_returns_empty_when_all_empty():
|
|
assert loop_audio.pick_existing_path(["", " ", "\""]) == ""
|
|
|
|
|
|
# --- first_multiline + autogen_label sanity checks ------------------------
|
|
|
|
|
|
def test_first_multiline_returns_first_or_empty():
|
|
assert loop_audio.first_multiline({"name": ["foo", "bar"]}, "name") == "foo"
|
|
assert loop_audio.first_multiline({}, "name") == ""
|
|
assert loop_audio.first_multiline({"name": []}, "name") == ""
|
|
|
|
|
|
def test_autogen_label_when_director_missing():
|
|
label = loop_audio.autogen_label("Bear_3x", None, "")
|
|
assert "missing" in label
|
|
assert "audio-off" in label
|
|
|
|
|
|
def test_autogen_label_when_director_present():
|
|
node = _director(use_custom_audio=True, audio_vae_link=42)
|
|
label = loop_audio.autogen_label(
|
|
"Bear_3x", node, '{"video_strength": 10.0}'
|
|
)
|
|
assert "koolook" in label
|
|
assert "audio-on" in label
|
|
assert "vstr10.0" in label
|
|
|
|
|
|
def test_next_run_number_reads_folders_and_log(tmp_path):
|
|
runs = tmp_path / "runs"
|
|
runs.mkdir()
|
|
(runs / "run-001_alpha").mkdir()
|
|
(runs / "log.md").write_text(
|
|
"| Run | Date |\n"
|
|
"|---|---|\n"
|
|
"| 002 | 2026-05-29 |\n",
|
|
encoding="utf-8",
|
|
)
|
|
assert loop_audio.next_run_number(runs) == 3
|