Initial commit
This commit is contained in:
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
dist/
|
||||
vendor/
|
||||
cache/
|
||||
.*/
|
||||
*.min.*
|
||||
*.test.*
|
||||
*.spec.*
|
||||
*.bundle.*
|
||||
*.bundle-min.*
|
||||
*.log
|
||||
package-lock.json
|
||||
+14
@@ -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",
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
@@ -0,0 +1,232 @@
|
||||
# ComfyUI Multi-Clip Text Script Nodes
|
||||
|
||||
<!-- Added a full-width overview image at the very top -->
|
||||
<img src="./multiclip_main.PNG" alt="Multi-Clip Text Script - Main overview" width="100%" />
|
||||
|
||||
|
||||
## How to use it (visual)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="./multiclip01.PNG" alt="Step 1 - Configure Main node" width="100%"></td>
|
||||
<td><img src="./multiclip02.PNG" alt="Step 2 - Author script with clips" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="./multiclip03.PNG" alt="Step 3 - Select clip and format" width="100%"></td>
|
||||
<td><img src="./multiclip04.PNG" alt="Step 4 - Connect to encoder and render" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
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<<<MULTICLIP_META_SEP>>>\n”
|
||||
- CLIP_SEP = “\n<<<MULTICLIP_CLIP_SEP>>>\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<number>) 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).
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import re
|
||||
|
||||
# Separatorer för intern kodning i pair_data
|
||||
CLIP_SEP = "\n<<<MULTICLIP_CLIP_SEP>>>\n"
|
||||
META_SEP = "\n<<<MULTICLIP_META_SEP>>>\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)
|
||||
<text>
|
||||
|
||||
(clip02)
|
||||
<text>
|
||||
|
||||
...
|
||||
|
||||
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 <<META_SEP>> suffix <<META_SEP>> clip1 <<CLIP_SEP>> clip2 <<CLIP_SEP>> ...
|
||||
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 <<META_SEP>> suffix <<META_SEP>> 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",
|
||||
}
|
||||
Reference in New Issue
Block a user