Implement structured H3 reference objects

This commit is contained in:
2026-08-26 16:19:35 +00:00
parent 0b20381c5c
commit c37498c175
6 changed files with 1042 additions and 214 deletions
+204 -57
View File
@@ -8,7 +8,7 @@ One node covering both of H3's conditioning tasks:
* REF2VA -- reference images condition the shot on what a character LOOKS like,
independent of any frame.
Connect nothing to ref_image_* and it behaves exactly as the FL2VA node always
Connect nothing to ref_* and it behaves exactly as the FL2VA node always
did. Connect a reference and `ref_mode` decides which shots use it.
THE ONE RULE: a shot carries EITHER references or the last-frame handoff, never
@@ -275,7 +275,7 @@ ADDED_WIDGETS = (
"exposed_terms", "anatomy_guard", "lock_restraints", "solidity_guard",
"motion_guard", "contact_guard",
"auto_soundscape", "allow_nonspeech_vocals",
"ref_image_5", "ref_image_6", "ref_image_7", "ref_image_8", "ref_image_9",
"ref_5", "ref_6", "ref_7", "ref_8", "ref_9",
"plan", "plan_scene_index",
)
@@ -3868,17 +3868,18 @@ def ref_image_canvas(w, h, gen_w, gen_h, mode="match"):
return snap(w), snap(h)
def _build_ref_images(vae, images, gen_w, gen_h, mode="match"):
"""(tokenizer items, DiT blocks) for a list of reference IMAGE tensors.
The tokenizer labels each one `<Picture N>:` itself, in the order given here --
so the roster the prompt refers to is decided by input order, not by anything
written in the prompt."""
items, blocks = [], []
for img in images:
if img is None:
continue
h, w = int(img.shape[1]), int(img.shape[2])
def _build_ref_images(vae, images, gen_w, gen_h, mode="match"):
"""(tokenizer items, DiT blocks) for a list of reference images.
The tokenizer labels each one `<Picture N>:` itself, in the order given here --
so the roster the prompt refers to is decided by input order, not by anything
written in the prompt."""
items, blocks = [], []
for source in images:
img = _reference_image(source)
if img is None:
continue
h, w = int(img.shape[1]), int(img.shape[2])
tw, th = ref_image_canvas(w, h, gen_w, gen_h, mode)
resized = _resize(img[:1], tw, th, "disabled")
items.append({"type": "image", "data": resized})
@@ -3887,11 +3888,11 @@ def _build_ref_images(vae, images, gen_w, gen_h, mode="match"):
return items, blocks
def _build_shot_conditioning(clip, vae, prompt, width, height, length, fps, handoff,
ref_images=None, ref_image_size="match", ref_noise_aug=None,
audio_vae=None, silent=False):
latent, fc = _empty_av_latent(width, height, length, fps)
refs = [r for r in (ref_images or []) if r is not None]
def _build_shot_conditioning(clip, vae, prompt, width, height, length, fps, handoff,
ref_images=None, ref_image_size="match", ref_noise_aug=None,
audio_vae=None, silent=False):
latent, fc = _empty_av_latent(width, height, length, fps)
refs = [r for r in (ref_images or []) if _reference_image(r) is not None]
if refs:
# ref2va: this shot is reference-conditioned rather than keyframe-conditioned,
# and run() decides which per shot. A tagged shot is handed the previous
@@ -4044,9 +4045,135 @@ def picture_tags(text):
return sorted({int(m.group(1)) for m in _PICTURE_TAG.finditer(text or "")})
def _reference_slot(ref, slot_index=None):
return _image_nodes.normalize_reference(ref, picture_id=slot_index, allow_image_fallback=True)
def _reference_image(ref):
try:
normalized = _reference_slot(ref)
except Exception:
return None
return normalized.get("image")
def _reference_text(value):
return " ".join(str(value or "").split()).strip()
def _reference_sentence(value):
text = _reference_text(value)
if text and text[-1] not in ".!?":
text += "."
return text
def _reference_name_keys(ref):
names = []
for key in ("name", "id"):
value = _reference_text(ref.get(key))
if value:
names.append(value)
for alias in ref.get("aliases") or []:
value = _reference_text(alias)
if value:
names.append(value)
seen = set()
out = []
for name in names:
key = name.lower()
if key in seen:
continue
seen.add(key)
out.append(name)
return out
def _slot_refs_for_text(text, ref_slots):
refs = []
for slot_number in picture_tags(text):
if not (1 <= slot_number <= len(ref_slots or [])):
continue
ref = ref_slots[slot_number - 1]
if _reference_image(ref) is None:
continue
refs.append((slot_number, _reference_slot(ref, slot_number)))
return refs
def _named_character_refs_for_text(text, ref_slots):
haystack = str(text or "")
matched = []
for slot_number, raw in enumerate(ref_slots or [], 1):
ref = _reference_slot(raw, slot_number)
if ref.get("kind") != "character" or _reference_image(ref) is None:
continue
for name in _reference_name_keys(ref):
if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I):
matched.append((slot_number, ref))
break
return matched
def _matched_reference_slots(text, ref_slots):
matched = []
seen = set()
for slot_number, ref in _slot_refs_for_text(text, ref_slots) + _named_character_refs_for_text(text, ref_slots):
if slot_number in seen:
continue
seen.add(slot_number)
matched.append((slot_number, ref))
return matched
def _reference_context_for_text(text, ref_slots):
parts = []
for slot_number, ref in _matched_reference_slots(text, ref_slots):
label = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or f"reference {slot_number}"
description = _reference_sentence(ref.get("description"))
wardrobe = _reference_sentence(ref.get("wardrobe"))
general = _reference_sentence(ref.get("general"))
if ref.get("kind") == "location":
if description:
parts.append(f"Location context for {label}: {description}")
if general:
parts.append(f"Location notes for {label}: {general}")
continue
if description:
parts.append(f"Persistent appearance for {label}: {description}")
if wardrobe:
parts.append(f"Persistent wardrobe/style for {label}: {wardrobe}")
if general:
parts.append(f"Character notes for {label}: {general}")
return " ".join(parts).strip()
def _reference_character_memory(ref_slots):
lines = []
seen = set()
for slot_number, raw in enumerate(ref_slots or [], 1):
ref = _reference_slot(raw, slot_number)
if ref.get("kind") != "character":
continue
wardrobe = _reference_text(ref.get("wardrobe"))
if not wardrobe:
continue
label = (
_reference_text(ref.get("name"))
or (_reference_name_keys(ref)[0] if _reference_name_keys(ref) else "")
)
line = f"{label} = {wardrobe}" if label else wardrobe
key = line.lower()
if key in seen:
continue
seen.add(key)
lines.append(line)
return "\n".join(lines)
def _connected_refs(ref_slots):
"""Connected refs only, preserving slot order and skipping empty sockets."""
return [ref for ref in (ref_slots or []) if ref is not None]
return [ref for ref in (ref_slots or []) if _reference_image(ref) is not None]
def _plan_scene_refs(plan, scene_index):
@@ -4054,7 +4181,13 @@ def _plan_scene_refs(plan, scene_index):
if plan is None:
return (None,) * _image_nodes._H3_PLAN_IMAGE_SLOTS
extracted = _image_nodes.DumasH3PlanExtractSceneImagesNode().extract(plan, scene_index)
return tuple(extracted[1:1 + _image_nodes._H3_PLAN_IMAGE_SLOTS])
refs = []
for slot_number, image in enumerate(
extracted[1:1 + _image_nodes._H3_PLAN_IMAGE_SLOTS],
1,
):
refs.append(_reference_slot(image, slot_number) if image is not None else None)
return tuple(refs)
def _merge_ref_slots(direct_slots, plan_slots):
@@ -5328,18 +5461,18 @@ class H3LongVideos:
# the order they are handed that shot.
# Refer to socket tags in the prompt if you want a reference bound to
# a named character ("Kristy, <Picture 7>, walks in").
"ref_image_1": ("IMAGE", {"tooltip": "Reference image <Picture 1> -- identity/appearance "
"ref_1": ("REFERENCE", {"tooltip": "Reference object for <Picture 1> -- image plus identity/environment metadata "
"carried into the shots. Which shots receive it is set by ref_mode (or <Picture N> "
"tags in the beats); a referenced shot ALSO carries the previous frame as its "
"keyframe, so taking a reference never costs continuity."}),
"ref_image_2": ("IMAGE", {"tooltip": "Reference image <Picture 2>."}),
"ref_image_3": ("IMAGE", {"tooltip": "Reference image <Picture 3>."}),
"ref_image_4": ("IMAGE", {"tooltip": "Reference image <Picture 4>."}),
"ref_image_5": ("IMAGE", {"tooltip": "Reference image <Picture 5>."}),
"ref_image_6": ("IMAGE", {"tooltip": "Reference image <Picture 6>."}),
"ref_image_7": ("IMAGE", {"tooltip": "Reference image <Picture 7>."}),
"ref_image_8": ("IMAGE", {"tooltip": "Reference image <Picture 8>."}),
"ref_image_9": ("IMAGE", {"tooltip": "Reference image <Picture 9>."}),
"ref_2": ("REFERENCE", {"tooltip": "Reference object for <Picture 2>."}),
"ref_3": ("REFERENCE", {"tooltip": "Reference object for <Picture 3>."}),
"ref_4": ("REFERENCE", {"tooltip": "Reference object for <Picture 4>."}),
"ref_5": ("REFERENCE", {"tooltip": "Reference object for <Picture 5>."}),
"ref_6": ("REFERENCE", {"tooltip": "Reference object for <Picture 6>."}),
"ref_7": ("REFERENCE", {"tooltip": "Reference object for <Picture 7>."}),
"ref_8": ("REFERENCE", {"tooltip": "Reference object for <Picture 8>."}),
"ref_9": ("REFERENCE", {"tooltip": "Reference object for <Picture 9>."}),
"plan": (
"H3_CHAIN_PLAN",
{
@@ -5356,7 +5489,7 @@ class H3LongVideos:
"max": 9999,
"step": 1,
"tooltip": "1-based plan scene index to read from `plan`. "
"Any directly-wired ref_image socket overrides the same slot "
"Any directly-wired ref socket overrides the same slot "
"from the plan scene."
},
),
@@ -5853,9 +5986,9 @@ class H3LongVideos:
watermark_opacity=0.75, watermark_margin=3.0,
intro_text="", intro_position="center", intro_seconds=3.0, intro_fade=0.6,
intro_size=9.0, overlay_font="arial.ttf", overlay_stroke=0,
ref_image_1=None, ref_image_2=None, ref_image_3=None, ref_image_4=None,
ref_image_5=None, ref_image_6=None, ref_image_7=None, ref_image_8=None,
ref_image_9=None,
ref_1=None, ref_2=None, ref_3=None, ref_4=None,
ref_5=None, ref_6=None, ref_7=None, ref_8=None,
ref_9=None,
plan=None, plan_scene_index=1,
ref_mode="where tagged", ref_image_size="match", ref_noise_aug=0.999,
graph=None, node_id=None):
@@ -5872,19 +6005,21 @@ class H3LongVideos:
# H3 renders 24 fps, always. Honor the widget only as a warning: a lower value
# used to silently shorten every shot (10s -> 124f -> 5.2s of real time).
fps_note = ("" if int(fps) == H3_FPS else
f"fps widget is {int(fps)} but H3 always renders {H3_FPS} fps -- all durations "
f"computed at {H3_FPS}; set your video-save node to {H3_FPS} too")
fps_note = ("" if int(fps) == H3_FPS else
f"fps widget is {int(fps)} but H3 always renders {H3_FPS} fps -- all durations "
f"computed at {H3_FPS}; set your video-save node to {H3_FPS} too")
fps = H3_FPS
w, h = parse_resolution(resolution)
direct_ref_slots = (
ref_image_1, ref_image_2, ref_image_3, ref_image_4, ref_image_5,
ref_image_6, ref_image_7, ref_image_8, ref_image_9,
ref_1, ref_2, ref_3, ref_4, ref_5,
ref_6, ref_7, ref_8, ref_9,
)
plan_ref_slots = _plan_scene_refs(plan, plan_scene_index)
ref_slots = _merge_ref_slots(direct_ref_slots, plan_ref_slots)
plan_ref_count = len(_connected_refs(plan_ref_slots))
direct_ref_count = len(_connected_refs(direct_ref_slots))
derived_character_memory = _reference_character_memory(ref_slots)
effective_character_memory = (character_memory or "").strip() or derived_character_memory
# A pixel budget overrides the preset's SIZE while keeping its aspect ratio,
# so the dropdown chooses the shape and this chooses how big. Scaling from
# the preset's own dimensions is what makes 1.00MP reproduce each native
@@ -5932,9 +6067,9 @@ class H3LongVideos:
# anchor to avoid introducing them twice). Keep it as a BEAT and say so loudly,
# rather than losing a shot and the scene text along with it.
anchor_note = ""
if (not anchor_override.strip()) and paras and \
(anchor_contributes_nothing(anchor, character_memory.strip())
or anchor_is_action_beat(anchor, paras[1:])):
if (not anchor_override.strip()) and paras and \
(anchor_contributes_nothing(anchor, effective_character_memory)
or anchor_is_action_beat(anchor, paras[1:])):
preview = " ".join(anchor.split())[:60]
anchor, beat_paras = "", paras
anchor_note = (
@@ -6077,23 +6212,35 @@ class H3LongVideos:
"BABBLE RISK -- " + "; ".join(filler_warnings)
+ ". Turn per_beat_length ON to size these shots from their line, or set "
"'seconds:' on the beat")
wardrobe_notes = []
strip_shots = [] # shots that newly bared a zone -> the NEXT shot starts fresh
gens = distribute_generations(anchor, beats, global_soundscape.strip(),
non_diegetic_music.strip(), character_memory.strip(),
wardrobe_notes = []
strip_shots = [] # shots that newly bared a zone -> the NEXT shot starts fresh
gens = distribute_generations(anchor, beats, global_soundscape.strip(),
non_diegetic_music.strip(), effective_character_memory,
auto_wardrobe, auto_silence_nonspeech, allow_nonspeech_vocals, count_subjects,
lora_on, notes_out=wardrobe_notes, auto_props=auto_props,
prevent_nudity=prevent_nudity,
exposed_terms=exposed_terms, strip_out=strip_shots,
anatomy_guard=anatomy_on,
anatomy_auto=anatomy_auto,
lock_restraints=lock_restraints,
solidity_guard=solidity_guard,
motion_guard=motion_guard,
contact_guard=contact_guard,
count_auto=(subject_count_guard == "auto"))
# A scenery beat mid-chain hands the next shot a frame with no people in
lora_on, notes_out=wardrobe_notes, auto_props=auto_props,
prevent_nudity=prevent_nudity,
exposed_terms=exposed_terms, strip_out=strip_shots,
anatomy_guard=anatomy_on,
anatomy_auto=anatomy_auto,
lock_restraints=lock_restraints,
solidity_guard=solidity_guard,
motion_guard=motion_guard,
contact_guard=contact_guard,
count_auto=(subject_count_guard == "auto"))
enriched_gens = []
for block in gens:
context = _reference_context_for_text(block, ref_slots)
if context:
block = re.sub(
r"^(\[Generation \d+\]\s*)",
lambda m: m.group(1) + context + " ",
block,
count=1,
)
enriched_gens.append(block)
gens = enriched_gens
# A scenery beat mid-chain hands the next shot a frame with no people in
# it. Both prompts are individually correct, so this is invisible without
# looking at the sequence -- which is why chains lose their cast in the
# middle rather than degrading steadily.