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
+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:
speakers = _speakers_in(body, names)
if speakers:
bound_names = set(refs) or (
{n for n in names} if _PLURAL_CAST.search(body) else set())
bound_names = set(refs) or (
{n for n in names} if _plural_cast_matches_present(body, len(names)) else set())
if speakers & bound_names:
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
roll_call = ""
if not refs and len(names) > 1 and _PLURAL_CAST.search(body):
bits = []
for n in names:
desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n)))
bits.append(f"{n} ({desc})" if desc else n)
if not refs and _plural_cast_matches_present(body, len(names)):
bits = []
for n in names:
desc = ", ".join(_clean_items(active[n], n, drop_mouth_state=_drop_mouth(n)))
bits.append(f"{n} ({desc})" if desc else n)
roll_call = ((", ".join(bits[:-1]) + " and " + bits[-1])
+ (" are both in this shot." if len(bits) == 2
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
# 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.
_PLURAL_CAST = re.compile(
r"\b(?:they|themselves|both|each other|one another|"
r"the two of them|all of them)\b", re.I)
_PLURAL_CAST = re.compile(
r"\b(?:they|themselves|both|each other|one another|"
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):
@@ -2335,7 +2369,7 @@ def person_referenced(body, name, active):
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
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
restraints appeared to break). Both are gated on this function now, so a third
caller cannot rediscover it."""
if person_referenced(body, name, active):
return True
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 ""))
if person_referenced(body, name, active):
return True
present = [n for n in (active or {}) if n and n not in (departed or ())]
return _plural_cast_matches_present(body, len(present))
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
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
uses the last-frame handoff. Continuity is unbroken and the
look propagates down the chain, but only through the frames.
'auto ref2v' -- use explicit prompt tags when they exist; otherwise carry
the references on every shot. This is the ref2v-biased
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
handoff at all, so shots meet as CUTS rather than as one
continuous take.
@@ -4089,6 +4127,8 @@ def shot_references(ref_list, ref_mode, shot_index, handoff):
refs = _connected_refs(ref_list)
if not refs:
return []
if ref_mode == "auto ref2v":
return list(refs)
if ref_mode == "first shot":
return list(refs) if shot_index == 0 else []
if ref_mode == "every shot":
@@ -5481,31 +5521,30 @@ class H3LongVideos:
"overlay_stroke": ("INT", {"default": 0, "min": 0, "max": 20,
"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."}),
"ref_mode": (["where tagged", "first shot", "every shot", "every shot + handoff ref"],
{"default": "where tagged",
"tooltip": "Which shots the ref_image inputs condition. A shot carries EITHER "
"references or the last-frame handoff, never both. 'where tagged' "
"(default): write <Picture 1> in the beat where that character "
"appears and ONLY that shot gets the reference -- every other shot "
"keeps its handoff. This is the precise option: the other modes go by "
"shot NUMBER and are blind to who is actually in the shot, so a "
"character who first appears in shot 2 gets nothing while an empty "
"establishing shot 1 gets a portrait pushed into it. Tags are "
"renumbered per shot, so <Picture 2> alone still resolves. With refs "
"connected but no tags anywhere, falls back to first shot rather than "
"silently doing nothing. 'first shot' / 'every shot' / 'every shot + "
"handoff ref' go purely by position. Ignored when no ref_image is "
"connected."}),
"ref_noise_aug": ("FLOAT", {"default": 0.999, "min": 0.50, "max": 1.0, "step": 0.005,
"tooltip": "How CLEAN each reference is presented to the model. 0.999 (H3's own "
"default) hands it a finished, noise-free image -- which invites the "
"model to REPRODUCE the reference in the opening frames instead of just "
"taking an identity from it. Lower values blend the condition with "
"noise and label it as approximate, so it informs the face without "
"being copied: try 0.95, then 0.90. Too low (below ~0.8) and the "
"reference stops holding identity at all. Applies ONLY to "
"ref-conditioned shots -- the last-frame handoff is never weakened, or "
"continuity would break."}),
"ref_mode": (["auto ref2v", "where tagged", "first shot", "every shot", "every shot + handoff ref"],
{"default": "auto ref2v",
"tooltip": "Which shots the ref_image inputs condition. 'auto ref2v' (default) is "
"the reference-to-video bias: if the prompt uses <Picture N> tags, those "
"tags decide which shot gets which ref; if there are NO tags anywhere, the "
"node conditions EVERY shot with the connected refs rather than collapsing "
"them to shot 1. That is the better default for single-subject ref2v and "
"for long chains where identity drift matters more than strict per-shot "
"routing. 'where tagged' keeps the old strict behavior, including the "
"first-shot fallback when no tags are found. Tags are renumbered per shot, "
"so <Picture 2> alone still resolves. 'first shot' / 'every shot' / "
"'every shot + handoff ref' go purely by position. Ignored when no "
"ref_image is connected."}),
"ref_noise_aug": ("FLOAT", {"default": 0.95, "min": 0.50, "max": 1.0, "step": 0.005,
"tooltip": "How CLEAN each reference is presented to the model. 0.999 (H3's own "
"default) hands it a finished, noise-free image -- which invites the "
"model to REPRODUCE the reference in the opening frames instead of just "
"taking an identity from it. Lower values blend the condition with "
"noise and label it as approximate, so it informs the face without "
"being copied. 0.95 is the ref2v-biased default here; 0.999 keeps the "
"upstream literal-reference behavior. Too low (below ~0.8) and the "
"reference stops holding identity at all. Applies ONLY to "
"ref-conditioned shots -- the last-frame handoff is never weakened, or "
"continuity would break."}),
"ref_image_size": (["match", "max"], {"default": "match",
"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 "
@@ -6111,15 +6150,17 @@ class H3LongVideos:
# prompts and falls back to first shot when nothing is tagged --
# reporting by ref_mode alone described shots the render never gave
# 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]]
how = "placed by <Picture N> tags"
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)
if shot_references(ref_slots, mode_eff, n, 1 if n else None)]
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 = []
if direct_ref_count:
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
# connected but nothing is tagged anywhere, fall back to first-shot placement
# 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)
if connected_ref_count and ref_mode == "where tagged" and not tag_driven:
ref_mode = "first shot"
if connected_ref_count and tag_mode and not tag_driven:
ref_mode = "every shot" if ref_mode == "auto ref2v" else "first shot"
if cleanup_between_shots:
_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)
# REF2VA registers under its OWN key. The FL2VA pack one directory up keeps
# "H3LongVideosFL2VA" and the legacy "H3LongVideosV1" alias; ComfyUI builds one
# flat registry, so repeating either here would silently overwrite that node --
# 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.
# The old FL2VA / REF2VA aliases were only alternate menu entries for the same class.
# Dumas workflows now use the canonical "DumasH3LongVideos" key, so expose a single
# node entry instead of triplicating the search results with duplicate aliases.
NODE_CLASS_MAPPINGS = {
"DumasH3LongVideos": H3LongVideos,
"DumasH3LongVideosFL2VA": H3LongVideos,
"DumasH3LongVideosREF2VA": H3LongVideos,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"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"]