Restore release-era character ref routing

This commit is contained in:
2026-08-29 12:18:38 +00:00
parent e08281c12b
commit 74aca203ab
2 changed files with 1463 additions and 1595 deletions
+44 -140
View File
@@ -4454,52 +4454,32 @@ def _slot_refs_for_text(text, ref_slots):
for slot_number in picture_tags(text): for slot_number in picture_tags(text):
if not (1 <= slot_number <= len(ref_slots or [])): if not (1 <= slot_number <= len(ref_slots or [])):
continue continue
ref = ref_slots[slot_number - 1] raw = ref_slots[slot_number - 1]
ref = _reference_slot(raw, slot_number)
if _reference_image(ref) is None: if _reference_image(ref) is None:
continue continue
refs.append((slot_number, ref)) refs.append((slot_number, ref))
return refs return refs
def _named_refs_for_text(text, ref_slots, kinds=None): def _named_character_refs_for_text(text, ref_slots):
haystack = str(text or "") haystack = str(text or "")
matched = [] matched = []
wanted = {str(k).strip().lower() for k in (kinds or ()) if str(k).strip()} for slot_number, raw in enumerate(ref_slots or [], 1):
by_name = {} ref = _reference_slot(raw, slot_number)
for slot_number, ref in enumerate(ref_slots or [], 1): if ref.get("kind") != "character" or _reference_image(ref) is None:
if ref is None or _reference_image(ref) is None:
continue
if wanted and str(ref.get("kind") or "").strip().lower() not in wanted:
continue continue
for name in _reference_name_keys(ref): for name in _reference_name_keys(ref):
by_name.setdefault(name.lower(), []).append((slot_number, ref, name))
for entries in by_name.values():
if len(entries) != 1:
continue
slot_number, ref, name = entries[0]
if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I): if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I):
matched.append((slot_number, ref)) matched.append((slot_number, ref))
break
return matched return matched
def _matched_reference_slots(text, ref_slots, normalized_refs=None): def _matched_reference_slots(text, ref_slots):
matched = [] matched = []
seen = set() seen = set()
normalized_slots = normalized_refs if normalized_refs is not None else _normalized_ref_slots(ref_slots) for slot_number, ref in _slot_refs_for_text(text, ref_slots) + _named_character_refs_for_text(text, ref_slots):
for slot_number, ref in _slot_refs_for_text(text, normalized_slots):
if slot_number in seen:
continue
seen.add(slot_number)
matched.append((slot_number, ref))
# Character names should win before location names when a beat mentions both.
# That keeps the face conditioning in front of the scene conditioning instead
# of letting an early location slot dominate the named-ref list.
for slot_number, ref in _named_refs_for_text(text, normalized_slots, kinds=("character",)):
if slot_number in seen:
continue
seen.add(slot_number)
matched.append((slot_number, ref))
for slot_number, ref in _named_refs_for_text(text, normalized_slots, kinds=("location",)):
if slot_number in seen: if slot_number in seen:
continue continue
seen.add(slot_number) seen.add(slot_number)
@@ -4507,20 +4487,12 @@ def _matched_reference_slots(text, ref_slots, normalized_refs=None):
return matched return matched
def _reference_context_for_text( def _reference_context_for_text(text, ref_slots):
text,
ref_slots,
include_character_wardrobe=True,
normalized_refs=None,
):
parts = [] parts = []
if normalized_refs is None: for slot_number, ref in _matched_reference_slots(text, ref_slots):
normalized_refs = _normalized_ref_slots(ref_slots)
for slot_number, ref in _matched_reference_slots(text, ref_slots, normalized_refs=normalized_refs):
label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}" label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}"
description = _reference_sentence(ref.get("description")) description = _reference_sentence(ref.get("description"))
wardrobe = (_reference_sentence(ref.get("wardrobe")) wardrobe = _reference_sentence(ref.get("wardrobe"))
if include_character_wardrobe else "")
general = _reference_sentence(ref.get("general")) general = _reference_sentence(ref.get("general"))
facts = _reference_fact_sentence(ref, label) facts = _reference_fact_sentence(ref, label)
if ref.get("kind") == "location": if ref.get("kind") == "location":
@@ -4557,12 +4529,10 @@ def _inject_reference_context(block, context):
) )
def _reference_character_memory(ref_slots, normalized_refs=None): def _reference_character_memory(ref_slots):
lines = [] lines = []
seen = set() seen = set()
if normalized_refs is None: for slot_number, ref in enumerate(ref_slots or [], 1):
normalized_refs = _normalized_ref_slots(ref_slots)
for slot_number, ref in enumerate(normalized_refs, 1):
if ref is None: if ref is None:
continue continue
if ref.get("kind") != "character": if ref.get("kind") != "character":
@@ -4583,14 +4553,12 @@ def _reference_character_memory(ref_slots, normalized_refs=None):
return "\n".join(lines) return "\n".join(lines)
def _connected_refs(ref_slots, normalized_refs=None): def _connected_refs(ref_slots):
"""Connected refs only, preserving slot order and skipping empty sockets.""" """Connected refs only, preserving slot order and skipping empty sockets."""
if normalized_refs is None: return [ref for ref in (ref_slots or []) if _reference_image(ref) is not None]
normalized_refs = _normalized_ref_slots(ref_slots)
return [ref for ref in normalized_refs if _reference_image(ref) is not None]
def resolve_tagged_refs(text, ref_list, normalized_refs=None): def resolve_tagged_refs(text, ref_list):
"""(rewritten text, images, dropped) for the <Picture N> tags in ONE shot. """(rewritten text, images, dropped) for the <Picture N> tags in ONE shot.
The tokenizer numbers references by their position in the list it is handed, so The tokenizer numbers references by their position in the list it is handed, so
@@ -4602,9 +4570,7 @@ def resolve_tagged_refs(text, ref_list, normalized_refs=None):
A tag naming a slot with no image connected refers to nothing at all, so it is A tag naming a slot with no image connected refers to nothing at all, so it is
removed from the text rather than left to confuse the encoder, and reported.""" removed from the text rather than left to confuse the encoder, and reported."""
wanted = picture_tags(text) wanted = picture_tags(text)
if normalized_refs is None: live = [n for n in wanted if 1 <= n <= len(ref_list or []) and ref_list[n - 1] is not None]
normalized_refs = _normalized_ref_slots(ref_list)
live = [n for n in wanted if 1 <= n <= len(normalized_refs) and _reference_image(normalized_refs[n - 1]) is not None]
dropped = [n for n in wanted if n not in live] dropped = [n for n in wanted if n not in live]
renumber = {old: new for new, old in enumerate(live, 1)} renumber = {old: new for new, old in enumerate(live, 1)}
@@ -4617,10 +4583,10 @@ def resolve_tagged_refs(text, ref_list, normalized_refs=None):
out = re.sub(r"\s+([,.;:])", r"\1", out) out = re.sub(r"\s+([,.;:])", r"\1", out)
out = re.sub(r"(,\s*){2,}", ", ", out) out = re.sub(r"(,\s*){2,}", ", ", out)
out = re.sub(r"\s{2,}", " ", out) out = re.sub(r"\s{2,}", " ", out)
return out.strip(), [normalized_refs[n - 1] for n in live], dropped return out.strip(), [ref_list[n - 1] for n in live], dropped
def resolve_prompt_refs(text, ref_list, include_named=True, normalized_refs=None): def resolve_prompt_refs(text, ref_list):
"""(rewritten text, refs, dropped) for the refs a shot actually carries. """(rewritten text, refs, dropped) for the refs a shot actually carries.
Explicit <Picture N> tags still decide which slot numbers the prompt points at, Explicit <Picture N> tags still decide which slot numbers the prompt points at,
@@ -4628,21 +4594,10 @@ def resolve_prompt_refs(text, ref_list, include_named=True, normalized_refs=None
that split, a shot could inherit the facts/context for "Mara" and "Jon" while that split, a shot could inherit the facts/context for "Mara" and "Jon" while
only carrying a tagged location image, which reads exactly like the names were only carrying a tagged location image, which reads exactly like the names were
understood but the faces were ignored.""" understood but the faces were ignored."""
if normalized_refs is None: rewritten, tagged_refs, dropped = resolve_tagged_refs(text, ref_list)
normalized_refs = _normalized_ref_slots(ref_list)
rewritten, tagged_refs, dropped = resolve_tagged_refs(text, normalized_refs, normalized_refs=normalized_refs)
refs = list(tagged_refs) refs = list(tagged_refs)
seen = {id(ref) for ref in refs} seen = {id(ref) for ref in refs}
if include_named: for _slot_number, ref in _named_character_refs_for_text(rewritten, ref_list):
# Keep named character refs ahead of location refs so the identity image
# is the first named reference the model sees on untagged beats.
for _slot_number, ref in _named_refs_for_text(rewritten, normalized_refs, kinds=("character",)):
marker = id(ref)
if marker in seen:
continue
seen.add(marker)
refs.append(ref)
for _slot_number, ref in _named_refs_for_text(rewritten, normalized_refs, kinds=("location",)):
marker = id(ref) marker = id(ref)
if marker in seen: if marker in seen:
continue continue
@@ -4651,47 +4606,7 @@ def resolve_prompt_refs(text, ref_list, include_named=True, normalized_refs=None
return rewritten, refs, dropped return rewritten, refs, dropped
def resolve_shot_references(text, ref_list, ref_mode, shot_index, handoff, connected_refs=None, normalized_refs=None): def shot_references(ref_list, ref_mode, shot_index, handoff):
"""(rewritten text, refs, dropped, shot_tag_driven, effective_mode) for one beat.
Each beat should be able to pick up character names on its own. A stray
<Picture N> tag in some other beat should not make name-matched characters
disappear from the untagged beats in the same chain."""
# Keep name matching local to the beat. Earlier versions used a chain-wide
# "any tags anywhere" gate here, which accidentally turned off character
# identity matching on untagged beats as soon as one tagged beat existed.
# That regression is exactly what this helper prevents.
refs = connected_refs if connected_refs is not None else _connected_refs(ref_list, normalized_refs=normalized_refs)
if not refs:
return text, [], [], False, ref_mode
normalized_refs = normalized_refs if normalized_refs is not None else _normalized_ref_slots(ref_list)
shot_tags = picture_tags(text)
if ref_mode == "auto ref2v":
rewritten, shot_refs, dropped = resolve_prompt_refs(
text,
ref_list,
include_named=True,
normalized_refs=normalized_refs,
)
if shot_tags:
return rewritten, shot_refs, dropped, True, "auto ref2v"
if shot_refs:
return rewritten, shot_refs, dropped, False, "auto ref2v"
return text, shot_references(ref_list, "every shot", shot_index, handoff, connected_refs=refs), [], False, "every shot"
if ref_mode == "where tagged":
if shot_tags:
rewritten, shot_refs, dropped = resolve_prompt_refs(
text,
ref_list,
include_named=False,
normalized_refs=normalized_refs,
)
return rewritten, shot_refs, dropped, True, "where tagged"
return text, shot_references(ref_list, "first shot", shot_index, handoff, connected_refs=refs), [], False, "first shot"
return text, shot_references(ref_list, ref_mode, shot_index, handoff, connected_refs=refs), [], False, ref_mode
def shot_references(ref_list, ref_mode, shot_index, handoff, connected_refs=None):
"""Pure: which reference images shot `shot_index` is conditioned on, or [] when """Pure: which reference images shot `shot_index` is conditioned on, or [] when
the shot should use the keyframe handoff instead. the shot should use the keyframe handoff instead.
@@ -4717,7 +4632,7 @@ def shot_references(ref_list, ref_mode, shot_index, handoff, connected_refs=None
back as a soft signal (the model is shown where the last back as a soft signal (the model is shown where the last
shot ended rather than told to start exactly there), and it shot ended rather than told to start exactly there), and it
stays a single ref2va task, so nothing conflicts.""" stays a single ref2va task, so nothing conflicts."""
refs = connected_refs if connected_refs is not None else _connected_refs(ref_list) refs = _connected_refs(ref_list)
if not refs: if not refs:
return [] return []
if ref_mode == "auto ref2v": if ref_mode == "auto ref2v":
@@ -6766,8 +6681,6 @@ class H3LongVideos:
context = _reference_context_for_text( context = _reference_context_for_text(
block, block,
ref_slots, ref_slots,
include_character_wardrobe=not bool(explicit_character_memory),
normalized_refs=normalized_ref_slots,
) )
if context: if context:
block = _inject_reference_context(block, context) block = _inject_reference_context(block, context)
@@ -6856,19 +6769,15 @@ class H3LongVideos:
effective_modes = [] effective_modes = []
for shot_index, gen in enumerate(gens): for shot_index, gen in enumerate(gens):
shot_mode = beat_ref_mode_directive(beats[shot_index] if shot_index < len(beats) else "") or ref_mode shot_mode = beat_ref_mode_directive(beats[shot_index] if shot_index < len(beats) else "") or ref_mode
_rewritten, refs, _dropped, shot_tag_driven, mode_eff = resolve_shot_references( if shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere:
gen, if resolve_prompt_refs(gen, ref_slots)[1]:
ref_slots, on.append(shot_index + 1)
shot_mode, tagged_used = True
shot_index, else:
1 if shot_index else None, mode_eff = ("every shot" if shot_mode == "auto ref2v"
connected_refs=connected_refs, else "first shot" if shot_mode == "where tagged" else shot_mode)
normalized_refs=normalized_ref_slots,
)
effective_modes.append(mode_eff) effective_modes.append(mode_eff)
if shot_tag_driven: if shot_references(ref_slots, mode_eff, shot_index, 1 if shot_index else None):
tagged_used = tagged_used or bool(refs)
if refs:
on.append(shot_index + 1) on.append(shot_index + 1)
if tagged_used: if tagged_used:
how = "placed by <Picture N> tags" how = "placed by <Picture N> tags"
@@ -6928,7 +6837,6 @@ class H3LongVideos:
ref_mode_used = [] ref_mode_used = []
continuity_used = [] continuity_used = []
ref_aug_used = [] ref_aug_used = []
any_explicit_picture_tags = False
shot_timings = [] shot_timings = []
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
@@ -6946,27 +6854,23 @@ class H3LongVideos:
shot_continuity = beat_continuity_directive(beat_text) or "auto" shot_continuity = beat_continuity_directive(beat_text) or "auto"
shot_ref_noise_aug = beat_ref_noise_aug_directive(beat_text) shot_ref_noise_aug = beat_ref_noise_aug_directive(beat_text)
shot_aug = ref_noise_aug if shot_ref_noise_aug is None else shot_ref_noise_aug shot_aug = ref_noise_aug if shot_ref_noise_aug is None else shot_ref_noise_aug
gen_prompt, shot_refs, dropped, shot_tag_driven, shot_mode_eff = resolve_shot_references( shot_tag_driven = bool(connected_ref_count) and shot_mode in ("where tagged", "auto ref2v") and any_tags_anywhere
gen_prompt, if shot_tag_driven:
ref_slots, shot_mode_eff = shot_mode
shot_mode, else:
i, shot_mode_eff = ("every shot" if shot_mode == "auto ref2v"
handoff, else "first shot" if shot_mode == "where tagged" else shot_mode)
connected_refs=connected_refs,
normalized_refs=normalized_ref_slots,
)
carry_keyframe = False # tagged shot keeps its handoff as a keyframe carry_keyframe = False # tagged shot keeps its handoff as a keyframe
if shot_tag_driven: if shot_tag_driven:
any_explicit_picture_tags = True
# The prompt itself says where each reference belongs: the shot whose # The prompt itself says where each reference belongs: the shot whose
# text names <Picture N> gets image N, renumbered to match what that # text names <Picture N> gets image N, renumbered to match what that
# shot actually carries. Named character matches are already included # shot actually carries. Every untagged shot keeps its handoff.
# in the same pass; the tag only makes the slot mapping explicit. gen_prompt, shot_refs, dropped = resolve_prompt_refs(gen_prompt, ref_slots)
# Do not reintroduce a chain-wide tag gate here. Named character
# refs must still route on beats that do not use <Picture N>.
for n in dropped: for n in dropped:
if n not in ref_missing: if n not in ref_missing:
ref_missing.append(n) ref_missing.append(n)
else:
shot_refs = shot_references(ref_slots, shot_mode_eff, i, handoff)
# A shot that follows a strip starts FRESH. Continuing from a frame that # A shot that follows a strip starts FRESH. Continuing from a frame that
# still shows the garment is how it reappears -- the picture outvotes the # still shows the garment is how it reappears -- the picture outvotes the
# text every time. Costs a cut exactly where the state changes, which is # text every time. Costs a cut exactly where the state changes, which is
@@ -7026,7 +6930,7 @@ class H3LongVideos:
"soft carry" if shot_handoff is not None else "hard cut") "soft carry" if shot_handoff is not None else "hard cut")
if carry_keyframe and (i + 1) not in ref_keyframed and handoff is not None: if carry_keyframe and (i + 1) not in ref_keyframed and handoff is not None:
ref_keyframed.append(i + 1) ref_keyframed.append(i + 1)
ref_mode_used.append(shot_mode_eff) ref_mode_used.append(shot_mode if shot_tag_driven else shot_mode_eff)
continuity_used.append(continuity_label) continuity_used.append(continuity_label)
ref_aug_used.append(shot_aug) ref_aug_used.append(shot_aug)
if shot_refs: if shot_refs:
@@ -7299,7 +7203,7 @@ class H3LongVideos:
if connected_ref_count and ref_shots: if connected_ref_count and ref_shots:
kept = [n for n in range(1, len(gens) + 1) if n not in ref_shots] kept = [n for n in range(1, len(gens) + 1) if n not in ref_shots]
distinct_ref_modes = list(dict.fromkeys(ref_mode_used)) distinct_ref_modes = list(dict.fromkeys(ref_mode_used))
tagged_used = any_explicit_picture_tags tagged_used = any(mode in ("where tagged", "auto ref2v") for mode in ref_mode_used) and any_tags_anywhere
ref_placement = ("placed by <Picture N> tags" if tagged_used else ref_placement = ("placed by <Picture N> tags" if tagged_used else
f"ref_mode '{distinct_ref_modes[0]}'" if len(distinct_ref_modes) == 1 else f"ref_mode '{distinct_ref_modes[0]}'" if len(distinct_ref_modes) == 1 else
"mixed per-shot ref_mode") "mixed per-shot ref_mode")
-36
View File
@@ -458,42 +458,6 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
self.assertEqual([self.module._reference_image(ref) for ref in references], ["img2"]) self.assertEqual([self.module._reference_image(ref) for ref in references], ["img2"])
self.assertEqual(dropped, []) self.assertEqual(dropped, [])
def test_resolve_shot_references_uses_named_characters_without_picture_tags(self):
refs = [
{"kind": "character", "image": "img1", "name": "Mara"},
{"kind": "character", "image": "img2", "name": "Jon"},
{"kind": "location", "image": "img3", "name": "Hangar"},
]
text, references, dropped, shot_tag_driven, mode_eff = self.module.resolve_shot_references(
"[Generation 1] Mara crosses the hangar.",
refs,
"auto ref2v",
0,
None,
)
self.assertEqual(text, "[Generation 1] Mara crosses the hangar.")
self.assertEqual([self.module._reference_image(ref) for ref in references], ["img1"])
self.assertEqual(dropped, [])
self.assertFalse(shot_tag_driven)
self.assertEqual(mode_eff, "auto ref2v")
def test_resolve_prompt_refs_prioritizes_characters_before_locations(self):
refs = [
{"kind": "location", "image": "img1", "name": "Hangar"},
{"kind": "character", "image": "img2", "name": "Mara"},
]
text, references, dropped = self.module.resolve_prompt_refs(
"[Generation 1] Mara waits in the hangar.",
refs,
)
self.assertEqual(text, "[Generation 1] Mara waits in the hangar.")
self.assertEqual([self.module._reference_image(ref) for ref in references], ["img2", "img1"])
self.assertEqual(dropped, [])
def test_shot_references_uses_all_connected_sparse_slots(self): def test_shot_references_uses_all_connected_sparse_slots(self):
refs = [ refs = [
None, None,