Refine H3 long videos defaults and aliases

This commit is contained in:
2026-08-26 15:20:45 +00:00
parent 18129327d9
commit 0b20381c5c
3 changed files with 168 additions and 63 deletions
+2
View File
@@ -38,8 +38,10 @@
- Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape` - Outputs: `images`, `audio`, `info`, `script`, `frames_per_shot`, `total_frames`, `shots`, `video_seconds`, `fps`, `fps_int`, `latent`, `soundscape`
- First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work. - First-pass Dumas port of the `MiniMax-H3-Longvideos` sampler, brought in as a local starting point for long-form H3 chaining work.
- Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline. - Keeps the upstream split-beats / handoff / ref-routing behavior close to source so future Dumas-specific improvements can be compared against a known baseline.
- Only the canonical `DumasH3LongVideos` node key is exposed now; the older FL2VA/REF2VA alias entries are no longer duplicated in the Add Node menu.
- Prompt `<Picture N>` tags now map to the actual ref socket numbers you wire, even with gaps such as only `ref_image_2` and `ref_image_7` connected. - Prompt `<Picture N>` tags now map to the actual ref socket numbers you wire, even with gaps such as only `ref_image_2` and `ref_image_7` connected.
- A connected H3 plan can now supply the current scenes 9-image bundle directly; any directly-wired `ref_image_*` socket overrides the same numbered plan slot. - A connected H3 plan can now supply the current scenes 9-image bundle directly; any directly-wired `ref_image_*` socket overrides the same numbered plan slot.
- The default ref2v bias is now stronger: `ref_mode` defaults to `auto ref2v` so untagged prompts condition every shot instead of only shot 1, and `ref_noise_aug` defaults to `0.95` rather than the upstream-literal `0.999`.
- `Dumas H3 Shot Length` - `Dumas H3 Shot Length`
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max` - Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
+95 -63
View File
@@ -1549,8 +1549,8 @@ def compose_persistent(body, active, anchor_id, removed=None, departed=None,
if speaking and silence_nonspeech and len(names) >= 2 and not unnamed: if speaking and silence_nonspeech and len(names) >= 2 and not unnamed:
speakers = _speakers_in(body, names) speakers = _speakers_in(body, names)
if speakers: if speakers:
bound_names = set(refs) or ( bound_names = set(refs) or (
{n for n in names} if _PLURAL_CAST.search(body) else set()) {n for n in names} if _plural_cast_matches_present(body, len(names)) else set())
if speakers & bound_names: if speakers & bound_names:
listeners = bound_names - speakers listeners = bound_names - speakers
@@ -1561,11 +1561,11 @@ def compose_persistent(body, active, anchor_id, removed=None, departed=None,
return speaking and n not in listeners return speaking and n not in listeners
roll_call = "" roll_call = ""
if not refs and len(names) > 1 and _PLURAL_CAST.search(body): if not refs and _plural_cast_matches_present(body, len(names)):
bits = [] bits = []
for n in names: for n in names:
desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n))) desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n)))
bits.append(f"{n} ({desc})" if desc else n) bits.append(f"{n} ({desc})" if desc else n)
roll_call = ((", ".join(bits[:-1]) + " and " + bits[-1]) roll_call = ((", ".join(bits[:-1]) + " and " + bits[-1])
+ (" are both in this shot." if len(bits) == 2 + (" are both in this shot." if len(bits) == 2
else " are all in this shot.")) else " are all in this shot."))
@@ -2313,9 +2313,43 @@ NO_VOICE_SPEECH_CLAUSE = ", no speech, no dialogue, no talking, no singing, no w
# often as to people -- "she steps out of them" is a garment, "light floods through # often as to people -- "she steps out of them" is a garment, "light floods through
# them" is a pair of doors -- and this fires only when nobody was bound by name or # them" is a pair of doors -- and this fires only when nobody was bound by name or
# singular pronoun, which is exactly the scenery-beat case that must stay empty. # singular pronoun, which is exactly the scenery-beat case that must stay empty.
_PLURAL_CAST = re.compile( _PLURAL_CAST = re.compile(
r"\b(?:they|themselves|both|each other|one another|" r"\b(?:they|themselves|both|each other|one another|"
r"the two of them|all of them)\b", re.I) r"the two of them|the two characters|the three characters|"
r"the four characters|all of them|all three|all four)\b", re.I)
_PLURAL_COUNT_PATTERNS = (
(re.compile(r"\b(?:both|each other|the two of them|the two characters)\b", re.I), 2),
(re.compile(r"\b(?:the three characters|all three)\b", re.I), 3),
(re.compile(r"\b(?:the four characters|all four)\b", re.I), 4),
(re.compile(r"\ball of them\b", re.I), "all"),
# Bare "they"/"themselves"/"one another" is only safe when exactly two tracked
# people are active; with three or more it is ambiguous and should not summon
# the whole cast into the shot.
(re.compile(r"\b(?:they|themselves|one another)\b", re.I), "ambiguous"),
)
def _plural_cast_matches_present(body, present_count):
"""Does this beat unambiguously refer to the whole currently-active cast?
Plural wording used to pull EVERY tracked character from character_memory into a
shot whenever the beat said "they" or "both of them". That is only safe when
the count implied by the words matches the active cast exactly. Otherwise the
plural is ambiguous and must not be expanded into a full roll-call."""
text = body or ""
present_count = max(0, int(present_count or 0))
if present_count < 2:
return False
for rx, target in _PLURAL_COUNT_PATTERNS:
if not rx.search(text):
continue
if target == "all":
return True
if target == "ambiguous":
return present_count == 2
return present_count == target
return False
def person_referenced(body, name, active): def person_referenced(body, name, active):
@@ -2335,7 +2369,7 @@ def person_referenced(body, name, active):
return False return False
def person_in_shot(body, name, active, departed=()): def person_in_shot(body, name, active, departed=()):
"""Is this person IN this shot -- by name, by a resolvable pronoun, or as part """Is this person IN this shot -- by name, by a resolvable pronoun, or as part
of a cast addressed in the plural? of a cast addressed in the plural?
@@ -2348,10 +2382,10 @@ def person_in_shot(body, name, active, departed=()):
the restraint clause (a plural beat dropped the physical constraint, so the the restraint clause (a plural beat dropped the physical constraint, so the
restraints appeared to break). Both are gated on this function now, so a third restraints appeared to break). Both are gated on this function now, so a third
caller cannot rediscover it.""" caller cannot rediscover it."""
if person_referenced(body, name, active): if person_referenced(body, name, active):
return True return True
present = [n for n in (active or {}) if n and n not in (departed or ())] present = [n for n in (active or {}) if n and n not in (departed or ())]
return len(present) > 1 and bool(_PLURAL_CAST.search(body or "")) return _plural_cast_matches_present(body, len(present))
def _subject_term(name, active): def _subject_term(name, active):
@@ -4074,9 +4108,13 @@ def shot_references(ref_list, ref_mode, shot_index, handoff):
mode contributes to the REFERENCE channel; they no longer describe a shot's whole mode contributes to the REFERENCE channel; they no longer describe a shot's whole
conditioning, and 'no handoff at all' is no longer a consequence of picking one: conditioning, and 'no handoff at all' is no longer a consequence of picking one:
'first shot' -- references establish the cast in shot 1; every later shot 'auto ref2v' -- use explicit prompt tags when they exist; otherwise carry
uses the last-frame handoff. Continuity is unbroken and the the references on every shot. This is the ref2v-biased
look propagates down the chain, but only through the frames. default: identity first, no need to tag a single-subject
chain by hand just to stop refs collapsing to shot 1.
'first shot' -- references establish the cast in shot 1; every later shot
uses the last-frame handoff. Continuity is unbroken and the
look propagates down the chain, but only through the frames.
'every shot' -- every shot is ref-conditioned. Strongest identity, and no 'every shot' -- every shot is ref-conditioned. Strongest identity, and no
handoff at all, so shots meet as CUTS rather than as one handoff at all, so shots meet as CUTS rather than as one
continuous take. continuous take.
@@ -4089,6 +4127,8 @@ def shot_references(ref_list, ref_mode, shot_index, handoff):
refs = _connected_refs(ref_list) refs = _connected_refs(ref_list)
if not refs: if not refs:
return [] return []
if ref_mode == "auto ref2v":
return list(refs)
if ref_mode == "first shot": if ref_mode == "first shot":
return list(refs) if shot_index == 0 else [] return list(refs) if shot_index == 0 else []
if ref_mode == "every shot": if ref_mode == "every shot":
@@ -5481,31 +5521,30 @@ class H3LongVideos:
"overlay_stroke": ("INT", {"default": 0, "min": 0, "max": 20, "overlay_stroke": ("INT", {"default": 0, "min": 0, "max": 20,
"tooltip": "Black outline thickness in pixels around the white text. 0 keeps it pure " "tooltip": "Black outline thickness in pixels around the white text. 0 keeps it pure "
"white as asked; 2-3 makes it survive a bright sky or a white wall."}), "white as asked; 2-3 makes it survive a bright sky or a white wall."}),
"ref_mode": (["where tagged", "first shot", "every shot", "every shot + handoff ref"], "ref_mode": (["auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"],
{"default": "where tagged", {"default": "auto ref2v",
"tooltip": "Which shots the ref_image inputs condition. A shot carries EITHER " "tooltip": "Which shots the ref_image inputs condition. 'auto ref2v' (default) is "
"references or the last-frame handoff, never both. 'where tagged' " "the reference-to-video bias: if the prompt uses <Picture N> tags, those "
"(default): write <Picture 1> in the beat where that character " "tags decide which shot gets which ref; if there are NO tags anywhere, the "
"appears and ONLY that shot gets the reference -- every other shot " "node conditions EVERY shot with the connected refs rather than collapsing "
"keeps its handoff. This is the precise option: the other modes go by " "them to shot 1. That is the better default for single-subject ref2v and "
"shot NUMBER and are blind to who is actually in the shot, so a " "for long chains where identity drift matters more than strict per-shot "
"character who first appears in shot 2 gets nothing while an empty " "routing. 'where tagged' keeps the old strict behavior, including the "
"establishing shot 1 gets a portrait pushed into it. Tags are " "first-shot fallback when no tags are found. Tags are renumbered per shot, "
"renumbered per shot, so <Picture 2> alone still resolves. With refs " "so <Picture 2> alone still resolves. 'first shot' / 'every shot' / "
"connected but no tags anywhere, falls back to first shot rather than " "'every shot + handoff ref' go purely by position. Ignored when no "
"silently doing nothing. 'first shot' / 'every shot' / 'every shot + " "ref_image is connected."}),
"handoff ref' go purely by position. Ignored when no ref_image is " "ref_noise_aug": ("FLOAT", {"default": 0.95, "min": 0.50, "max": 1.0, "step": 0.005,
"connected."}), "tooltip": "How CLEAN each reference is presented to the model. 0.999 (H3's own "
"ref_noise_aug": ("FLOAT", {"default": 0.999, "min": 0.50, "max": 1.0, "step": 0.005, "default) hands it a finished, noise-free image -- which invites the "
"tooltip": "How CLEAN each reference is presented to the model. 0.999 (H3's own " "model to REPRODUCE the reference in the opening frames instead of just "
"default) hands it a finished, noise-free image -- which invites the " "taking an identity from it. Lower values blend the condition with "
"model to REPRODUCE the reference in the opening frames instead of just " "noise and label it as approximate, so it informs the face without "
"taking an identity from it. Lower values blend the condition with " "being copied. 0.95 is the ref2v-biased default here; 0.999 keeps the "
"noise and label it as approximate, so it informs the face without " "upstream literal-reference behavior. Too low (below ~0.8) and the "
"being copied: try 0.95, then 0.90. Too low (below ~0.8) and the " "reference stops holding identity at all. Applies ONLY to "
"reference stops holding identity at all. Applies ONLY to " "ref-conditioned shots -- the last-frame handoff is never weakened, or "
"ref-conditioned shots -- the last-frame handoff is never weakened, or " "continuity would break."}),
"continuity would break."}),
"ref_image_size": (["match", "max"], {"default": "match", "ref_image_size": (["match", "max"], {"default": "match",
"tooltip": "How large each reference is encoded. 'match' scales it down to the " "tooltip": "How large each reference is encoded. 'match' scales it down to the "
"generation's pixel area -- a reference then costs about one frame per " "generation's pixel area -- a reference then costs about one frame per "
@@ -6111,15 +6150,17 @@ class H3LongVideos:
# prompts and falls back to first shot when nothing is tagged -- # prompts and falls back to first shot when nothing is tagged --
# reporting by ref_mode alone described shots the render never gave # reporting by ref_mode alone described shots the render never gave
# references to. # references to.
if ref_mode == "where tagged" and any(picture_tags(g) for g in gens): tagged_mode = ref_mode in ("where tagged", "auto ref2v")
if tagged_mode and any(picture_tags(g) for g in gens):
on = [n + 1 for n, g in enumerate(gens) if resolve_tagged_refs(g, ref_slots)[1]] on = [n + 1 for n, g in enumerate(gens) if resolve_tagged_refs(g, ref_slots)[1]]
how = "placed by <Picture N> tags" how = "placed by <Picture N> tags"
else: else:
mode_eff = "first shot" if ref_mode == "where tagged" else ref_mode mode_eff = ("every shot" if ref_mode == "auto ref2v"
else "first shot" if ref_mode == "where tagged" else ref_mode)
on = [n + 1 for n in range(shots) on = [n + 1 for n in range(shots)
if shot_references(ref_slots, mode_eff, n, 1 if n else None)] if shot_references(ref_slots, mode_eff, n, 1 if n else None)]
how = (f"ref_mode '{mode_eff}'" how = (f"ref_mode '{mode_eff}'"
+ (" -- no tags found anywhere" if ref_mode == "where tagged" else "")) + (" -- no tags found anywhere" if tagged_mode else ""))
src = [] src = []
if direct_ref_count: if direct_ref_count:
src.append(f"{direct_ref_count} direct") src.append(f"{direct_ref_count} direct")
@@ -6169,10 +6210,11 @@ class H3LongVideos:
# 'where tagged' reads the prompt instead of counting shots. If references are # 'where tagged' reads the prompt instead of counting shots. If references are
# connected but nothing is tagged anywhere, fall back to first-shot placement # connected but nothing is tagged anywhere, fall back to first-shot placement
# rather than silently conditioning nothing at all. # rather than silently conditioning nothing at all.
tag_driven = bool(connected_ref_count) and ref_mode == "where tagged" and any( tag_mode = ref_mode in ("where tagged", "auto ref2v")
tag_driven = bool(connected_ref_count) and tag_mode and any(
picture_tags(g) for g in gens) picture_tags(g) for g in gens)
if connected_ref_count and ref_mode == "where tagged" and not tag_driven: if connected_ref_count and tag_mode and not tag_driven:
ref_mode = "first shot" ref_mode = "every shot" if ref_mode == "auto ref2v" else "first shot"
if cleanup_between_shots: if cleanup_between_shots:
_deep_cleanup() # start the first (heaviest) shot with max free VRAM _deep_cleanup() # start the first (heaviest) shot with max free VRAM
@@ -6546,23 +6588,13 @@ class H3LongVideos:
float(fps), int(fps), latent_out, global_soundscape) float(fps), int(fps), latent_out, global_soundscape)
# REF2VA registers under its OWN key. The FL2VA pack one directory up keeps # The old FL2VA / REF2VA aliases were only alternate menu entries for the same class.
# "H3LongVideosFL2VA" and the legacy "H3LongVideosV1" alias; ComfyUI builds one # Dumas workflows now use the canonical "DumasH3LongVideos" key, so expose a single
# flat registry, so repeating either here would silently overwrite that node -- # node entry instead of triplicating the search results with duplicate aliases.
# same name in the search, no way to tell which copy a workflow is running.
# ONE node, three registration keys. ComfyUI stores the key verbatim in every saved
# workflow, so all three must keep resolving or existing graphs load as red "missing
# node" boxes: "H3LongVideosV1" is the original name, "H3LongVideosFL2VA" the rename,
# and "H3LongVideosREF2VA" the separate reference node that has now been folded in.
# They are aliases onto the same class -- there is no second implementation.
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"DumasH3LongVideos": H3LongVideos, "DumasH3LongVideos": H3LongVideos,
"DumasH3LongVideosFL2VA": H3LongVideos,
"DumasH3LongVideosREF2VA": H3LongVideos,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)", "DumasH3LongVideos": "Dumas H3 Long Videos (FL2VA + REF2VA)",
"DumasH3LongVideosFL2VA": "Dumas H3 Long Videos (FL2VA + REF2VA)",
"DumasH3LongVideosREF2VA": "Dumas H3 Long Videos (FL2VA + REF2VA)",
} }
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+71
View File
@@ -183,6 +183,10 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
def test_shot_references_uses_all_connected_sparse_slots(self): def test_shot_references_uses_all_connected_sparse_slots(self):
refs = [None, "img2", None, "img4", None, None, "img7", None, None] refs = [None, "img2", None, "img4", None, None, "img7", None, None]
self.assertEqual(
self.module.shot_references(refs, "auto ref2v", 0, None),
["img2", "img4", "img7"],
)
self.assertEqual( self.assertEqual(
self.module.shot_references(refs, "first shot", 0, None), self.module.shot_references(refs, "first shot", 0, None),
["img2", "img4", "img7"], ["img2", "img4", "img7"],
@@ -225,6 +229,73 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
("plan1", "direct2", "plan3", None, "direct5", None, "plan7", None, None), ("plan1", "direct2", "plan3", None, "direct5", None, "plan7", None, None),
) )
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)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()