commit 7faf9951acc4016cd31cd79116ac4442d9c0f02e Author: unknown Date: Sat Dec 6 15:16:30 2025 +0100 Initial commit diff --git a/.vscode/easycode.ignore b/.vscode/easycode.ignore new file mode 100644 index 0000000..d7cdc53 --- /dev/null +++ b/.vscode/easycode.ignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +vendor/ +cache/ +.*/ +*.min.* +*.test.* +*.spec.* +*.bundle.* +*.bundle-min.* +*.log +package-lock.json \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b3f974b --- /dev/null +++ b/__init__.py @@ -0,0 +1,14 @@ +from .sequence_prompt_nodes import ( + MultiClipTextScriptMain, + MultiClipTextScriptClipSelector, +) + +NODE_CLASS_MAPPINGS = { + "MultiClipTextScriptMain": MultiClipTextScriptMain, + "MultiClipTextScriptClipSelector": MultiClipTextScriptClipSelector, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "MultiClipTextScriptMain": "Multi-Clip Text Script: Main", + "MultiClipTextScriptClipSelector": "Multi-Clip Text Script: Clip Selector", +} diff --git a/multiclip01.PNG b/multiclip01.PNG new file mode 100644 index 0000000..9fefefc Binary files /dev/null and b/multiclip01.PNG differ diff --git a/multiclip02.PNG b/multiclip02.PNG new file mode 100644 index 0000000..3990522 Binary files /dev/null and b/multiclip02.PNG differ diff --git a/multiclip03.PNG b/multiclip03.PNG new file mode 100644 index 0000000..2347a09 Binary files /dev/null and b/multiclip03.PNG differ diff --git a/multiclip04.PNG b/multiclip04.PNG new file mode 100644 index 0000000..cdea4e9 Binary files /dev/null and b/multiclip04.PNG differ diff --git a/multiclip_main.PNG b/multiclip_main.PNG new file mode 100644 index 0000000..fe91e95 Binary files /dev/null and b/multiclip_main.PNG differ diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..629896c --- /dev/null +++ b/readme.md @@ -0,0 +1,232 @@ +# ComfyUI Multi-Clip Text Script Nodes + + +Multi-Clip Text Script - Main overview + + +## How to use it (visual) + + + + + + + + + + +
Step 1 - Configure Main nodeStep 2 - Author script with clips
Step 3 - Select clip and formatStep 4 - Connect to encoder and render
+ +This ComfyUI node was created while I was making Wan 2.2 videos with 81 frames and crossfading them together. I found myself dragging and clicking through different parts of the workflow to change clip text prompts, so I wanted a faster, centralized place to edit all prompts. + +This node was generated with ChatGPT 5.1, and this README was also written by ChatGPT. + +Two simple nodes that help you author sequence-friendly prompts from a single script that contains multiple clips. You write one text block with (clip01), (clip02), … sections, then select one clip at a time to produce a clean prompt (with shared prefix/suffix and negative prompt). + +Nodes +- Multi-Clip Text Script: Main +- Multi-Clip Text Script: Clip Selector + +Category +- Text / Multi-Clip + +File +- sequence_prompt_nodes.py + + +## Why this exists + +- Keep consistent prompt parts across many shots (prefix/suffix/negative) while varying per-clip action text. +- Author all clips once, then iterate and render per-clip without copy/paste. +- Optional auto formatting to output a classic “prefix, clip, suffix.” prompt line. + + +## Installation +- Go to your ComfyUI/custom_nodes folder. + +Clona the repo (exempel): + +cd ComfyUI/custom_nodes +git clone https://github.com/TuonoMindCode/ComfyUI-MultiClip-Text-Script.git + +or + +Copy sequence_prompt_nodes.py and __init__.py into your ComfyUI/custom_nodes/ComfyUI-MultiClip-Text-Script. +Restart ComfyUI. +Search for: + - “Multi-Clip Text Script: Main” + - “Multi-Clip Text Script: Clip Selector” + + +## Script format + +The Main node parses a single multiline script using labeled sections: + +- Label each section with (clipXX) where XX is a number (1-based). +- Case-insensitive. Any number of digits is supported. +- Text for a clip runs until the next (clipYY) or the end of the script. + +Example script + +(clip01) woman walks forward and waves to the camera + +(clip02) woman walks forward and gets angry + +(clip03) woman throws the phone + + +Clips are sorted numerically by their label: (clip1), (clip02), (clip10) → 1, 2, 10. + + +## Node: Multi-Clip Text Script: Main + +Purpose +- Parse the multi-clip script, store prefix/suffix and per-clip texts in an internal string (MULTICLIP_PAIR_DATA). +- Provide total clip count and a shared negative prompt for downstream nodes. + +Inputs (required) +- prefix_text (STRING, multiline): Common description always included (subject, style, tempo, etc.). +- multi_clip_script (STRING, multiline): Your script with (clipXX) sections. +- suffix_text (STRING, multiline): Appended after every clip (camera style, quality, etc.). + +Inputs (optional) +- negative_text (STRING, multiline): One negative prompt used for every clip. + +Outputs +- pair_data (MULTICLIP_PAIR_DATA): Internal encoded data with prefix, suffix, and all clips. +- num_clips (INT): Number of parsed clips. +- multi_clip_negative (MULTICLIP_NEGATIVE): The shared negative prompt. + +Details +- If no clips are found, pair_data still contains prefix and suffix. num_clips is 0. +- Leading/trailing whitespace is trimmed from all pieces. + + +## Node: Multi-Clip Text Script: Clip Selector + +Purpose +- Select one clip (1-based index) from pair_data and build the final prompt text. + +Inputs (required) +- pair_data (MULTICLIP_PAIR_DATA): Output from Main. +- clip_number (INT): 1-based index of the clip to use. + +Inputs (optional) +- auto_format (BOOLEAN): + - False: Join with blank lines: prefix + blank + clip + blank + suffix. + - True: Produce “prefix, clip, suffix.” and clean trailing commas/semicolons/spaces. +- log_to_console (BOOLEAN): Print the combined prompt for debugging. +- negative (MULTICLIP_NEGATIVE): Connect from Main’s multi_clip_negative to pass the shared negative forward. + +Outputs +- clip_text (STRING): The final prompt for the selected clip. +- negative_text (STRING): The negative prompt passthrough (if provided). + +Behavior notes +- Out-of-range clip_number is clamped to the valid range. +- If pair_data had no clips, the output becomes a combination of prefix/suffix only. +- Cleaning rules used by auto_format remove trailing commas, semicolons, and spaces on each piece before joining. The final text gets a period if it ends without punctuation. + + +## Quick example + +Inputs to Main +- prefix_text: + “cinematic, natural light, handheld” +- multi_clip_script: + ``` + (clip01) + woman walks forward and waves to the camera + + (clip02) + woman walks forward and gets angry + + (clip03) + woman throws the phone + ``` +- suffix_text: + “soft bokeh, film grain” +- negative_text: + “low quality, blurry, artifacts” + +Main outputs +- pair_data → connect to Clip Selector +- num_clips → e.g., 3 +- multi_clip_negative → connect to Clip Selector’s negative + +Clip Selector +- clip_number: 2 +- auto_format: False → Output: + ``` + cinematic, natural light, handheld + + woman walks forward and gets angry + + soft bokeh, film grain + ``` +- auto_format: True → Output: + ``` + cinematic, natural light, handheld, woman walks forward and gets angry, soft bokeh, film grain. + ``` +- negative_text: “low quality, blurry, artifacts” + + +## Typical workflow + +- Multi-Clip Text Script: Main + - pair_data → Multi-Clip Text Script: Clip Selector.pair_data + - multi_clip_negative → Multi-Clip Text Script: Clip Selector.negative +- Multi-Clip Text Script: Clip Selector + - clip_text → your text encoder’s positive prompt input + - negative_text → your text encoder’s negative prompt input +- Render per-clip by changing clip_number or automating it in a loop. + + +## Internal encoding (for reference) + +- pair_data is a single string combining: + - prefix + - suffix + - all clip texts, in order +- Separators (do not type these yourself; they are inserted by the node): + - META_SEP = “\n<<>>\n” + - CLIP_SEP = “\n<<>>\n” +- Layout: + - prefix + META_SEP + suffix + META_SEP + (clip1 + CLIP_SEP + clip2 + CLIP_SEP + …) + + +## Tips + +- Use auto_format = True for a compact, comma-separated prompt line. +- Keep prefix/suffix relatively short to avoid overly long prompts when combined. +- Enable log_to_console to debug the exact text sent to your text encoder. +- If no (clipXX) sections are detected, num_clips = 0; you can still use prefix/suffix. + + +## Troubleshooting + +- No clips detected + - Ensure each section starts with (clip01), (clip02), … on its own line or followed by whitespace. + - Labels are case-insensitive but must follow the (clip) pattern. +- Wrong clip selected + - clip_number is 1-based. 1 = first clip. + - Values out of range are clamped to the nearest valid index. +- Extra punctuation + - Use auto_format = True to remove trailing commas/semicolons/spaces from each part before joining. + + +## Development notes + +- Node classes: + - MultiClipTextScriptMain + - MultiClipTextScriptClipSelector +- Display names: + - “Multi-Clip Text Script: Main” + - “Multi-Clip Text Script: Clip Selector” +- Category: “Text / Multi-Clip” +- Regex used to parse clips: + - (clip(\d+)) followed by text until the next (clipN) or end of string. + - DOTALL and IGNORECASE enabled. +- Sorting: + - Clips are sorted numerically by the parsed number part (e.g., clip02 → 2). + diff --git a/sequence_prompt_nodes.py b/sequence_prompt_nodes.py new file mode 100644 index 0000000..cfc592e --- /dev/null +++ b/sequence_prompt_nodes.py @@ -0,0 +1,317 @@ +import re + +# Separatorer för intern kodning i pair_data +CLIP_SEP = "\n<<>>\n" +META_SEP = "\n<<>>\n" + + +class MultiClipTextScriptMain: + """ + Multi-Clip Text Script: Main + + Tar text från t.ex. Ollama eller någon annan källa + konstant prefix/suffix: + + prefix_text: + Beskrivning som alltid ska vara med (personen, scenen, stil, etc). + + multi_clip_script (exempel): + (clip01) + woman waves at the camera + + (clip02) + woman gets angry + + (clip03) + woman calls someone + + suffix_text: + Text som läggs till efter varje klipp (t.ex. kamera-stil, kvalitet, osv). + + negative_text: + Negativ prompt som är gemensam för alla klipp. + + Scriptet parsas i formatet: + + (clip01) + + + (clip02) + + + ... + + Och omvandlas till ett internt paket (MULTICLIP_PAIR_DATA) där vi sparar: + - prefix + - suffix + - alla clip-texter i ordning + + Sedan kan "Multi-Clip Text Script: Clip Selector" plocka ut + en clip-text i taget och bygga en färdig prompt. + + Output: + pair_data (MULTICLIP_PAIR_DATA) – intern sträng med all data + num_clips (INT) – hur många klipp som hittades + multi_clip_negative (MULTICLIP_NEGATIVE) – negativ prompt för alla klipp + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + # 1. Prefix först + "prefix_text": ( + "STRING", + { + "multiline": True, + "default": "", + }, + ), + # 2. Huvudfönstret för (clip01)(clip02)...-scriptet + "multi_clip_script": ( + "STRING", + { + "multiline": True, + "default": "(clip01)\nwoman walks forward and waves to the camera\n\n" + "(clip02)\nwoman walks forward and gets angry\n\n" + "(clip03)\nwoman throws the phone", + }, + ), + # 3. Suffix sist + "suffix_text": ( + "STRING", + { + "multiline": True, + "default": "", + }, + ), + }, + "optional": { + "negative_text": ( + "STRING", + { + "multiline": True, + "default": "", + }, + ), + }, + } + + RETURN_TYPES = ("MULTICLIP_PAIR_DATA", "INT", "MULTICLIP_NEGATIVE") + RETURN_NAMES = ("pair_data", "num_clips", "multi_clip_negative") + FUNCTION = "parse_script" + CATEGORY = "Text / Multi-Clip" + + def parse_script( + self, + prefix_text: str, + multi_clip_script: str, + suffix_text: str, + negative_text: str = "", + ): + # Hitta alla (clipXX) + text fram till nästa (clipYY) eller slutet + pattern = r"\(clip(\d+)\)\s*(.*?)(?=\n\(clip\d+\)|\Z)" + matches = re.findall(pattern, multi_clip_script, flags=re.DOTALL | re.IGNORECASE) + + clips = [] + for idx, text in matches: + text = text.strip() + if text: + clips.append((int(idx), text)) # "01" -> 1 osv, för sortering + + # Sortera efter clip-nummer + clips.sort(key=lambda x: x[0]) + + prefix_clean = (prefix_text or "").strip() + suffix_clean = (suffix_text or "").strip() + negative_clean = (negative_text or "").strip() + + if not clips: + # Inga klipp hittade – koda ändå prefix/suffix men utan clips + payload = META_SEP.join([prefix_clean, suffix_clean, ""]) + return (payload, 0, negative_clean) + + # Själva klipp-texterna i ordning + clip_texts = [t for _, t in clips] + clips_blob = CLIP_SEP.join(clip_texts) + + # Kodning i pair_data: + # prefix <> suffix <> clip1 <> clip2 <> ... + payload = META_SEP.join([prefix_clean, suffix_clean, clips_blob]) + num_clips = len(clip_texts) + + return (payload, num_clips, negative_clean) + + +class MultiClipTextScriptClipSelector: + """ + Multi-Clip Text Script: Clip Selector + + Tar emot pair_data från MultiClipTextScriptMain (typ MULTICLIP_PAIR_DATA) + och väljer EN specifik klipp-text baserat på clip_number (1-baserat). + + clip_number (1-baserat i UI): + 1 -> första klipp-texten (clip01) + 2 -> andra (clip02) + 3 -> tredje (clip03) + osv. + + auto_format: + - False: prefix + tomrad + clip + tomrad + suffix + - True: "prefix, clip, suffix." (klassisk prompt-stil) + + rensar bort extra kommatecken i slutet av varje del. + + negative: + - Input av typen MULTICLIP_NEGATIVE (ingen egen text-ruta i denna nod). + Typisk kedja: + Multi-Clip Text Script: Main (multi_clip_negative) + → Anything Everywhere + → Multi-Clip Text Script: Clip Selector (negative) + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "pair_data": ("MULTICLIP_PAIR_DATA", {}), + "clip_number": ( + "INT", + { + "default": 1, + "min": 1, + "max": 999, + "step": 1, + }, + ), + }, + "optional": { + "auto_format": ( + "BOOLEAN", + { + "default": False, + }, + ), + "log_to_console": ( + "BOOLEAN", + { + "default": False, + }, + ), + "negative": ( + "MULTICLIP_NEGATIVE", + {}, + ), + }, + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("clip_text", "negative_text") + FUNCTION = "select_pair" + CATEGORY = "Text / Multi-Clip" + + def select_pair( + self, + pair_data: str, + clip_number: int, + auto_format: bool = False, + log_to_console: bool = False, + negative: str = "", + ): + if not pair_data: + return ("", negative) + + # Förväntat format: + # prefix <> suffix <> clips_blob + parts = pair_data.split(META_SEP) + if len(parts) == 3: + prefix, suffix, clips_blob = parts + elif len(parts) == 2: + prefix, clips_blob = parts + suffix = "" + else: + prefix = "" + suffix = "" + clips_blob = parts[0] if parts else "" + + clips_blob = clips_blob or "" + clips = clips_blob.split(CLIP_SEP) if clips_blob else [] + + internal_index = clip_number - 1 + + if not clips: + combined = self._combine(prefix, "", suffix, auto_format) + if log_to_console and combined: + print("[Multi-Clip Selector] (no clips) →", combined) + return (combined, negative) + + if internal_index < 0: + internal_index = 0 + if internal_index >= len(clips): + internal_index = len(clips) - 1 + + clip_raw = clips[internal_index] + + combined = self._combine(prefix, clip_raw, suffix, auto_format) + + if log_to_console and combined: + print(f"[Multi-Clip Selector] clip_number={clip_number} →", combined) + + return (combined, negative) + + @staticmethod + def _clean_piece(piece: str) -> str: + if not piece: + return "" + p = piece.strip() + while p and p[-1] in [",", ";", " "]: + p = p[:-1] + return p.strip() + + @staticmethod + def _combine(prefix: str, clip: str, suffix: str, auto_format: bool) -> str: + prefix = prefix or "" + clip = clip or "" + suffix = suffix or "" + + if not auto_format: + parts = [] + if prefix.strip(): + parts.append(prefix.strip()) + if clip.strip(): + parts.append(clip.strip()) + if suffix.strip(): + parts.append(suffix.strip()) + return "\n\n".join(parts) + + prefix_clean = MultiClipTextScriptClipSelector._clean_piece(prefix) + clip_clean = MultiClipTextScriptClipSelector._clean_piece(clip) + suffix_clean = MultiClipTextScriptClipSelector._clean_piece(suffix) + + parts = [] + if prefix_clean: + parts.append(prefix_clean) + if clip_clean: + parts.append(clip_clean) + if suffix_clean: + parts.append(suffix_clean) + + if not parts: + return "" + + text = ", ".join(parts) + + text = text.strip() + if text and text[-1] not in [".", "!", "?"]: + text += "." + return text + + +# Registrering för ComfyUI +NODE_CLASS_MAPPINGS = { + "MultiClipTextScriptMain": MultiClipTextScriptMain, + "MultiClipTextScriptClipSelector": MultiClipTextScriptClipSelector, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "MultiClipTextScriptMain": "Multi-Clip Text Script: Main", + "MultiClipTextScriptClipSelector": "Multi-Clip Text Script: Clip Selector", +}