Prepare DiffusionGemma Director 0.2.0
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import wave
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as torch_functional
|
||||
|
||||
|
||||
def install_comfy_stubs() -> None:
|
||||
"""Install the narrow ComfyUI seams exercised by the reframe unit tests."""
|
||||
|
||||
folder_paths = ModuleType("folder_paths")
|
||||
folder_paths.get_input_directory = lambda: str(Path.cwd())
|
||||
folder_paths.annotated_filepath = lambda name: (name, None)
|
||||
folder_paths.filter_files_content_types = lambda files, _types: list(files)
|
||||
|
||||
def load_audio(path: str):
|
||||
with wave.open(str(path), "rb") as handle:
|
||||
if handle.getsampwidth() != 2:
|
||||
raise ValueError("test audio stub supports 16-bit PCM only")
|
||||
channels = handle.getnchannels()
|
||||
frame_count = handle.getnframes()
|
||||
sample_rate = handle.getframerate()
|
||||
payload = bytearray(handle.readframes(frame_count))
|
||||
samples = torch.frombuffer(payload, dtype=torch.int16).reshape(-1, channels)
|
||||
waveform = samples.transpose(0, 1).to(torch.float32).div_(32768.0).contiguous()
|
||||
return waveform, sample_rate
|
||||
|
||||
comfy_extras = ModuleType("comfy_extras")
|
||||
comfy_extras.__path__ = []
|
||||
comfy_audio = ModuleType("comfy_extras.nodes_audio")
|
||||
comfy_audio.load = load_audio
|
||||
comfy_extras.nodes_audio = comfy_audio
|
||||
|
||||
comfy = ModuleType("comfy")
|
||||
comfy.__path__ = []
|
||||
comfy_utils = ModuleType("comfy.utils")
|
||||
comfy_utils.common_upscale = lambda images, width, height, _method, _crop: (
|
||||
torch_functional.interpolate(
|
||||
images,
|
||||
size=(int(height), int(width)),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
)
|
||||
comfy.utils = comfy_utils
|
||||
|
||||
class UnsupportedVideo:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
raise RuntimeError("video decoding is outside this unit-test seam")
|
||||
|
||||
comfy_latest = ModuleType("comfy_api.latest")
|
||||
comfy_latest.InputImpl = SimpleNamespace(
|
||||
VideoFromFile=UnsupportedVideo,
|
||||
VideoFromComponents=UnsupportedVideo,
|
||||
)
|
||||
comfy_latest.Types = SimpleNamespace(
|
||||
VideoComponents=lambda **values: SimpleNamespace(**values)
|
||||
)
|
||||
comfy_api = ModuleType("comfy_api")
|
||||
comfy_api.__path__ = []
|
||||
comfy_api.latest = comfy_latest
|
||||
|
||||
aiohttp_web = ModuleType("aiohttp.web")
|
||||
aiohttp_web.json_response = lambda data, status=200: SimpleNamespace(
|
||||
data=data,
|
||||
status=status,
|
||||
)
|
||||
aiohttp = ModuleType("aiohttp")
|
||||
aiohttp.__path__ = []
|
||||
aiohttp.web = aiohttp_web
|
||||
|
||||
sys.modules.update(
|
||||
{
|
||||
"aiohttp": aiohttp,
|
||||
"aiohttp.web": aiohttp_web,
|
||||
"comfy": comfy,
|
||||
"comfy.utils": comfy_utils,
|
||||
"comfy_api": comfy_api,
|
||||
"comfy_api.latest": comfy_latest,
|
||||
"comfy_extras": comfy_extras,
|
||||
"comfy_extras.nodes_audio": comfy_audio,
|
||||
"folder_paths": folder_paths,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import wave
|
||||
|
||||
import torch
|
||||
|
||||
from comfy_test_stubs import install_comfy_stubs
|
||||
|
||||
install_comfy_stubs()
|
||||
|
||||
from ltx_reframe import (
|
||||
LTXReframeCustomAudioLoader,
|
||||
LTXReframeRectilinearCorrection,
|
||||
LTXReframeTargetFit,
|
||||
NODE_CLASS_MAPPINGS,
|
||||
build_reframe_masks,
|
||||
compute_reframe_placement,
|
||||
ensure_audio_track,
|
||||
fit_audio_track,
|
||||
prepare_reframe_canvas,
|
||||
rectilinear_correct_images,
|
||||
resolve_frame_range,
|
||||
snap_dimension,
|
||||
)
|
||||
|
||||
|
||||
class LTXReframeTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _write_test_wav(path: Path, *, sample_rate: int = 8000, samples: int = 80):
|
||||
with wave.open(str(path), "wb") as handle:
|
||||
handle.setnchannels(1)
|
||||
handle.setsampwidth(2)
|
||||
handle.setframerate(sample_rate)
|
||||
handle.writeframes(b"\x00\x00" * samples)
|
||||
|
||||
def test_custom_audio_loader_is_registered_and_accepts_an_empty_lazy_branch(self):
|
||||
self.assertIs(
|
||||
NODE_CLASS_MAPPINGS["LTXReframeCustomAudioLoader"],
|
||||
LTXReframeCustomAudioLoader,
|
||||
)
|
||||
self.assertTrue(LTXReframeCustomAudioLoader.VALIDATE_INPUTS(""))
|
||||
with self.assertRaisesRegex(ValueError, "no custom audio file is selected"):
|
||||
LTXReframeCustomAudioLoader().load_audio("")
|
||||
|
||||
def test_custom_audio_loader_lists_audio_uploads_with_a_blank_default(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.filter_files_content_types",
|
||||
return_value=["song.wav"],
|
||||
),
|
||||
):
|
||||
spec = LTXReframeCustomAudioLoader.INPUT_TYPES()
|
||||
|
||||
options, config = spec["required"]["audio_file"]
|
||||
self.assertEqual(options, ["", "song.wav"])
|
||||
self.assertTrue(config["audio_upload"])
|
||||
|
||||
def test_custom_audio_loader_decodes_and_fingerprints_an_input_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
audio_path = Path(directory) / "clip.wav"
|
||||
self._write_test_wav(audio_path)
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.annotated_filepath",
|
||||
side_effect=lambda name: (name, None),
|
||||
),
|
||||
):
|
||||
audio, = LTXReframeCustomAudioLoader().load_audio("clip.wav")
|
||||
first_fingerprint = LTXReframeCustomAudioLoader.IS_CHANGED("clip.wav")
|
||||
second_fingerprint = LTXReframeCustomAudioLoader.IS_CHANGED("clip.wav")
|
||||
with audio_path.open("ab") as handle:
|
||||
handle.write(b"changed")
|
||||
changed_fingerprint = LTXReframeCustomAudioLoader.IS_CHANGED("clip.wav")
|
||||
|
||||
self.assertEqual(audio["sample_rate"], 8000)
|
||||
self.assertEqual(tuple(audio["waveform"].shape), (1, 1, 80))
|
||||
self.assertEqual(first_fingerprint, second_fingerprint)
|
||||
self.assertNotEqual(first_fingerprint, changed_fingerprint)
|
||||
self.assertEqual(len(first_fingerprint), 64)
|
||||
|
||||
def test_custom_audio_loader_reports_a_missing_input_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.annotated_filepath",
|
||||
side_effect=lambda name: (name, None),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "file was not found"):
|
||||
LTXReframeCustomAudioLoader().load_audio("missing.wav")
|
||||
|
||||
def test_custom_audio_loader_rejects_paths_outside_comfyui_input(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.annotated_filepath",
|
||||
return_value=("../outside.wav", None),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "input directory"):
|
||||
LTXReframeCustomAudioLoader().load_audio("../outside.wav")
|
||||
|
||||
def test_custom_audio_example_workflow_has_consistent_links_and_safe_defaults(self):
|
||||
workflow_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "examples"
|
||||
/ "gemmaREFRAME_Experimental_audio_on-off.json"
|
||||
)
|
||||
workflow = json.loads(workflow_path.read_text(encoding="utf-8"))
|
||||
nodes = {node["id"]: node for node in workflow["nodes"]}
|
||||
links = {link[0]: link for link in workflow["links"]}
|
||||
|
||||
self.assertEqual(len(nodes), len(workflow["nodes"]))
|
||||
self.assertEqual(len(links), len(workflow["links"]))
|
||||
for link_id, (_, source_id, source_slot, target_id, target_slot, _type) in links.items():
|
||||
self.assertIn(source_id, nodes)
|
||||
self.assertIn(target_id, nodes)
|
||||
self.assertIn(link_id, nodes[source_id]["outputs"][source_slot].get("links") or [])
|
||||
self.assertEqual(nodes[target_id]["inputs"][target_slot].get("link"), link_id)
|
||||
|
||||
custom_loader = nodes[9086]
|
||||
selector = nodes[9112]
|
||||
reframe = nodes[5553]
|
||||
stage_one = nodes[9055]
|
||||
create_video = nodes[5539]
|
||||
preview = nodes[9088]
|
||||
|
||||
self.assertEqual(custom_loader["type"], "LTXReframeCustomAudioLoader")
|
||||
self.assertEqual(custom_loader["widgets_values"], [""])
|
||||
self.assertEqual(selector["type"], "LazySwitchKJ")
|
||||
self.assertFalse(selector["widgets_values"][0])
|
||||
self.assertEqual(set(selector["outputs"][0]["links"]), {14666, 14818, 14918})
|
||||
self.assertEqual(reframe["outputs"][4]["links"], [14990, 14991])
|
||||
self.assertEqual(stage_one["inputs"][10]["link"], 14818)
|
||||
self.assertEqual(preview["inputs"][0]["link"], 14918)
|
||||
self.assertEqual(create_video["inputs"][1]["link"], 14999)
|
||||
self.assertEqual(create_video["inputs"][2]["link"], 14666)
|
||||
|
||||
def test_existing_audio_is_preserved(self):
|
||||
audio = {
|
||||
"waveform": torch.ones((1, 1, 100), dtype=torch.float32),
|
||||
"sample_rate": 16000,
|
||||
}
|
||||
|
||||
result, synthesized = ensure_audio_track(audio, frame_count=24, fps=24.0)
|
||||
|
||||
self.assertIs(result, audio)
|
||||
self.assertFalse(synthesized)
|
||||
|
||||
def test_audio_less_video_gets_duration_matched_stereo_silence(self):
|
||||
audio, synthesized = ensure_audio_track(None, frame_count=121, fps=24.0)
|
||||
|
||||
self.assertTrue(synthesized)
|
||||
self.assertEqual(audio["sample_rate"], 44100)
|
||||
self.assertEqual(tuple(audio["waveform"].shape), (1, 2, 222338))
|
||||
self.assertEqual(torch.count_nonzero(audio["waveform"]).item(), 0)
|
||||
|
||||
def test_silent_audio_fallback_rejects_invalid_fps(self):
|
||||
with self.assertRaisesRegex(ValueError, "valid video frame rate"):
|
||||
ensure_audio_track(None, frame_count=121, fps=0.0)
|
||||
|
||||
def test_audio_is_cropped_and_padded_to_selected_duration(self):
|
||||
cropped, synthesized = fit_audio_track(
|
||||
{"waveform": torch.ones((1, 2, 2000)), "sample_rate": 1000},
|
||||
frame_count=25,
|
||||
fps=25.0,
|
||||
)
|
||||
padded, _ = fit_audio_track(
|
||||
{"waveform": torch.ones((1, 2, 500)), "sample_rate": 1000},
|
||||
frame_count=25,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
self.assertFalse(synthesized)
|
||||
self.assertEqual(cropped["waveform"].shape[-1], 1000)
|
||||
self.assertEqual(padded["waveform"].shape[-1], 1000)
|
||||
self.assertEqual(torch.count_nonzero(padded["waveform"][..., 500:]).item(), 0)
|
||||
|
||||
def test_full_video_range_is_not_aligned_or_trimmed(self):
|
||||
selected = resolve_frame_range(70, 25.0, False, 8, 16)
|
||||
|
||||
self.assertFalse(selected.range_enabled)
|
||||
self.assertEqual(selected.effective_first_frame, 0)
|
||||
self.assertEqual(selected.effective_last_frame, 69)
|
||||
self.assertEqual(selected.selected_frame_count, 70)
|
||||
|
||||
def test_valid_sixty_five_frame_range_is_preserved(self):
|
||||
selected = resolve_frame_range(241, 25.0, True, 0, 64)
|
||||
|
||||
self.assertEqual(selected.effective_first_frame, 0)
|
||||
self.assertEqual(selected.effective_last_frame, 64)
|
||||
self.assertEqual(selected.selected_frame_count, 65)
|
||||
|
||||
def test_range_alignment_preserves_first_frame_and_trims_end(self):
|
||||
zero_based = resolve_frame_range(241, 25.0, True, 0, 70)
|
||||
offset = resolve_frame_range(241, 25.0, True, 100, 199)
|
||||
|
||||
self.assertEqual((zero_based.effective_first_frame, zero_based.effective_last_frame), (0, 64))
|
||||
self.assertEqual(zero_based.selected_frame_count, 65)
|
||||
self.assertEqual((offset.effective_first_frame, offset.effective_last_frame), (100, 196))
|
||||
self.assertEqual(offset.selected_frame_count, 97)
|
||||
|
||||
def test_last_frame_minus_one_resolves_to_source_end_then_aligns(self):
|
||||
selected = resolve_frame_range(100, 25.0, True, 10, -1)
|
||||
|
||||
self.assertEqual(selected.requested_last_frame, 99)
|
||||
self.assertEqual(selected.effective_last_frame, 98)
|
||||
self.assertEqual(selected.selected_frame_count, 89)
|
||||
|
||||
def test_invalid_frame_ranges_are_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "greater than or equal"):
|
||||
resolve_frame_range(100, 25.0, True, 30, 20)
|
||||
with self.assertRaisesRegex(ValueError, "outside"):
|
||||
resolve_frame_range(100, 25.0, True, 0, 100)
|
||||
with self.assertRaisesRegex(ValueError, "at least nine"):
|
||||
resolve_frame_range(100, 25.0, True, 0, 7)
|
||||
|
||||
def test_single_frame_range_is_allowed(self):
|
||||
selected = resolve_frame_range(100, 25.0, True, 42, 42)
|
||||
|
||||
self.assertEqual(selected.selected_frame_count, 1)
|
||||
self.assertEqual(selected.effective_last_frame, 42)
|
||||
|
||||
def test_dimensions_snap_to_ltx_multiple(self):
|
||||
self.assertEqual(snap_dimension(1919), 1920)
|
||||
self.assertEqual(snap_dimension(1050), 1024)
|
||||
self.assertEqual(snap_dimension(1), 256)
|
||||
|
||||
def test_portrait_canvas_centers_aspect_locked_landscape_source(self):
|
||||
placement = compute_reframe_placement(1920, 1080, 1088, 1920, 0.5, 0.5, 0.7)
|
||||
self.assertEqual(placement.target_width, 1088)
|
||||
self.assertEqual(placement.target_height, 1920)
|
||||
self.assertLess(abs(placement.box_width / placement.box_height - 16 / 9), 0.01)
|
||||
self.assertLessEqual(abs(placement.box_x * 2 + placement.box_width - placement.target_width), 1)
|
||||
self.assertLessEqual(abs(placement.box_y * 2 + placement.box_height - placement.target_height), 1)
|
||||
|
||||
def test_canvas_and_masks_preserve_source_region(self):
|
||||
frames = torch.ones((2, 8, 16, 3), dtype=torch.float32)
|
||||
placement = compute_reframe_placement(16, 8, 256, 256, 0.5, 0.5, 0.5)
|
||||
canvas = prepare_reframe_canvas(frames, placement)
|
||||
outpaint, source = build_reframe_masks(placement, feather=0)
|
||||
|
||||
self.assertEqual(tuple(canvas.shape), (2, 256, 256, 3))
|
||||
self.assertEqual(tuple(outpaint.shape), (1, 256, 256))
|
||||
self.assertEqual(tuple(source.shape), (1, 256, 256))
|
||||
self.assertTrue(torch.allclose(outpaint + source, torch.ones_like(source)))
|
||||
self.assertEqual(canvas[:, placement.box_y, placement.box_x].mean().item(), 1.0)
|
||||
self.assertEqual(canvas[:, 0, 0].sum().item(), 0.0)
|
||||
|
||||
def test_feather_ramps_only_outside_source_box(self):
|
||||
placement = compute_reframe_placement(16, 9, 256, 256, 0.5, 0.5, 0.5)
|
||||
outpaint, source = build_reframe_masks(placement, feather=16)
|
||||
inside_y = placement.box_y + placement.box_height // 2
|
||||
inside_x = placement.box_x + placement.box_width // 2
|
||||
self.assertEqual(outpaint[0, inside_y, inside_x].item(), 0.0)
|
||||
self.assertEqual(source[0, inside_y, inside_x].item(), 1.0)
|
||||
self.assertEqual(outpaint[0, 0, 0].item(), 1.0)
|
||||
|
||||
def test_zoom_in_can_extend_source_beyond_target(self):
|
||||
placement = compute_reframe_placement(1920, 1080, 1088, 1920, 0.5, 0.5, 2.0)
|
||||
|
||||
self.assertEqual(placement.box_width, 2176)
|
||||
self.assertEqual(placement.box_height, 1224)
|
||||
self.assertEqual(placement.box_x, -544)
|
||||
self.assertEqual(placement.box_y, 348)
|
||||
self.assertEqual(placement.source_scale, 2.0)
|
||||
|
||||
def test_zoomed_source_is_cropped_safely_to_target_canvas(self):
|
||||
frames = torch.ones((2, 8, 16, 3), dtype=torch.float32)
|
||||
placement = compute_reframe_placement(16, 8, 256, 256, 0.5, 0.5, 4.0)
|
||||
canvas = prepare_reframe_canvas(frames, placement)
|
||||
outpaint, source = build_reframe_masks(placement, feather=32)
|
||||
|
||||
self.assertEqual(tuple(canvas.shape), (2, 256, 256, 3))
|
||||
self.assertTrue(torch.allclose(canvas, torch.ones_like(canvas)))
|
||||
self.assertTrue(torch.allclose(source, torch.ones_like(source)))
|
||||
self.assertTrue(torch.allclose(outpaint, torch.zeros_like(outpaint)))
|
||||
|
||||
def test_target_fit_returns_exact_dimensions(self):
|
||||
images = torch.rand((2, 96, 160, 3), dtype=torch.float32)
|
||||
|
||||
fitted, = LTXReframeTargetFit().fit(images, target_width=128, target_height=128)
|
||||
|
||||
self.assertEqual(tuple(fitted.shape), (2, 128, 128, 3))
|
||||
self.assertGreaterEqual(fitted.min().item(), 0.0)
|
||||
self.assertLessEqual(fitted.max().item(), 1.0)
|
||||
|
||||
def test_rectilinear_correction_is_temporally_deterministic(self):
|
||||
horizontal = torch.linspace(0.0, 1.0, 65).view(1, 1, 65, 1)
|
||||
frame = horizontal.expand(1, 33, 65, 3)
|
||||
images = frame.expand(2, -1, -1, -1).clone()
|
||||
|
||||
corrected, correction_mask = rectilinear_correct_images(
|
||||
images,
|
||||
correction_strength=0.1,
|
||||
center_x=0.5,
|
||||
center_y=0.5,
|
||||
protect_source=0.0,
|
||||
protection_feather=0,
|
||||
interpolation="bilinear",
|
||||
chunk_size=1,
|
||||
)
|
||||
|
||||
self.assertEqual(tuple(corrected.shape), tuple(images.shape))
|
||||
self.assertEqual(tuple(correction_mask.shape), (1, 33, 65))
|
||||
self.assertTrue(torch.allclose(corrected[0], corrected[1]))
|
||||
self.assertGreater(corrected[0, 16, 0, 0].item(), images[0, 16, 0, 0].item())
|
||||
self.assertLess(corrected[0, 16, -1, 0].item(), images[0, 16, -1, 0].item())
|
||||
self.assertAlmostEqual(
|
||||
corrected[0, 16, 32, 0].item(),
|
||||
images[0, 16, 32, 0].item(),
|
||||
places=5,
|
||||
)
|
||||
|
||||
def test_rectilinear_source_mask_can_fully_protect_original_pixels(self):
|
||||
images = torch.rand((2, 24, 40, 3), dtype=torch.float32)
|
||||
source_mask = torch.ones((1, 24, 40), dtype=torch.float32)
|
||||
|
||||
corrected, correction_mask = rectilinear_correct_images(
|
||||
images,
|
||||
correction_strength=0.15,
|
||||
center_x=0.5,
|
||||
center_y=0.5,
|
||||
protect_source=1.0,
|
||||
protection_feather=0,
|
||||
interpolation="bicubic",
|
||||
chunk_size=2,
|
||||
source_mask=source_mask,
|
||||
)
|
||||
|
||||
self.assertTrue(torch.allclose(corrected, images))
|
||||
self.assertEqual(torch.count_nonzero(correction_mask).item(), 0)
|
||||
|
||||
def test_rectilinear_node_bypass_is_pixel_exact(self):
|
||||
images = torch.rand((3, 16, 20, 3), dtype=torch.float32)
|
||||
|
||||
bypassed, correction_mask = LTXReframeRectilinearCorrection().correct(
|
||||
images,
|
||||
enabled=False,
|
||||
correction_strength=0.05,
|
||||
center_x=0.5,
|
||||
center_y=0.5,
|
||||
protect_source=1.0,
|
||||
protection_feather=96,
|
||||
interpolation="bicubic",
|
||||
chunk_size=8,
|
||||
)
|
||||
|
||||
self.assertIs(bypassed, images)
|
||||
self.assertEqual(tuple(correction_mask.shape), (1, 16, 20))
|
||||
self.assertEqual(torch.count_nonzero(correction_mask).item(), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
let registeredExtension = null;
|
||||
globalThis.window = {
|
||||
comfyAPI: {
|
||||
app: {
|
||||
app: {
|
||||
registerExtension(extension) {
|
||||
registeredExtension = extension;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await import(`../web/js/model_loader.js?test=${Date.now()}`);
|
||||
assert.ok(registeredExtension, "the model-loader frontend extension should register");
|
||||
|
||||
class UnrelatedNode {}
|
||||
await registeredExtension.beforeRegisterNodeDef(UnrelatedNode, { name: "OtherNode" });
|
||||
assert.equal(
|
||||
UnrelatedNode.prototype.onNodeCreated,
|
||||
undefined,
|
||||
"unrelated node types should not be patched",
|
||||
);
|
||||
|
||||
let createdCalls = 0;
|
||||
let configuredCalls = 0;
|
||||
|
||||
class FakeLoaderNode {}
|
||||
FakeLoaderNode.prototype.onNodeCreated = function () {
|
||||
createdCalls += 1;
|
||||
};
|
||||
FakeLoaderNode.prototype.onConfigure = function () {
|
||||
configuredCalls += 1;
|
||||
};
|
||||
|
||||
await registeredExtension.beforeRegisterNodeDef(FakeLoaderNode, {
|
||||
name: "DiffusionGemmaModelLoader",
|
||||
});
|
||||
|
||||
const hiddenWidgetNames = new Set([
|
||||
"backend",
|
||||
"dtype",
|
||||
"quantization",
|
||||
"local_files_only",
|
||||
"unload_policy",
|
||||
"max_memory_gb",
|
||||
]);
|
||||
const node = new FakeLoaderNode();
|
||||
node.widgets = [
|
||||
{ name: "model_path", value: "models/diffusiongemma" },
|
||||
{ name: "temperature", value: "invalid" },
|
||||
...[...hiddenWidgetNames].map((name) => ({ name, value: "advanced" })),
|
||||
];
|
||||
node.size = [520, 640];
|
||||
node.computeSize = () => [360, 240];
|
||||
node.setSize = (size) => {
|
||||
node.size = [...size];
|
||||
};
|
||||
|
||||
node.onNodeCreated();
|
||||
assert.equal(createdCalls, 1, "the original creation hook should still run");
|
||||
assert.equal(
|
||||
node.widgets.find((entry) => entry.name === "temperature").value,
|
||||
0.45,
|
||||
"invalid saved temperatures should migrate to the stable default",
|
||||
);
|
||||
assert.deepEqual(node.size, [520, 240], "hiding widgets should preserve the user's width");
|
||||
|
||||
for (const entry of node.widgets) {
|
||||
if (hiddenWidgetNames.has(entry.name)) {
|
||||
assert.equal(entry.hidden, true, `${entry.name} should be hidden`);
|
||||
assert.equal(entry.__dgHidden, true, `${entry.name} should only be hidden once`);
|
||||
assert.deepEqual(entry.computeSize(), [0, -4]);
|
||||
} else {
|
||||
assert.notEqual(entry.hidden, true, `${entry.name} should remain visible`);
|
||||
}
|
||||
}
|
||||
|
||||
node.widgets.find((entry) => entry.name === "temperature").value = 0.7;
|
||||
node.size = [610, 700];
|
||||
node.computeSize = () => [380, 260];
|
||||
node.onConfigure({});
|
||||
assert.equal(configuredCalls, 1, "the original configuration hook should still run");
|
||||
assert.equal(node.widgets.find((entry) => entry.name === "temperature").value, 0.7);
|
||||
assert.deepEqual(node.size, [610, 260], "configuration should keep the saved width");
|
||||
|
||||
console.log("DiffusionGemma model-loader UI checks passed");
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PackageMetadataTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.metadata = tomllib.loads(
|
||||
(ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
def test_registry_identity_and_release_version_are_current(self) -> None:
|
||||
self.assertEqual(self.metadata["project"]["version"], "0.2.0")
|
||||
self.assertEqual(self.metadata["tool"]["comfy"]["PublisherId"], "exportanything")
|
||||
|
||||
def test_registry_compatibility_starts_at_the_tested_baseline(self) -> None:
|
||||
self.assertEqual(
|
||||
self.metadata["tool"]["comfy"]["requires-comfyui"],
|
||||
">=0.30.1",
|
||||
)
|
||||
|
||||
def test_registry_archive_excludes_ci_and_large_demo_media(self) -> None:
|
||||
ignored = {
|
||||
line.strip()
|
||||
for line in (ROOT / ".comfyignore").read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
}
|
||||
self.assertTrue({".github/", "tests/", "*.mp4", "*.gif"} <= ignored)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMFY_ROOT = ROOT.parents[1]
|
||||
SCHEMA = ROOT / "schemas" / "splatstage_show_blueprint.schema.json"
|
||||
|
||||
DIRECTOR_NODE_IDS = {
|
||||
"DiffusionGemmaModelLoader",
|
||||
"DiffusionGemmaContextHub",
|
||||
"DiffusionGemmaH3ReferenceContext",
|
||||
"DiffusionGemmaTargetProfile",
|
||||
"DiffusionGemmaCoTGenerator",
|
||||
"DiffusionGemmaJSONSplitter",
|
||||
"DiffusionGemmaGenerationGate",
|
||||
}
|
||||
EXPERIMENTAL_NODE_IDS = {
|
||||
"DiffusionGemmaSplatStagePlanner",
|
||||
"DiffusionGemmaSplatStageEfficientPlanner",
|
||||
}
|
||||
|
||||
|
||||
def load_nodes_module():
|
||||
if str(COMFY_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(COMFY_ROOT))
|
||||
package_name = f"diffusiongemma_splatstage_fixture_{uuid.uuid4().hex}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
package_name,
|
||||
ROOT / "__init__.py",
|
||||
submodule_search_locations=[str(ROOT)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("could not load DiffusionGemma Prompt Builder")
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
sys.modules[package_name] = package
|
||||
spec.loader.exec_module(package)
|
||||
return sys.modules[f"{package_name}.nodes"]
|
||||
|
||||
|
||||
class SplatStagePlannerMusicTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.nodes = load_nodes_module()
|
||||
cls.schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
|
||||
def test_strict_schema_is_bundled_with_the_package(self) -> None:
|
||||
self.assertEqual(self.nodes._splatstage_blueprint_schema_path(), SCHEMA)
|
||||
|
||||
def test_prompt_requests_a_full_song_before_the_video_excerpt(self) -> None:
|
||||
prompt = self.nodes._splatstage_blueprint_prompt(
|
||||
"make a pop-music video",
|
||||
42,
|
||||
self.schema,
|
||||
duration_seconds=20.0,
|
||||
aspect_ratio="16:9",
|
||||
)
|
||||
self.assertIn("complete 90-second source song", prompt)
|
||||
self.assertIn("final video uses a 20-second excerpt", prompt)
|
||||
self.assertIn("two separately generated performer clips", prompt)
|
||||
self.assertIn("one performer object, one background object, and one FX object", prompt)
|
||||
self.assertIn("never arrays or clip-specific lists", prompt)
|
||||
self.assertIn("16 to 36 non-empty lines", prompt)
|
||||
self.assertIn("SAM3 object-category bundle", prompt)
|
||||
self.assertIn("wide-brimmed mariachi hat", prompt)
|
||||
self.assertIn("electric guitar", prompt)
|
||||
self.assertIn("positive_prompt must use affirmative visual language only", prompt)
|
||||
self.assertIn('"schema":"splatstage.show_blueprint"', prompt)
|
||||
self.assertIn('"version":1', prompt)
|
||||
self.assertIn(
|
||||
'Never put the schema document identifier "splatstage.show_blueprint@1"',
|
||||
prompt,
|
||||
)
|
||||
|
||||
def test_planner_contract_supports_ten_or_twenty_seconds(self) -> None:
|
||||
spec = self.nodes.DiffusionGemmaSplatStagePlanner.INPUT_TYPES()["required"][
|
||||
"duration_seconds"
|
||||
][1]
|
||||
self.assertEqual(spec["min"], 10.0)
|
||||
self.assertEqual(spec["max"], 20.0)
|
||||
self.assertEqual(spec["default"], 20.0)
|
||||
|
||||
def test_full_song_lyric_budget_accepts_48_lines_but_not_49(self) -> None:
|
||||
def blueprint(line_count: int) -> dict:
|
||||
return {
|
||||
"schema": "splatstage.show_blueprint",
|
||||
"version": 1,
|
||||
"project_title": "Song",
|
||||
"creative_summary": "A compact performance video.",
|
||||
"music": {
|
||||
"ace_tags": "bright pop",
|
||||
"vocal_mode": "vocal_hook",
|
||||
"lyrics": "\n".join(
|
||||
f"Original lyric line {index}" for index in range(line_count)
|
||||
),
|
||||
"bpm": 120,
|
||||
"key": "C major",
|
||||
"time_signature": "4",
|
||||
"language": "en",
|
||||
},
|
||||
"performer": {
|
||||
"label": "Singer",
|
||||
"positive_prompt": "One singer performing",
|
||||
"negative_prompt": "crowd",
|
||||
"segmentation_prompt": "the singer",
|
||||
},
|
||||
"background": {
|
||||
"positive_prompt": "Abstract lights",
|
||||
"negative_prompt": "people",
|
||||
},
|
||||
"fx": {
|
||||
"positive_prompt": "Prism sparks",
|
||||
"negative_prompt": "people, text",
|
||||
"blend_mode": "screen",
|
||||
"key_mode": "luminance",
|
||||
"opacity": 0.5,
|
||||
},
|
||||
"editing": {
|
||||
"background_density": "musical",
|
||||
"fx_density": "musical",
|
||||
"music_reactivity": 0.5,
|
||||
},
|
||||
"finish": {"style_preset": "broadcast_clean"},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_blueprint_errors(
|
||||
blueprint(48), self.schema
|
||||
),
|
||||
[],
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"at most 48" in error
|
||||
for error in self.nodes._splatstage_blueprint_errors(
|
||||
blueprint(49), self.schema
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_segmentation_prompt_uses_separate_sam3_object_categories(self) -> None:
|
||||
valid = {
|
||||
"schema": "splatstage.show_blueprint",
|
||||
"version": 1,
|
||||
"project_title": "Mariachi",
|
||||
"creative_summary": "A cyberpunk mariachi performance.",
|
||||
"music": {
|
||||
"ace_tags": "cyberpunk mariachi",
|
||||
"vocal_mode": "instrumental",
|
||||
"lyrics": "[Instrumental]",
|
||||
"bpm": 128,
|
||||
"key": "G minor",
|
||||
"time_signature": "4",
|
||||
"language": "en",
|
||||
},
|
||||
"performer": {
|
||||
"label": "Lead mariachi",
|
||||
"positive_prompt": "One cyberpunk mariachi playing a glowing guitar",
|
||||
"negative_prompt": "crowd",
|
||||
"segmentation_prompt": (
|
||||
"cyberpunk mariachi musician, black charro suit, "
|
||||
"wide-brimmed mariachi hat, glowing electric guitar"
|
||||
),
|
||||
},
|
||||
"background": {
|
||||
"positive_prompt": "Martian landscape",
|
||||
"negative_prompt": "people",
|
||||
},
|
||||
"fx": {
|
||||
"positive_prompt": "isolated sparks",
|
||||
"negative_prompt": "people, text",
|
||||
"blend_mode": "screen",
|
||||
"key_mode": "luminance",
|
||||
"opacity": 0.5,
|
||||
},
|
||||
"editing": {
|
||||
"background_density": "musical",
|
||||
"fx_density": "musical",
|
||||
"music_reactivity": 0.5,
|
||||
},
|
||||
"finish": {"style_preset": "broadcast_clean"},
|
||||
}
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_blueprint_errors(valid, self.schema),
|
||||
[],
|
||||
)
|
||||
leaked = json.loads(json.dumps(valid))
|
||||
leaked["fx"]["positive_prompt"] = "Neon sparks, no words, text-free overlay"
|
||||
polarity_errors = self.nodes._splatstage_blueprint_errors(leaked, self.schema)
|
||||
self.assertTrue(any("affirmative visual language" in error for error in polarity_errors))
|
||||
invalid = json.loads(json.dumps(valid))
|
||||
invalid["performer"]["segmentation_prompt"] = (
|
||||
"the musician in the charro suit and guitar"
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"separate the performer" in error
|
||||
for error in self.nodes._splatstage_blueprint_errors(
|
||||
invalid, self.schema
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_two_option_lane_lists_are_seed_addressed_to_one_coherent_contract(self) -> None:
|
||||
candidate = {
|
||||
"performer": [{"label": "firefighter"}, {"label": "country singer"}],
|
||||
"background": [{"name": "fire station"}, {"name": "burning skyline"}],
|
||||
"fx": [{"name": "embers"}, {"name": "smoke"}],
|
||||
}
|
||||
normalized, report = self.nodes._normalize_splatstage_lane_cardinality(
|
||||
candidate,
|
||||
selection_index=5,
|
||||
)
|
||||
self.assertEqual(normalized["performer"]["label"], "country singer")
|
||||
self.assertEqual(normalized["background"]["name"], "burning skyline")
|
||||
self.assertEqual(normalized["fx"]["name"], "smoke")
|
||||
self.assertEqual(report["performer"]["selected_index"], 1)
|
||||
self.assertIsInstance(candidate["performer"], list)
|
||||
|
||||
def test_malformed_or_oversized_lane_lists_remain_invalid(self) -> None:
|
||||
candidate = {
|
||||
"performer": [{"label": "a"}, {"label": "b"}, {"label": "c"}],
|
||||
"background": [],
|
||||
"fx": ["not an object"],
|
||||
}
|
||||
normalized, report = self.nodes._normalize_splatstage_lane_cardinality(
|
||||
candidate,
|
||||
selection_index=0,
|
||||
)
|
||||
self.assertEqual(normalized, candidate)
|
||||
self.assertEqual(report, {})
|
||||
|
||||
def test_schema_document_id_alias_is_canonicalized_only_for_matching_version(self) -> None:
|
||||
candidate = {
|
||||
"schema": "splatstage.show_blueprint@1",
|
||||
"version": 1,
|
||||
"project_title": "Alias candidate",
|
||||
}
|
||||
normalized, report = self.nodes._normalize_splatstage_blueprint_identity(
|
||||
candidate,
|
||||
self.schema,
|
||||
)
|
||||
self.assertEqual(normalized["schema"], "splatstage.show_blueprint")
|
||||
self.assertEqual(report["input_schema"], "splatstage.show_blueprint@1")
|
||||
self.assertEqual(candidate["schema"], "splatstage.show_blueprint@1")
|
||||
|
||||
wrong_version = dict(candidate, version=2)
|
||||
unchanged, report = self.nodes._normalize_splatstage_blueprint_identity(
|
||||
wrong_version,
|
||||
self.schema,
|
||||
)
|
||||
self.assertEqual(unchanged, wrong_version)
|
||||
self.assertEqual(report, {})
|
||||
|
||||
unknown_alias = dict(candidate, schema="splatstage.show_blueprint@999")
|
||||
unchanged, report = self.nodes._normalize_splatstage_blueprint_identity(
|
||||
unknown_alias,
|
||||
self.schema,
|
||||
)
|
||||
self.assertEqual(unchanged, unknown_alias)
|
||||
self.assertEqual(report, {})
|
||||
|
||||
|
||||
class EfficientSplatStagePlannerTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.nodes = load_nodes_module()
|
||||
cls.schema = json.loads(
|
||||
(ROOT / "schemas" / "splatstage_efficient_show_blueprint.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def valid_blueprint() -> dict:
|
||||
return {
|
||||
"schema": "splatstage.efficient_show_blueprint",
|
||||
"version": 1,
|
||||
"project_title": "Cyber Mariachi",
|
||||
"creative_summary": "A cyberpunk mariachi performance on Mars.",
|
||||
"music": {
|
||||
"ace_tags": "cyber mariachi pop, vocal hook",
|
||||
"vocal_mode": "vocal_hook",
|
||||
"lyrics": "[Verse]\nRed dust in the light\n[Chorus]\nPlay it to the stars",
|
||||
"bpm": 124,
|
||||
"key": "C major",
|
||||
"time_signature": "4",
|
||||
"language": "en",
|
||||
},
|
||||
"performer": {
|
||||
"identity_description": "One cyberpunk mariachi guitarist in a black charro suit",
|
||||
"positive_prompt": "Full-body performance beneath magenta concert light",
|
||||
"negative_prompt": "crowd, duplicate person, text, logos",
|
||||
"segmentation_prompt": "mariachi guitarist, mariachi hat, charro suit, electric guitar",
|
||||
"variations": [
|
||||
"Low-angle strumming with a slow dolly",
|
||||
"Energetic side view with rhythmic footwork",
|
||||
"Centered hero framing with sweeping arm motion",
|
||||
],
|
||||
},
|
||||
"background": {
|
||||
"positive_prompt": "Martian neon concert architecture and red dust",
|
||||
"negative_prompt": "people, performers, crowd, text, logos",
|
||||
"variations": [
|
||||
"Wide canyon stage with drifting haze",
|
||||
"Graphic orbital city vista with slow parallax",
|
||||
],
|
||||
},
|
||||
"fx": {
|
||||
"positive_prompt": "Isolated luminous prismatic guitar-wave geometry on a pure black background",
|
||||
"negative_prompt": "people, faces, text, words, letters, logos, symbols",
|
||||
"blend_mode": "screen",
|
||||
"key_mode": "luminance",
|
||||
"opacity": 0.55,
|
||||
},
|
||||
"editing": {"density": "musical", "music_reactivity": "medium"},
|
||||
"finish": {"style_preset": "broadcast_clean"},
|
||||
}
|
||||
|
||||
def test_schema_requires_exact_candidate_counts_and_affirmative_prompts(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_efficient_blueprint_errors(value, self.schema), []
|
||||
)
|
||||
value["performer"]["variations"] = value["performer"]["variations"][:2]
|
||||
value["fx"]["positive_prompt"] = "Glowing particles, no words, text-free"
|
||||
errors = self.nodes._splatstage_efficient_blueprint_errors(value, self.schema)
|
||||
self.assertTrue(any("variations" in error for error in errors))
|
||||
self.assertTrue(any("affirmative" in error for error in errors))
|
||||
|
||||
def test_prompt_locks_full_song_short_assets_and_semantic_union(self) -> None:
|
||||
prompt = self.nodes._splatstage_efficient_blueprint_prompt(
|
||||
"make a pop-music video", 42, self.schema
|
||||
)
|
||||
self.assertIn("complete 90-second song", prompt)
|
||||
self.assertIn("three separate five-second performer videos", prompt)
|
||||
self.assertIn("canonical performer identity first", prompt)
|
||||
self.assertIn("only concrete silhouette-extending items actually present", prompt)
|
||||
self.assertIn("richly art-directed luminous motif", prompt)
|
||||
self.assertIn("limited color palette", prompt)
|
||||
self.assertIn("rhythmic energy or", prompt)
|
||||
self.assertIn("clear spatial composition", prompt)
|
||||
self.assertIn("Do not name TouchDesigner", prompt)
|
||||
self.assertIn("locked TouchDesigner-inspired", prompt)
|
||||
self.assertIn("affirmative visual language", prompt)
|
||||
|
||||
def test_fx_creative_slot_rejects_software_or_interface_language(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
value["fx"]["positive_prompt"] = (
|
||||
"TouchDesigner application screenshot with a cyan particle vortex"
|
||||
)
|
||||
errors = self.nodes._splatstage_efficient_blueprint_errors(value, self.schema)
|
||||
self.assertTrue(
|
||||
any("locked TouchDesigner-inspired" in error for error in errors)
|
||||
)
|
||||
|
||||
def test_efficient_segmentation_rejects_free_standing_generic_categories(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
value["performer"]["segmentation_prompt"] = (
|
||||
"mariachi guitarist, face and head, hair and worn headwear, "
|
||||
"costume and worn accessories, held instrument and attached props"
|
||||
)
|
||||
errors = self.nodes._splatstage_efficient_blueprint_errors(value, self.schema)
|
||||
self.assertTrue(any("identity-specific items" in error for error in errors))
|
||||
|
||||
def test_efficient_segmentation_uses_identity_and_specific_items_only(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
value["performer"]["segmentation_prompt"] = (
|
||||
"cyberpunk mariachi guitarist, face and head, hair and worn headwear, "
|
||||
"black charro suit, electric guitar"
|
||||
)
|
||||
normalized = self.nodes._normalize_splatstage_efficient_segmentation(value)
|
||||
segmentation = normalized["performer"]["segmentation_prompt"]
|
||||
self.assertTrue(
|
||||
segmentation.startswith("One cyberpunk mariachi guitarist in a black charro suit")
|
||||
)
|
||||
self.assertNotIn("face and head", segmentation)
|
||||
self.assertNotIn("hair and worn headwear", segmentation)
|
||||
self.assertIn("black charro suit", segmentation)
|
||||
self.assertIn("electric guitar", segmentation)
|
||||
self.assertLessEqual(len(segmentation.split(",")), 6)
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_efficient_blueprint_errors(normalized, self.schema),
|
||||
[],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalized["performer"]["positive_prompt"],
|
||||
value["performer"]["positive_prompt"],
|
||||
)
|
||||
|
||||
def test_efficient_planner_is_registered_with_safe_widget_contract(self) -> None:
|
||||
self.assertIs(
|
||||
self.nodes.NODE_CLASS_MAPPINGS["DiffusionGemmaSplatStageEfficientPlanner"],
|
||||
self.nodes.DiffusionGemmaSplatStageEfficientPlanner,
|
||||
)
|
||||
required = self.nodes.DiffusionGemmaSplatStageEfficientPlanner.INPUT_TYPES()[
|
||||
"required"
|
||||
]
|
||||
self.assertEqual(required["max_new_tokens"][1]["default"], 3072)
|
||||
self.assertGreaterEqual(required["max_new_tokens"][1]["min"], 1024)
|
||||
self.assertNotIn("duration_seconds", required)
|
||||
|
||||
|
||||
class ExportedNodeSurfaceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.nodes = load_nodes_module()
|
||||
|
||||
def test_export_map_has_seven_director_and_two_experimental_nodes(self) -> None:
|
||||
expected = DIRECTOR_NODE_IDS | EXPERIMENTAL_NODE_IDS
|
||||
self.assertEqual(set(self.nodes.NODE_CLASS_MAPPINGS), expected)
|
||||
self.assertEqual(set(self.nodes.NODE_DISPLAY_NAME_MAPPINGS), expected)
|
||||
|
||||
def test_experimental_planners_are_visibly_separate(self) -> None:
|
||||
for node_id in DIRECTOR_NODE_IDS:
|
||||
self.assertEqual(
|
||||
self.nodes.NODE_CLASS_MAPPINGS[node_id].CATEGORY,
|
||||
self.nodes.CATEGORY,
|
||||
)
|
||||
for node_id in EXPERIMENTAL_NODE_IDS:
|
||||
self.assertEqual(
|
||||
self.nodes.NODE_CLASS_MAPPINGS[node_id].CATEGORY,
|
||||
self.nodes.EXPERIMENTAL_MOTION_CATEGORY,
|
||||
)
|
||||
self.assertTrue(
|
||||
self.nodes.NODE_DISPLAY_NAME_MAPPINGS[node_id].endswith("(Experimental)")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user