Add measured-audio, timed-lyrics, H3 lane planning, identity relay, upload-song routing, model-specific camera contracts, LTX long-horizon planning, workflow migrations, the validated V6 example, regression coverage, and an Unreleased changelog.
290 lines
12 KiB
Python
290 lines
12 KiB
Python
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]
|
|
|
|
|
|
def load_nodes_module():
|
|
if str(COMFY_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(COMFY_ROOT))
|
|
package_name = f"diffusiongemma_model_targets_{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 ModelTargetProfileNodeTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.nodes = load_nodes_module()
|
|
|
|
def test_each_model_node_exposes_only_relevant_controls(self) -> None:
|
|
ltx = self.nodes.DiffusionGemmaLTX25TargetProfile.INPUT_TYPES()
|
|
self.assertEqual(
|
|
list(ltx["required"]),
|
|
[
|
|
"generation_mode",
|
|
"target_duration_seconds",
|
|
"style_guidance",
|
|
"audio_mode",
|
|
"audio_guidance",
|
|
"negative_prompt_mode",
|
|
"negative_prompt_guidance",
|
|
"long_horizon_mode",
|
|
],
|
|
)
|
|
self.assertEqual(list(ltx["optional"]), ["camera_capability"])
|
|
camera_choices, camera_config = ltx["optional"]["camera_capability"]
|
|
self.assertEqual(
|
|
camera_choices,
|
|
["Stable / base model", "Advanced / controlled camera"],
|
|
)
|
|
self.assertEqual(camera_config["default"], "Stable / base model")
|
|
|
|
h3 = self.nodes.DiffusionGemmaMiniMaxH3TargetProfile.INPUT_TYPES()
|
|
self.assertEqual(
|
|
list(h3["required"]),
|
|
[
|
|
"generation_mode",
|
|
"target_duration_seconds",
|
|
"audio_mode",
|
|
"audio_guidance",
|
|
"shot_count",
|
|
"custom_shot_count",
|
|
"dialogue_mode",
|
|
"dialogue_line_count",
|
|
"dialogue_guidance",
|
|
"negative_prompt_mode",
|
|
"negative_prompt_guidance",
|
|
],
|
|
)
|
|
self.assertEqual(list(h3["optional"]), ["shot_count_override"])
|
|
override_type, override_config = h3["optional"]["shot_count_override"]
|
|
self.assertEqual(override_type, "INT")
|
|
self.assertEqual(override_config["default"], 0)
|
|
self.assertIn("native [Shot N]", override_config["tooltip"])
|
|
self.assertIn("never connect a Project Master generation-lane count", override_config["tooltip"])
|
|
|
|
ideogram = self.nodes.DiffusionGemmaIdeogram4TargetProfile.INPUT_TYPES()
|
|
self.assertEqual(
|
|
list(ideogram["required"]),
|
|
[
|
|
"aspect_ratio",
|
|
"render_style",
|
|
"exact_text",
|
|
"json_output",
|
|
"negative_prompt_mode",
|
|
"negative_prompt_guidance",
|
|
],
|
|
)
|
|
self.assertNotIn("optional", ideogram)
|
|
self.assertNotIn("audio_mode", ideogram["required"])
|
|
self.assertNotIn("audio_guidance", ideogram["required"])
|
|
self.assertNotIn("target_duration_seconds", ideogram["required"])
|
|
|
|
def test_ltx_node_builds_the_same_canonical_config_as_the_legacy_surface(self) -> None:
|
|
config, payload_json = self.nodes.DiffusionGemmaLTX25TargetProfile().build(
|
|
generation_mode="Image to video",
|
|
target_duration_seconds=8.0,
|
|
style_guidance="restrained live-action thriller",
|
|
audio_mode="explicit_sound_design",
|
|
audio_guidance="dry footsteps and one exact off-screen line",
|
|
negative_prompt_mode="custom",
|
|
negative_prompt_guidance="no captions",
|
|
)
|
|
legacy, legacy_json = self.nodes.DiffusionGemmaTargetProfile().build(
|
|
target_profile="ltx",
|
|
audio_mode="explicit_sound_design",
|
|
audio_guidance="dry footsteps and one exact off-screen line",
|
|
target_duration_seconds=8.0,
|
|
ltx_style="restrained live-action thriller",
|
|
ideogram_aspect_ratio="1:1",
|
|
ideogram_render_style="",
|
|
ideogram_exact_text="",
|
|
ideogram_json_output=True,
|
|
negative_prompt_mode="custom",
|
|
negative_prompt_guidance="no captions",
|
|
ltx_generation_mode="Image to video",
|
|
)
|
|
self.assertEqual(config, legacy)
|
|
self.assertEqual(json.loads(payload_json), json.loads(legacy_json))
|
|
self.assertEqual(config.target_profile, "ltx")
|
|
self.assertEqual(config.ltx_generation_mode, "image_to_video")
|
|
self.assertEqual(config.ltx_camera_capability, "stable")
|
|
|
|
def test_h3_node_preserves_audio_shot_dialogue_and_negative_controls(self) -> None:
|
|
config, payload_json = self.nodes.DiffusionGemmaMiniMaxH3TargetProfile().build(
|
|
generation_mode="ref2va",
|
|
target_duration_seconds=20.0,
|
|
audio_mode="explicit_sound_design",
|
|
audio_guidance="wind and measured footsteps",
|
|
shot_count="custom",
|
|
custom_shot_count=17,
|
|
dialogue_mode="required",
|
|
dialogue_line_count=3,
|
|
dialogue_guidance="English; protagonist S1; three terse lines",
|
|
negative_prompt_mode="custom",
|
|
negative_prompt_guidance="no subtitles",
|
|
)
|
|
payload = json.loads(payload_json)
|
|
self.assertEqual(config.target_profile, "minimax_h3")
|
|
self.assertEqual(config.minimax_h3_mode, "ref2va")
|
|
self.assertEqual(config.minimax_h3_shot_count, "17")
|
|
self.assertEqual(config.minimax_h3_dialogue_mode, "required")
|
|
self.assertEqual(config.minimax_h3_dialogue_line_count, 3)
|
|
self.assertEqual(payload["negative_prompt_guidance"], "no subtitles")
|
|
|
|
overridden, overridden_json = self.nodes.DiffusionGemmaMiniMaxH3TargetProfile().build(
|
|
generation_mode="ref2va",
|
|
target_duration_seconds=25.0,
|
|
audio_mode="auto_scene_audio",
|
|
audio_guidance="locked soundtrack",
|
|
shot_count="auto",
|
|
custom_shot_count=12,
|
|
dialogue_mode="off",
|
|
dialogue_line_count=2,
|
|
dialogue_guidance="",
|
|
negative_prompt_mode="auto",
|
|
negative_prompt_guidance="",
|
|
shot_count_override=2,
|
|
)
|
|
self.assertEqual(overridden.minimax_h3_shot_count, "2")
|
|
self.assertEqual(json.loads(overridden_json)["minimax_h3_shot_count"], "2")
|
|
|
|
with self.assertRaisesRegex(ValueError, "conflicts with audio_mode=visual_only"):
|
|
self.nodes.DiffusionGemmaMiniMaxH3TargetProfile().build(
|
|
generation_mode="t2va",
|
|
target_duration_seconds=5.0,
|
|
audio_mode="visual_only",
|
|
audio_guidance="",
|
|
shot_count="auto",
|
|
custom_shot_count=12,
|
|
dialogue_mode="required",
|
|
dialogue_line_count=1,
|
|
dialogue_guidance="",
|
|
negative_prompt_mode="auto",
|
|
negative_prompt_guidance="",
|
|
)
|
|
|
|
def test_ideogram_node_has_visual_defaults_and_no_hidden_audio_request(self) -> None:
|
|
config, payload_json = self.nodes.DiffusionGemmaIdeogram4TargetProfile().build(
|
|
aspect_ratio="4:3",
|
|
render_style="editorial poster illustration",
|
|
exact_text="DIFFUSION GEMMA",
|
|
json_output=True,
|
|
negative_prompt_mode="custom",
|
|
negative_prompt_guidance="no extra text or logos",
|
|
)
|
|
payload = json.loads(payload_json)
|
|
self.assertEqual(config.target_profile, "ideogram4")
|
|
self.assertEqual(config.audio_mode, "visual_only")
|
|
self.assertEqual(config.audio_guidance, "")
|
|
self.assertEqual(config.target_duration_seconds, 0.0)
|
|
self.assertEqual(config.ideogram_aspect_ratio, "4:3")
|
|
self.assertEqual(config.ideogram_exact_text, "DIFFUSION GEMMA")
|
|
self.assertEqual(payload["ideogram_render_style"], "editorial poster illustration")
|
|
|
|
def test_dedicated_nodes_share_the_downstream_type_and_legacy_stays_loadable(self) -> None:
|
|
node_ids = {
|
|
"DiffusionGemmaLTX25TargetProfile",
|
|
"DiffusionGemmaMiniMaxH3TargetProfile",
|
|
"DiffusionGemmaIdeogram4TargetProfile",
|
|
}
|
|
for node_id in node_ids:
|
|
with self.subTest(node_id=node_id):
|
|
node_class = self.nodes.NODE_CLASS_MAPPINGS[node_id]
|
|
self.assertEqual(node_class.RETURN_TYPES, (self.nodes.TARGET_PROFILE_TYPE, "STRING"))
|
|
self.assertEqual(
|
|
node_class.RETURN_NAMES,
|
|
("target_profile_config", "target_profile_json"),
|
|
)
|
|
self.assertIn(node_id, self.nodes.NODE_DISPLAY_NAME_MAPPINGS)
|
|
|
|
self.assertIn("DiffusionGemmaTargetProfile", self.nodes.NODE_CLASS_MAPPINGS)
|
|
self.assertTrue(self.nodes.DiffusionGemmaTargetProfile.DEPRECATED)
|
|
self.assertIn("Legacy", self.nodes.NODE_DISPLAY_NAME_MAPPINGS["DiffusionGemmaTargetProfile"])
|
|
|
|
def test_ideogram_resolution_ignores_splitter_widget_override(self) -> None:
|
|
context = self.nodes.GemmaContext()
|
|
target = self.nodes._target_profile_config_to_dict(
|
|
self.nodes.TargetProfileConfig(
|
|
target_profile="ideogram4",
|
|
ideogram_aspect_ratio="4:3",
|
|
)
|
|
)
|
|
|
|
resolved = self.nodes._splitter_resolution_aspect_ratio(
|
|
"16:9 (Widescreen)",
|
|
context,
|
|
target,
|
|
"4:3",
|
|
)
|
|
|
|
self.assertEqual(resolved, ("4:3", "ideogram_target_profile"))
|
|
|
|
def test_h3_auto_resolution_does_not_follow_reference_media_dimensions(self) -> None:
|
|
context = self.nodes.GemmaContext(
|
|
media_metadata={
|
|
"width": 1920,
|
|
"height": 1080,
|
|
"ltx_first_frame_width": 1080,
|
|
"ltx_first_frame_height": 1920,
|
|
}
|
|
)
|
|
target = self.nodes._target_profile_config_to_dict(
|
|
self.nodes.TargetProfileConfig(target_profile="minimax_h3")
|
|
)
|
|
|
|
resolved = self.nodes._splitter_resolution_aspect_ratio(
|
|
self.nodes.RESOLUTION_SELECTOR_AUTO,
|
|
context,
|
|
target,
|
|
"4:3",
|
|
)
|
|
|
|
self.assertEqual(resolved, ("4:3", "legacy_target_profile"))
|
|
|
|
def test_ltx_auto_aliases_and_invalid_values_do_not_silently_select_square(self) -> None:
|
|
context = self.nodes.GemmaContext()
|
|
target = self.nodes._target_profile_config_to_dict(
|
|
self.nodes.TargetProfileConfig(target_profile="ltx")
|
|
)
|
|
|
|
for requested in (
|
|
"auto",
|
|
"AUTO (TARGET/SOURCE)",
|
|
"not-a-resolution",
|
|
):
|
|
with self.subTest(requested=requested):
|
|
aspect_ratio, source = self.nodes._splitter_resolution_aspect_ratio(
|
|
requested,
|
|
context,
|
|
target,
|
|
"1:1",
|
|
)
|
|
self.assertEqual((aspect_ratio, source), ("16:9", "ltx_default"))
|
|
self.assertEqual(
|
|
self.nodes._resolution_selector_dimensions(aspect_ratio, 0.5, 32),
|
|
(960, 544),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|