From 6e48ca10d85c65c98beb111dbdfd75dfc76db1d6 Mon Sep 17 00:00:00 2001 From: ausboss <102309304+ausboss@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:49:14 -0600 Subject: [PATCH] H3 Prompt Writer: plain swap sentences, the new person from the first frame, a looks line written from clip (#300) A sentence like "Replace the man in the red shirt with @1" now names only the person who goes. Every swap with a picture asks for the replacement from the first frame. A text model on clip writes the @1 looks like line for hair and face when none is typed. --- CHANGELOG.md | 8 +++++ nodes/_h3_cast_helpers.py | 53 ++++++++++++++++++++++++++++-- nodes/_h3_prompt_helpers.py | 2 +- nodes/_h3_script_helpers.py | 11 +++++-- nodes/node_h3_prompt_writer.py | 8 +++-- tests/test_h3_prompt_writer.py | 60 +++++++++++++++++++++++++++++++++- 6 files changed, 133 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee38ee..823992f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to ComfyUI-AusBoss are documented here. ## Unreleased +- **H3 Prompt Writer reads a plain swap sentence.** "Replace the man in the red + shirt with @1", "Swap the woman for @1" and similar phrasings now name only the + person who goes, instead of putting the whole sentence into the prompt. +- **A swapped-in person is there from the first frame.** Swaps with a picture + always ask for the replacement throughout, with no transition or morph. +- **A text model on H3 Prompt Writer's `clip` input writes the `@1 looks like` + line in a swap.** It describes the person's hair and face from the picture + when no line is typed. Without a model on `clip`, nothing changes. - **Workflow Note tiles start compact in the classic canvas again.** A leftover image preview filed under the node's id made the classic canvas grow a new tile to hold the picture; notes now stand that preview down. diff --git a/nodes/_h3_cast_helpers.py b/nodes/_h3_cast_helpers.py index c2ad789..398c226 100644 --- a/nodes/_h3_cast_helpers.py +++ b/nodes/_h3_cast_helpers.py @@ -102,6 +102,33 @@ ONE_GOES = "the original person" APPEARANCE_LINE = re.compile(r'^\s*@([1-4])\s+looks like\s+([^"\n]+?)\s*$', re.I) +# Hair and face only, no clothes, expression or background. When the line names clothes that differ from what the +# person in the clip wears, H3 tends to open on the picture's own backdrop before cutting to the clip. +DESCRIBE_PERSON = ( + "Describe only the hair and face of the person in this picture, in one short line for a video prompt: hair colour " + "and style, and glasses, a beard or other face details you can see. No clothes, no expression, no background. " + "Start with 'a man' or 'a woman'." +) + + +def add_looks(text, jobs, describe): + """A swap's script with an @N looks like line for each new person who has none, written by describe(slot), a + small picture-reading model shown that person's picture. Without such a line H3 kept the original person for the + first seconds (2026-11-07), so a connected model writes it when the user didn't.""" + typed = {int(match.group(1)) for match in map(APPEARANCE_LINE.match, text.splitlines()) if match} + for slot in sorted(jobs): + if jobs[slot] not in (PERSON, SHEET) or slot in typed: + continue + words = next((line for line in str(describe(slot) or "").splitlines() if line.strip()), "") + words = re.sub(r'["\u201c\u201d]', "", words).strip(" .,;:") + words = re.sub(r"^(?:the\s+)?(?:person|subject)\s+(?:in\s+the\s+picture\s+)?(?:is|looks\s+like)\s+", "", words, flags=re.I) + # "and no visible beard": naming what isn't there can put it in the picture. + words = re.sub(r",?\s*(?:and\s+|with\s+)*(?:no|without)\s+(?:visible\s+)?[^,.;]*", "", words, flags=re.I).strip(" .,;:") + if words: + text = f"{text.rstrip()}\n@{slot} looks like {words[:1].lower() + words[1:]}" + return text + + def swap_appearances(text, jobs, video): """An optional separate @1 looks like line describes a replacement, not who goes. @@ -143,6 +170,21 @@ REPLACES = re.compile( r"|swaps?\s+(?:in\s+)?for|goes\s+in\s+for|stands?\s+in\s+for)\s+", re.I, ) +# A plain sentence: "Replace the man in red with @1", "swap the guy for @1", "make the dancer look like @1", +# "swap @1 in for the singer". The words caught are who goes. +ASKS = re.compile( + r"^\s*(?:please\s+)?(?:replace|swap(?:\s+out)?|switch(?:\s+out)?|change|turn|make)\s+(?!@\d)(.+?)\s+" + r"(?:with|for|to|into|by|look\s+like|become)\s+@\d\b" + r"|^\s*(?:please\s+)?(?:swap|put|place|drop|switch)\s+@\d\s+(?:in\s+)?(?:with|for|over|on|onto|in\s+place\s+of)\s+(.+)", + re.I, +) +# "the cashier is now @1", "the man on the left becomes @1". +BECOMES = re.compile( + r"^\s*(.+?)\s+(?:is\s+now|becomes|turns\s+into|is\s+replaced\s+(?:by|with)|gets\s+replaced\s+(?:by|with)" + r"|is\s+swapped\s+(?:for|with|to)|gets\s+swapped\s+(?:for|with)|->|\u2192)\s*@\d\b", + re.I, +) +IN_THE_CLIP = re.compile(r"\s+(?:in|from|of)\s+the\s+(?:clip|video|original(?:\s+(?:clip|video))?)\b", re.I) # "Elon is @1," names the new person, not the one who goes. # "the man on the left, Dario is" ends where the next person's name starts. NAMES_NEXT = re.compile(r",?\s*(?:[A-Z][\w'.-]*\s+)+(?:is|as|plays)\s*$") @@ -189,9 +231,16 @@ REVOICE_KEPT = ( def who_goes_in(text): """Who a typed swap line says goes, without the rest of the line: "Elon is @1, he replaces the man at the - counter" is the man at the counter.""" + counter" is the man at the counter, and so is "Replace the man at the counter with @1.".""" replaces = REPLACES.search(text) - text = text[replaces.end() :] if replaces else NAMES_THE_NEW.sub("", text) + plain = None if replaces else next((match for match in (ASKS.search(text), BECOMES.search(text)) if match), None) + if replaces: + text = text[replaces.end() :] + elif plain: + text = next(group for group in plain.groups() if group) + else: + text = NAMES_THE_NEW.sub("", text) + text = IN_THE_CLIP.sub("", text) text = re.split(r"[.;!?]", text.strip(" .;!?"), maxsplit=1)[0] text = NAMES_NEXT.sub("", text) text = SAYS_WHO.sub("", TOKEN.sub("", text).strip(" .,;")).strip(" .,;") diff --git a/nodes/_h3_prompt_helpers.py b/nodes/_h3_prompt_helpers.py index ca3264f..e66fc21 100644 --- a/nodes/_h3_prompt_helpers.py +++ b/nodes/_h3_prompt_helpers.py @@ -347,7 +347,7 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E scene.moments.append(Moment(ACTION_GOES_ON if video == KEEP else "")) if voice and not any(moment.speech for moment in scene.moments): raise ValueError(VOICE_NO_WORDS) - if ask is not None: + if ask is not None and video != SWAP: # A swap keeps the clip's own action; its model only writes the looks. add_moments(scene, seconds, ask, jobs) add_sound(scene, ask, jobs) audio += soundtrack and video == KEEP diff --git a/nodes/_h3_script_helpers.py b/nodes/_h3_script_helpers.py index 1376ba9..066bae5 100644 --- a/nodes/_h3_script_helpers.py +++ b/nodes/_h3_script_helpers.py @@ -727,11 +727,16 @@ def before_the_action(number, text, cast, wearers): for _, who, new in cast.swaps: picture = next((p for p in cast.swappers if subject_label(p.subject) == new), None) appearance = cast.appearances.get(picture.slot) if picture else None - if appearance: + if picture: + # Every swap gets the firm wording. Without it H3 kept the original person for the first seconds, + # or for the whole clip at Fast preview; with it the new + # person is there from the first frame. An @1 looks like line only adds its own words. + looks = (f"has the defined appearance from {picture_label(picture.picture)}: {appearance}" if appearance + else f"has the face, hair and clothes of the person in {picture_label(picture.picture)}") notes.append( f"{new} occupies the starting position of {who} and follows the same route, movements, timing and pace. " - f"{new} has the defined appearance from {picture_label(picture.picture)}: {appearance}. " - f"The replacement stays present throughout, ending at the original performance's final position. " + f"{new} {looks}. " + f"The replacement stays present throughout, from the first frame, ending at the original performance's final position. " f"Do not show {who} in that role at any time; there is no transition or morph into the replacement." ) else: diff --git a/nodes/node_h3_prompt_writer.py b/nodes/node_h3_prompt_writer.py index e4cf41b..f505c41 100644 --- a/nodes/node_h3_prompt_writer.py +++ b/nodes/node_h3_prompt_writer.py @@ -4,7 +4,7 @@ from __future__ import annotations import sys -from ._h3_cast_helpers import EDIT, JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS +from ._h3_cast_helpers import DESCRIBE_PERSON, add_looks, EDIT, JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS from ._h3_prompt_helpers import find_beats, find_speech, split_sections, write_prompt from ._h3_script_helpers import CAMERAS, LOOKS, script_report from ._route_errors import safe_route_error @@ -177,7 +177,8 @@ class AusBossH3PromptWriter: { "tooltip": ( "Optional: a small text model such as Qwen3-VL 8B. It adds " - "moments after a one-line idea and matches the sound to the place." + "moments after a one-line idea and matches the sound to the place. " + "In a swap it writes what the new person looks like, from their picture." ), }, ), @@ -335,6 +336,9 @@ class AusBossH3PromptWriter: kinds = {1: job_1, 2: job_2, 3: job_3, 4: job_4} jobs = {slot: kinds[slot] for slot in given} video_job = "" if video is None else video_job + if clip is not None and video_job == SWAP: + # The model on clip writes each new person's @N looks like line from their picture, when none is typed. + script = add_looks(script, jobs, lambda slot: model_asker(clip, given[slot])(DESCRIBE_PERSON, 60)) frames = clip_frames(seconds) if video_job in KEEPS_ITS_LENGTH: frames = video_frames(video.shape[0]) diff --git a/tests/test_h3_prompt_writer.py b/tests/test_h3_prompt_writer.py index 71f59eb..8570020 100644 --- a/tests/test_h3_prompt_writer.py +++ b/tests/test_h3_prompt_writer.py @@ -733,6 +733,63 @@ class VideoJobTests(unittest.TestCase): self.assertIn("