852 lines
36 KiB
Python
852 lines
36 KiB
Python
import importlib
|
|
import inspect
|
|
import sys
|
|
import types
|
|
import unittest
|
|
|
|
|
|
class DumasH3LongVideosHelperTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls._saved_modules = {
|
|
name: sys.modules.get(name)
|
|
for name in (
|
|
"torch",
|
|
"nodes",
|
|
"comfy",
|
|
"comfy.utils",
|
|
"comfy.samplers",
|
|
"comfy.nested_tensor",
|
|
"comfy.model_management",
|
|
"node_helpers",
|
|
"numpy",
|
|
"PIL",
|
|
"PIL.Image",
|
|
"folder_paths",
|
|
"dumas_image_nodes",
|
|
"dumas_h3_longvideos",
|
|
)
|
|
}
|
|
|
|
fake_torch = types.SimpleNamespace(
|
|
cuda=types.SimpleNamespace(OutOfMemoryError=RuntimeError),
|
|
float32="float32",
|
|
)
|
|
fake_numpy = types.SimpleNamespace(
|
|
clip=lambda array, _low, _high: array,
|
|
uint8="uint8",
|
|
)
|
|
fake_pil_image_module = types.SimpleNamespace(fromarray=lambda _array: None)
|
|
fake_pil_module = types.SimpleNamespace(Image=fake_pil_image_module)
|
|
fake_folder_paths = types.SimpleNamespace(
|
|
get_temp_directory=lambda: "/tmp",
|
|
get_output_directory=lambda: "/tmp",
|
|
get_save_image_path=lambda prefix, _out, _width, _height: ("/tmp", prefix, 1, "", prefix),
|
|
)
|
|
fake_nodes = types.SimpleNamespace(common_ksampler=lambda *args, **kwargs: ({},))
|
|
fake_comfy_samplers = types.SimpleNamespace(
|
|
KSampler=types.SimpleNamespace(SAMPLERS=("res_multistep",), SCHEDULERS=("simple",))
|
|
)
|
|
fake_comfy_utils = types.SimpleNamespace(ProgressBar=lambda total: None)
|
|
fake_mm = types.SimpleNamespace(
|
|
current_loaded_models=[],
|
|
free_memory=lambda *args, **kwargs: None,
|
|
get_torch_device=lambda: "cpu",
|
|
soft_empty_cache=lambda *args, **kwargs: None,
|
|
unload_all_models=lambda *args, **kwargs: None,
|
|
get_free_memory=lambda *args, **kwargs: 0,
|
|
get_total_memory=lambda *args, **kwargs: 0,
|
|
)
|
|
fake_comfy = types.SimpleNamespace(
|
|
utils=fake_comfy_utils,
|
|
samplers=fake_comfy_samplers,
|
|
nested_tensor=types.SimpleNamespace(),
|
|
model_management=fake_mm,
|
|
)
|
|
|
|
sys.modules["torch"] = fake_torch
|
|
sys.modules["numpy"] = fake_numpy
|
|
sys.modules["PIL"] = fake_pil_module
|
|
sys.modules["PIL.Image"] = fake_pil_image_module
|
|
sys.modules["folder_paths"] = fake_folder_paths
|
|
sys.modules["nodes"] = fake_nodes
|
|
sys.modules["comfy"] = fake_comfy
|
|
sys.modules["comfy.utils"] = fake_comfy_utils
|
|
sys.modules["comfy.samplers"] = fake_comfy_samplers
|
|
sys.modules["comfy.nested_tensor"] = fake_comfy.nested_tensor
|
|
sys.modules["comfy.model_management"] = fake_mm
|
|
sys.modules["node_helpers"] = types.SimpleNamespace()
|
|
|
|
cls.image_module = importlib.import_module("dumas_image_nodes")
|
|
cls.module = importlib.import_module("dumas_h3_longvideos")
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
for name, module in cls._saved_modules.items():
|
|
if module is None:
|
|
sys.modules.pop(name, None)
|
|
else:
|
|
sys.modules[name] = module
|
|
|
|
def test_extract_wardrobe_is_cached(self):
|
|
fn = self.module.extract_wardrobe
|
|
fn.cache_clear()
|
|
|
|
beat = "walks forward\nwardrobe: red jacket, grey shorts\nlooks back"
|
|
self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts"))
|
|
self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts"))
|
|
self.assertGreater(fn.cache_info().hits, 0)
|
|
|
|
def test_dialogue_helpers_keep_existing_outputs_and_cache(self):
|
|
spans_cache = self.module._dialogue_spans_cached
|
|
sec_fn = self.module.dialogue_seconds
|
|
words_fn = self.module.dialogue_words
|
|
|
|
spans_cache.cache_clear()
|
|
sec_fn.cache_clear()
|
|
words_fn.cache_clear()
|
|
|
|
beat = 'Mara says, "Open it now." Jon replies, "Do it."'
|
|
self.assertEqual(self.module.dialogue_spans(beat), [3, 2])
|
|
self.assertEqual(words_fn(beat), 5)
|
|
self.assertAlmostEqual(sec_fn(beat), 3.5)
|
|
self.assertAlmostEqual(sec_fn(beat, pad=False), 2.5)
|
|
|
|
self.module.dialogue_spans(beat)
|
|
sec_fn(beat)
|
|
words_fn(beat)
|
|
|
|
self.assertGreater(spans_cache.cache_info().hits, 0)
|
|
self.assertGreater(sec_fn.cache_info().hits, 0)
|
|
self.assertGreater(words_fn.cache_info().hits, 0)
|
|
|
|
def test_directive_and_estimate_helpers_are_cached(self):
|
|
directive_fn = self.module.beat_seconds_directive
|
|
estimate_fn = self.module.estimate_beat_seconds
|
|
action_fn = self.module.action_clauses
|
|
|
|
directive_fn.cache_clear()
|
|
estimate_fn.cache_clear()
|
|
action_fn.cache_clear()
|
|
|
|
beat = 'seconds: 7.5\nShe opens the hatch and climbs inside.'
|
|
self.assertEqual(directive_fn(beat), 7.5)
|
|
self.assertEqual(action_fn(beat), 2)
|
|
self.assertAlmostEqual(estimate_fn(beat), 7.0)
|
|
|
|
directive_fn(beat)
|
|
action_fn(beat)
|
|
estimate_fn(beat)
|
|
|
|
self.assertGreater(directive_fn.cache_info().hits, 0)
|
|
self.assertGreater(action_fn.cache_info().hits, 0)
|
|
self.assertGreater(estimate_fn.cache_info().hits, 0)
|
|
|
|
def test_per_shot_directive_helpers_parse_new_controls(self):
|
|
beat = (
|
|
"ref_mode: every shot + handoff ref\n"
|
|
"ref_noise_aug: 0.87\n"
|
|
"continuity: keyframe carry\n"
|
|
"The courier waits under the sign."
|
|
)
|
|
|
|
self.assertEqual(
|
|
self.module.beat_ref_mode_directive(beat),
|
|
"every shot + handoff ref",
|
|
)
|
|
self.assertEqual(self.module.beat_ref_noise_aug_directive(beat), 0.87)
|
|
self.assertEqual(
|
|
self.module.beat_continuity_directive(beat),
|
|
"keyframe carry",
|
|
)
|
|
|
|
def test_detail_pass_refines_video_but_preserves_audio(self):
|
|
class FakeTensor:
|
|
def __init__(self, name):
|
|
self.name = name
|
|
|
|
def detach(self):
|
|
return self
|
|
|
|
def to(self, *args, **kwargs):
|
|
return self
|
|
|
|
class FakeNestedTensor:
|
|
def __init__(self, parts):
|
|
self._parts = tuple(parts)
|
|
self.is_nested = True
|
|
|
|
def unbind(self):
|
|
return self._parts
|
|
|
|
calls = []
|
|
first_out = {"samples": FakeNestedTensor((FakeTensor("v1"), FakeTensor("a1")))}
|
|
second_out = {"samples": FakeNestedTensor((FakeTensor("v2"), FakeTensor("a2")))}
|
|
|
|
original_common_ksampler = self.module.nodes.common_ksampler
|
|
original_build = self.module._build_shot_conditioning
|
|
original_evict = self.module._evict_all_but
|
|
original_decode_video = self.module._decode_video
|
|
original_decode_audio = self.module._decode_audio
|
|
original_cleanup = self.module._deep_cleanup
|
|
original_nested = getattr(self.module.comfy.nested_tensor, "NestedTensor", None)
|
|
try:
|
|
self.module.comfy.nested_tensor.NestedTensor = FakeNestedTensor
|
|
|
|
def common_ksampler(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
return (first_out if len(calls) == 1 else second_out,)
|
|
|
|
self.module.nodes.common_ksampler = common_ksampler
|
|
self.module._build_shot_conditioning = lambda *_args, **_kwargs: (
|
|
"cond",
|
|
{"samples": FakeNestedTensor((FakeTensor("basev"), FakeTensor("basea")))},
|
|
)
|
|
self.module._evict_all_but = lambda *_args, **_kwargs: None
|
|
self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent
|
|
self.module._decode_audio = lambda _vae, out_latent: out_latent
|
|
self.module._deep_cleanup = lambda: None
|
|
|
|
result = self.module.H3LongVideos()._render(
|
|
model=object(),
|
|
clip=types.SimpleNamespace(
|
|
tokenize=lambda text, **kwargs: text,
|
|
encode_from_tokens_scheduled=lambda tokens: tokens,
|
|
),
|
|
vae=object(),
|
|
audio_vae=object(),
|
|
negative="negative",
|
|
prompt="beat",
|
|
w=128,
|
|
h=64,
|
|
ln=24,
|
|
fps=24,
|
|
tiled=False,
|
|
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
|
|
handoff=None,
|
|
detail_pass=True,
|
|
detail_sampler_name="euler",
|
|
detail_scheduler="beta",
|
|
detail_steps=5,
|
|
detail_denoise=0.4,
|
|
)
|
|
|
|
self.assertEqual(len(calls), 2)
|
|
self.assertIsNot(calls[1][0][8], first_out)
|
|
self.assertIs(calls[1][0][8]["samples"], first_out["samples"])
|
|
self.assertEqual(calls[1][0][4], "euler")
|
|
self.assertEqual(calls[1][0][5], "beta")
|
|
self.assertAlmostEqual(calls[1][1]["denoise"], 0.4)
|
|
self.assertEqual(result[1], first_out)
|
|
self.assertEqual(result[2][0].name, "v2")
|
|
self.assertEqual(result[2][1].name, "a1")
|
|
self.assertEqual(result[0]["samples"].unbind()[0].name, "v2")
|
|
self.assertEqual(result[0]["samples"].unbind()[-1].name, "a1")
|
|
finally:
|
|
self.module.nodes.common_ksampler = original_common_ksampler
|
|
self.module._build_shot_conditioning = original_build
|
|
self.module._evict_all_but = original_evict
|
|
self.module._decode_video = original_decode_video
|
|
self.module._decode_audio = original_decode_audio
|
|
self.module._deep_cleanup = original_cleanup
|
|
if original_nested is None:
|
|
delattr(self.module.comfy.nested_tensor, "NestedTensor")
|
|
else:
|
|
self.module.comfy.nested_tensor.NestedTensor = original_nested
|
|
|
|
def test_detail_pass_treats_falsey_strings_as_disabled(self):
|
|
calls = []
|
|
original_common_ksampler = self.module.nodes.common_ksampler
|
|
original_build = self.module._build_shot_conditioning
|
|
original_evict = self.module._evict_all_but
|
|
original_decode_video = self.module._decode_video
|
|
original_decode_audio = self.module._decode_audio
|
|
original_cleanup = self.module._deep_cleanup
|
|
try:
|
|
self.module.nodes.common_ksampler = lambda *args, **kwargs: (calls.append((args, kwargs)) or {"samples": "latent"},)
|
|
self.module._build_shot_conditioning = lambda *_args, **_kwargs: ("cond", {"samples": "base"})
|
|
self.module._evict_all_but = lambda *_args, **_kwargs: None
|
|
self.module._decode_video = lambda _vae, out_latent, *_args, **_kwargs: out_latent
|
|
self.module._decode_audio = lambda _vae, out_latent: out_latent
|
|
self.module._deep_cleanup = lambda: None
|
|
|
|
self.module.H3LongVideos()._render(
|
|
model=object(),
|
|
clip=object(),
|
|
vae=object(),
|
|
audio_vae=object(),
|
|
negative="negative",
|
|
prompt="beat",
|
|
w=128,
|
|
h=64,
|
|
ln=24,
|
|
fps=24,
|
|
tiled=False,
|
|
sa=(123, 20, 1.0, "res_multistep", "simple", 1.0),
|
|
handoff=None,
|
|
detail_pass="false",
|
|
)
|
|
|
|
self.assertEqual(len(calls), 1)
|
|
finally:
|
|
self.module.nodes.common_ksampler = original_common_ksampler
|
|
self.module._build_shot_conditioning = original_build
|
|
self.module._evict_all_but = original_evict
|
|
self.module._decode_video = original_decode_video
|
|
self.module._decode_audio = original_decode_audio
|
|
self.module._deep_cleanup = original_cleanup
|
|
|
|
def test_distribute_generations_canonicalizes_per_shot_audio_and_anchor_directives(self):
|
|
generations = self.module.distribute_generations(
|
|
"",
|
|
[
|
|
"anchor_add: harsh sodium spill, wet asphalt reflections\n"
|
|
"soundscape: distant traffic hiss, loose sign rattle\n"
|
|
"music: low pulsing synth tension\n"
|
|
"continuity: hard cut\n"
|
|
"ref_mode: every shot\n"
|
|
"ref_noise_aug: 0.88\n"
|
|
"A courier waits under the streetlight."
|
|
],
|
|
"global rain",
|
|
"global score",
|
|
)
|
|
|
|
block = generations[0]
|
|
self.assertIn("harsh sodium spill, wet asphalt reflections", block)
|
|
self.assertIn("overall_soundscape: distant traffic hiss, loose sign rattle", block)
|
|
self.assertIn("non_diegetic_music: low pulsing synth tension", block)
|
|
self.assertNotIn("\nsoundscape:", block)
|
|
self.assertNotIn("\nmusic:", block)
|
|
self.assertNotIn("\ncontinuity:", block)
|
|
self.assertNotIn("\nref_mode:", block)
|
|
self.assertNotIn("\nref_noise_aug:", block)
|
|
self.assertNotIn("\nanchor_add:", block)
|
|
|
|
def test_has_speech_cache_respects_written_text_filter(self):
|
|
fn = self.module.has_speech
|
|
fn.cache_clear()
|
|
|
|
written = 'She reads the sign marked "EXIT" and keeps walking.'
|
|
spoken = 'She says, "Exit now." and points to the door.'
|
|
self.assertFalse(fn(written))
|
|
self.assertTrue(fn(spoken))
|
|
fn(written)
|
|
fn(spoken)
|
|
self.assertGreaterEqual(fn.cache_info().hits, 2)
|
|
|
|
def test_resolve_tagged_refs_preserves_sparse_socket_numbers(self):
|
|
refs = [
|
|
None,
|
|
{"kind": "character", "image": "img2", "name": "Jon"},
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
{"kind": "character", "image": "img7", "name": "Mara"},
|
|
None,
|
|
{"kind": "location", "image": "img9", "name": "Watchtower"},
|
|
]
|
|
|
|
text, references, dropped = self.module.resolve_tagged_refs(
|
|
"Mara <Picture 7> turns toward Jon <Picture 2> while <Picture 9> watches.",
|
|
refs,
|
|
)
|
|
|
|
self.assertEqual(
|
|
text,
|
|
"Mara <Picture 2> turns toward Jon <Picture 1> while <Picture 3> watches.",
|
|
)
|
|
self.assertEqual(
|
|
[self.module._reference_image(ref) for ref in references],
|
|
["img2", "img7", "img9"],
|
|
)
|
|
self.assertEqual(dropped, [])
|
|
|
|
def test_resolve_tagged_refs_drops_unconnected_sparse_slots(self):
|
|
refs = [
|
|
None,
|
|
{"kind": "character", "image": "img2", "name": "Jon"},
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
{"kind": "character", "image": "img7", "name": "Mara"},
|
|
None,
|
|
None,
|
|
]
|
|
|
|
text, references, dropped = self.module.resolve_tagged_refs(
|
|
"Use <Picture 7>, skip <Picture 4>, keep <Picture 2>.",
|
|
refs,
|
|
)
|
|
|
|
self.assertEqual(text, "Use <Picture 2>, skip, keep <Picture 1>.")
|
|
self.assertEqual(
|
|
[self.module._reference_image(ref) for ref in references],
|
|
["img2", "img7"],
|
|
)
|
|
self.assertEqual(dropped, [4])
|
|
|
|
def test_resolve_prompt_refs_keeps_named_character_images_alongside_tagged_location(self):
|
|
refs = [
|
|
{"kind": "character", "image": "img1", "name": "Mara"},
|
|
{"kind": "character", "image": "img2", "name": "Jon"},
|
|
{"kind": "location", "image": "img3", "name": "Hangar"},
|
|
]
|
|
|
|
text, references, dropped = self.module.resolve_prompt_refs(
|
|
"Mara and Jon argue inside <Picture 3>.",
|
|
refs,
|
|
)
|
|
|
|
self.assertEqual(text, "Mara and Jon argue inside <Picture 1>.")
|
|
self.assertEqual(
|
|
[self.module._reference_image(ref) for ref in references],
|
|
["img3", "img1", "img2"],
|
|
)
|
|
self.assertEqual(dropped, [])
|
|
|
|
def test_shot_references_uses_all_connected_sparse_slots(self):
|
|
refs = [
|
|
None,
|
|
{"kind": "character", "image": "img2"},
|
|
None,
|
|
{"kind": "character", "image": "img4"},
|
|
None,
|
|
None,
|
|
{"kind": "location", "image": "img7"},
|
|
None,
|
|
None,
|
|
]
|
|
|
|
for mode, shot_index in (("auto ref2v", 0), ("first shot", 0), ("every shot", 3)):
|
|
self.assertEqual(
|
|
[self.module._reference_image(ref) for ref in self.module.shot_references(refs, mode, shot_index, None)],
|
|
["img2", "img4", "img7"],
|
|
)
|
|
|
|
def test_input_types_expose_nine_ref_slots(self):
|
|
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
|
|
|
for index in range(1, 10):
|
|
self.assertIn(f"ref_{index}", optional)
|
|
|
|
names = list(optional)
|
|
ref_positions = [names.index(f"ref_{index}") for index in range(1, 10)]
|
|
self.assertEqual(ref_positions, list(range(ref_positions[0], ref_positions[0] + 9)))
|
|
|
|
def test_input_types_keep_legacy_ref_image_aliases(self):
|
|
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
|
|
|
for index in range(1, 10):
|
|
self.assertIn(f"ref_image_{index}", optional)
|
|
|
|
def test_shot_seconds_tooltip_describes_ceiling_behavior(self):
|
|
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
|
tooltip = optional["shot_seconds"][1]["tooltip"]
|
|
|
|
self.assertIn("GLOBAL per-shot ceiling", tooltip)
|
|
self.assertIn("not 'force every beat to exactly this length'", tooltip)
|
|
self.assertIn("hard ~15.1s single-shot limit", tooltip)
|
|
|
|
def test_run_defaults_match_declared_ref_widget_defaults(self):
|
|
node = self.module.H3LongVideos()
|
|
optional = node.INPUT_TYPES()["optional"]
|
|
params = inspect.signature(node.run).parameters
|
|
|
|
self.assertEqual(params["ref_mode"].default, optional["ref_mode"][1]["default"])
|
|
self.assertEqual(params["ref_image_size"].default, optional["ref_image_size"][1]["default"])
|
|
self.assertEqual(params["ref_noise_aug"].default, optional["ref_noise_aug"][1]["default"])
|
|
|
|
def test_node_appends_per_beat_list_outputs_without_reordering_existing_slots(self):
|
|
self.assertEqual(
|
|
self.module.H3LongVideos.RETURN_NAMES[-2:],
|
|
("beat_images", "beat_audio"),
|
|
)
|
|
self.assertEqual(
|
|
self.module.H3LongVideos.OUTPUT_IS_LIST[-2:],
|
|
(True, True),
|
|
)
|
|
|
|
def test_reference_context_matches_character_names_and_location_tags(self):
|
|
refs = [
|
|
{
|
|
"kind": "character",
|
|
"image": "img1",
|
|
"name": "Mara",
|
|
"aliases": ["Xtina"],
|
|
"description": "silver hair",
|
|
"wardrobe": "red jacket",
|
|
"general": "wears a long grey coat",
|
|
"facts": {
|
|
"gender": "female",
|
|
"age": "41",
|
|
"nationality": "English",
|
|
"occupation": "a detective",
|
|
"height_feet": "6",
|
|
"height_inches": "2",
|
|
"accent": "English",
|
|
},
|
|
},
|
|
{"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor"},
|
|
]
|
|
|
|
context = self.module._reference_context_for_text(
|
|
"[Generation 1] Mara crosses the room toward <Picture 2>.",
|
|
refs,
|
|
)
|
|
|
|
self.assertIn("Character facts for Mara:", context)
|
|
self.assertIn("also known as Xtina", context)
|
|
self.assertIn("female", context)
|
|
self.assertIn("41 years old", context)
|
|
self.assertIn("English", context)
|
|
self.assertIn("works as a detective", context)
|
|
self.assertIn("6 foot 2 tall", context)
|
|
self.assertIn("speaks with a English accent", context)
|
|
self.assertIn("Persistent appearance for Mara: silver hair.", context)
|
|
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
|
self.assertIn("Character notes for Mara: wears a long grey coat.", context)
|
|
|
|
def test_run_uses_legacy_ref_image_inputs_when_new_slots_are_empty(self):
|
|
calls = {}
|
|
original_parse_resolution = self.module.parse_resolution
|
|
original_connected_refs = self.module._connected_refs
|
|
original_reference_character_memory = self.module._reference_character_memory
|
|
original_vram_gb = self.module.vram_gb
|
|
original_dit_resident_gb = self.module.dit_resident_gb
|
|
original_lora_overhead_gb = self.module.lora_overhead_gb
|
|
original_resolve_shot_frames = self.module.resolve_shot_frames
|
|
original_lora_active = self.module.lora_active
|
|
original_sla_pairing = self.module.sla_pairing
|
|
original_apply_h3_model_sampling = self.module.apply_h3_model_sampling
|
|
original_split_paragraphs = self.module.split_paragraphs
|
|
original_expand_beats = self.module.expand_beats
|
|
original_anchor_warnings = self.module.anchor_warnings
|
|
original_anchor_contributes_nothing = self.module.anchor_contributes_nothing
|
|
original_anchor_is_action_beat = self.module.anchor_is_action_beat
|
|
original_distribute_generations = self.module.distribute_generations
|
|
original_continuity_warnings = self.module.continuity_warnings
|
|
original_speech_flags = self.module.speech_flags
|
|
original_annotate_script_debug = self.module.annotate_script_debug
|
|
original_empty_av_latent = self.module._empty_av_latent
|
|
original_torch_zeros = getattr(self.module.torch, "zeros", None)
|
|
try:
|
|
self.module.torch.zeros = lambda shape: shape
|
|
self.module.parse_resolution = lambda _resolution: (640, 360)
|
|
self.module._connected_refs = lambda refs: [ref for ref in refs if ref is not None]
|
|
self.module._reference_character_memory = lambda refs: (calls.setdefault("refs", tuple(refs)), "")[1]
|
|
self.module.vram_gb = lambda: (0, 0)
|
|
self.module.dit_resident_gb = lambda _model: 0
|
|
self.module.lora_overhead_gb = lambda _model: 0
|
|
self.module.resolve_shot_frames = lambda *args, **kwargs: (53, "")
|
|
self.module.lora_active = lambda _model: False
|
|
self.module.sla_pairing = lambda *_args, **_kwargs: ("", False, "")
|
|
self.module.apply_h3_model_sampling = lambda model, *_args: (model, "")
|
|
self.module.split_paragraphs = lambda _prompt, _sep: ["Anchor.", "Beat."]
|
|
self.module.expand_beats = lambda beat_paras, _split: (list(beat_paras), "")
|
|
self.module.anchor_warnings = lambda _anchor: []
|
|
self.module.anchor_contributes_nothing = lambda *_args, **_kwargs: False
|
|
self.module.anchor_is_action_beat = lambda *_args, **_kwargs: False
|
|
self.module.distribute_generations = lambda _anchor, beats, *_args, **_kwargs: list(beats)
|
|
self.module.continuity_warnings = lambda _gens: []
|
|
self.module.speech_flags = lambda _beats: []
|
|
self.module.annotate_script_debug = lambda *_args, **_kwargs: "script"
|
|
self.module._empty_av_latent = lambda *_args, **_kwargs: ({"samples": "latent"}, 5)
|
|
|
|
clip = types.SimpleNamespace(
|
|
tokenize=lambda text, **kwargs: text,
|
|
encode_from_tokens_scheduled=lambda tokens: tokens,
|
|
)
|
|
|
|
result = self.module.H3LongVideos().run(
|
|
model=object(),
|
|
clip=clip,
|
|
vae=object(),
|
|
audio_vae=object(),
|
|
prompt="Anchor only.",
|
|
resolution="16:9",
|
|
steps=6,
|
|
cfg=1,
|
|
sampler_name="res_multistep",
|
|
scheduler="simple",
|
|
seed=1,
|
|
plan_only=True,
|
|
ref_image_1={"image": "legacy-1"},
|
|
ref_image_3={"image": "legacy-3"},
|
|
)
|
|
|
|
self.assertEqual(calls["refs"][0]["image"], "legacy-1")
|
|
self.assertIsNone(calls["refs"][1])
|
|
self.assertEqual(calls["refs"][2]["image"], "legacy-3")
|
|
self.assertEqual(result[2].count("ref2va: 2 reference image(s)"), 1)
|
|
finally:
|
|
self.module.parse_resolution = original_parse_resolution
|
|
self.module._connected_refs = original_connected_refs
|
|
self.module._reference_character_memory = original_reference_character_memory
|
|
self.module.vram_gb = original_vram_gb
|
|
self.module.dit_resident_gb = original_dit_resident_gb
|
|
self.module.lora_overhead_gb = original_lora_overhead_gb
|
|
self.module.resolve_shot_frames = original_resolve_shot_frames
|
|
self.module.lora_active = original_lora_active
|
|
self.module.sla_pairing = original_sla_pairing
|
|
self.module.apply_h3_model_sampling = original_apply_h3_model_sampling
|
|
self.module.split_paragraphs = original_split_paragraphs
|
|
self.module.expand_beats = original_expand_beats
|
|
self.module.anchor_warnings = original_anchor_warnings
|
|
self.module.anchor_contributes_nothing = original_anchor_contributes_nothing
|
|
self.module.anchor_is_action_beat = original_anchor_is_action_beat
|
|
self.module.distribute_generations = original_distribute_generations
|
|
self.module.continuity_warnings = original_continuity_warnings
|
|
self.module.speech_flags = original_speech_flags
|
|
self.module.annotate_script_debug = original_annotate_script_debug
|
|
self.module._empty_av_latent = original_empty_av_latent
|
|
if original_torch_zeros is None:
|
|
delattr(self.module.torch, "zeros")
|
|
else:
|
|
self.module.torch.zeros = original_torch_zeros
|
|
|
|
def test_reference_context_matches_tagged_character_without_name_in_text(self):
|
|
refs = [
|
|
{"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"},
|
|
]
|
|
|
|
context = self.module._reference_context_for_text(
|
|
"[Generation 1] <Picture 1> walks into the room.",
|
|
refs,
|
|
)
|
|
|
|
self.assertIn("Persistent appearance for Mara: silver hair.", context)
|
|
self.assertIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
|
|
|
def test_reference_context_can_skip_character_wardrobe_when_live_memory_is_explicit(self):
|
|
refs = [
|
|
{"kind": "character", "image": "img1", "name": "Mara", "description": "silver hair", "wardrobe": "red jacket"},
|
|
]
|
|
|
|
context = self.module._reference_context_for_text(
|
|
"[Generation 1] Mara walks into the room.",
|
|
refs,
|
|
include_character_wardrobe=False,
|
|
)
|
|
|
|
self.assertIn("Persistent appearance for Mara: silver hair.", context)
|
|
self.assertNotIn("Persistent wardrobe/style for Mara: red jacket.", context)
|
|
|
|
def test_reference_context_injects_immediately_after_generation_label(self):
|
|
block = (
|
|
"[Generation 1] Classic sitcom lighting and staging. "
|
|
"Duke walks into the room."
|
|
)
|
|
context = "Character facts for Duke: female, 25 years old."
|
|
|
|
result = self.module._inject_reference_context(block, context)
|
|
|
|
self.assertEqual(
|
|
result,
|
|
"[Generation 1] Character facts for Duke: female, 25 years old. "
|
|
"Classic sitcom lighting and staging. "
|
|
"Duke walks into the room.",
|
|
)
|
|
|
|
def test_reference_character_memory_uses_character_wardrobe_only(self):
|
|
refs = [
|
|
{"kind": "character", "image": "img1", "name": "Mara", "wardrobe": "red jacket, black boots"},
|
|
{"kind": "location", "image": "img2", "name": "Hangar", "description": "wet concrete floor", "wardrobe": "should be ignored"},
|
|
]
|
|
|
|
self.assertEqual(
|
|
self.module._reference_character_memory(refs),
|
|
"Mara = red jacket, black boots",
|
|
)
|
|
|
|
def test_ref_mode_defaults_are_ref2v_biased(self):
|
|
optional = self.module.H3LongVideos.INPUT_TYPES()["optional"]
|
|
|
|
self.assertEqual(optional["ref_mode"][1]["default"], "auto ref2v")
|
|
self.assertEqual(optional["ref_noise_aug"][1]["default"], 0.95)
|
|
|
|
def test_only_canonical_h3_long_videos_node_is_exposed(self):
|
|
self.assertEqual(
|
|
self.module.NODE_CLASS_MAPPINGS,
|
|
{"DumasH3LongVideos": self.module.H3LongVideos},
|
|
)
|
|
self.assertEqual(
|
|
self.module.NODE_DISPLAY_NAME_MAPPINGS,
|
|
{"DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)"},
|
|
)
|
|
|
|
def test_compose_persistent_does_not_expand_ambiguous_plural_to_full_cast(self):
|
|
active = self.module.parse_wardrobe(
|
|
"Maya = she, red jacket\n"
|
|
"Jon = he, navy overalls\n"
|
|
"Becca = she, green coat"
|
|
)
|
|
|
|
shot = self.module.compose_persistent(
|
|
"Both of them walk to the door.",
|
|
active,
|
|
"",
|
|
speaking=False,
|
|
)
|
|
|
|
self.assertEqual(shot, "Both of them walk to the door.")
|
|
|
|
def test_compose_persistent_keeps_two_person_plural_binding(self):
|
|
active = self.module.parse_wardrobe(
|
|
"Maya = she, red jacket\n"
|
|
"Jon = he, navy overalls"
|
|
)
|
|
|
|
shot = self.module.compose_persistent(
|
|
"They walk to the door.",
|
|
active,
|
|
"",
|
|
speaking=False,
|
|
)
|
|
|
|
self.assertIn("Maya (red jacket)", shot)
|
|
self.assertIn("Jon (navy overalls)", shot)
|
|
self.assertIn("They walk to the door.", shot)
|
|
|
|
def test_compose_persistent_all_three_characters_binds_full_cast(self):
|
|
active = self.module.parse_wardrobe(
|
|
"Maya = she, red jacket\n"
|
|
"Jon = he, navy overalls\n"
|
|
"Becca = she, green coat"
|
|
)
|
|
|
|
shot = self.module.compose_persistent(
|
|
"The three characters walk to the door.",
|
|
active,
|
|
"",
|
|
speaking=False,
|
|
)
|
|
|
|
self.assertIn("Maya (red jacket)", shot)
|
|
self.assertIn("Jon (navy overalls)", shot)
|
|
self.assertIn("Becca (green coat)", shot)
|
|
|
|
def test_plan_only_returns_joined_generations_on_script_socket_with_anchor_override(self):
|
|
module = self.module
|
|
node = module.H3LongVideos()
|
|
|
|
class _Clip:
|
|
def tokenize(self, text):
|
|
return text
|
|
|
|
def encode_from_tokens_scheduled(self, tokens):
|
|
return tokens
|
|
|
|
class _TorchStub:
|
|
@staticmethod
|
|
def zeros(shape):
|
|
return ("zeros", shape)
|
|
|
|
original_torch = module.torch
|
|
original_vram_gb = module.vram_gb
|
|
original_dit_resident_gb = module.dit_resident_gb
|
|
original_lora_overhead_gb = module.lora_overhead_gb
|
|
original_check_vae_wiring = module.check_vae_wiring
|
|
original_check_text_encoder = module.check_text_encoder
|
|
original_apply_h3_model_sampling = module.apply_h3_model_sampling
|
|
original_sla_pairing = module.sla_pairing
|
|
original_lora_hint_notes = module.lora_hint_notes
|
|
original_schedule_balance_note = module.schedule_balance_note
|
|
original_kernel_backend_note = module.kernel_backend_note
|
|
original_audio_scale_note = module.audio_scale_note
|
|
original_quant_accel_note = module.quant_accel_note
|
|
original_lora_active = module.lora_active
|
|
original_resolve_shot_frames = module.resolve_shot_frames
|
|
original_plan_beat_frames = module.plan_beat_frames
|
|
original_dialogue_fit_warnings = module.dialogue_fit_warnings
|
|
original_dialogue_filler_warnings = module.dialogue_filler_warnings
|
|
original_distribute_generations = module.distribute_generations
|
|
original_continuity_warnings = module.continuity_warnings
|
|
original_empty_av_latent = module._empty_av_latent
|
|
try:
|
|
module.torch = _TorchStub()
|
|
module.vram_gb = lambda: (0.0, 0.0)
|
|
module.dit_resident_gb = lambda _model: 0.0
|
|
module.lora_overhead_gb = lambda _model: 0.0
|
|
module.check_vae_wiring = lambda *_args, **_kwargs: None
|
|
module.check_text_encoder = lambda *_args, **_kwargs: None
|
|
module.apply_h3_model_sampling = lambda model, *_args, **_kwargs: (model, "")
|
|
module.sla_pairing = lambda *_args, **_kwargs: ("", False, "")
|
|
module.lora_hint_notes = lambda *_args, **_kwargs: []
|
|
module.schedule_balance_note = lambda *_args, **_kwargs: ""
|
|
module.kernel_backend_note = lambda *_args, **_kwargs: ""
|
|
module.audio_scale_note = lambda *_args, **_kwargs: ""
|
|
module.quant_accel_note = lambda *_args, **_kwargs: ""
|
|
module.lora_active = lambda _model: False
|
|
module.resolve_shot_frames = lambda *_args, **_kwargs: (73, "")
|
|
module.plan_beat_frames = lambda beats, fps, budget, per_beat=True: ([73] * len(beats), [])
|
|
module.dialogue_fit_warnings = lambda *_args, **_kwargs: []
|
|
module.dialogue_filler_warnings = lambda *_args, **_kwargs: []
|
|
module.distribute_generations = lambda anchor, beats, *_args, **_kwargs: [
|
|
f"[Generation 1] {anchor}. {beats[0]}",
|
|
f"[Generation 2] {anchor}. {beats[1]}{module.ANATOMY_STATE}",
|
|
]
|
|
module.continuity_warnings = lambda _gens: []
|
|
module._empty_av_latent = lambda *_args, **_kwargs: ({"samples": "latent"},)
|
|
|
|
result = node.run(
|
|
model=object(),
|
|
clip=_Clip(),
|
|
vae=object(),
|
|
audio_vae=object(),
|
|
prompt="Francine stands alone.\n\nFrancine and Frankie walk together.",
|
|
resolution="16:9",
|
|
steps=20,
|
|
cfg=1.0,
|
|
sampler_name="res_multistep",
|
|
scheduler="simple",
|
|
seed=1,
|
|
anchor_override="editorial room, soft practical lighting",
|
|
character_memory="Francine = white top\nFrankie = black jacket",
|
|
plan_only=True,
|
|
)
|
|
|
|
self.assertIn("# anatomy_guard: injected on shot(s) 2", result[3])
|
|
self.assertIn("# shot 1 refs: none", result[3])
|
|
self.assertIn("# shot 2 refs: none", result[3])
|
|
self.assertIn(
|
|
"[Generation 1] editorial room, soft practical lighting. Francine stands alone.",
|
|
result[3],
|
|
)
|
|
self.assertIn(
|
|
"[Generation 2] editorial room, soft practical lighting. Francine and Frankie walk together.",
|
|
result[3],
|
|
)
|
|
self.assertIn("2 beat(s)", result[2])
|
|
self.assertIn("2 shot(s)", result[2])
|
|
self.assertIn("ANATOMY -- guard injected on shot(s) 2", result[2])
|
|
self.assertEqual(result[-2:], ([], []))
|
|
finally:
|
|
module.torch = original_torch
|
|
module.vram_gb = original_vram_gb
|
|
module.dit_resident_gb = original_dit_resident_gb
|
|
module.lora_overhead_gb = original_lora_overhead_gb
|
|
module.check_vae_wiring = original_check_vae_wiring
|
|
module.check_text_encoder = original_check_text_encoder
|
|
module.apply_h3_model_sampling = original_apply_h3_model_sampling
|
|
module.sla_pairing = original_sla_pairing
|
|
module.lora_hint_notes = original_lora_hint_notes
|
|
module.schedule_balance_note = original_schedule_balance_note
|
|
module.kernel_backend_note = original_kernel_backend_note
|
|
module.audio_scale_note = original_audio_scale_note
|
|
module.quant_accel_note = original_quant_accel_note
|
|
module.lora_active = original_lora_active
|
|
module.resolve_shot_frames = original_resolve_shot_frames
|
|
module.plan_beat_frames = original_plan_beat_frames
|
|
module.dialogue_fit_warnings = original_dialogue_fit_warnings
|
|
module.dialogue_filler_warnings = original_dialogue_filler_warnings
|
|
module.distribute_generations = original_distribute_generations
|
|
module.continuity_warnings = original_continuity_warnings
|
|
module._empty_av_latent = original_empty_av_latent
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|