Compare commits

...
Author SHA1 Message Date
ausboss 89bda6ba11 Merge pull request #329 from ausboss/feat/text-lines-node
Text Lines: run what comes after once for each line of text
2026-11-11 19:56:16 -06:00
ausboss 47d1c4f739 Text Lines: run what comes after once for each line of text
A new node. Type a list of prompts, one on each line, or wire in a list
another node wrote. Empty lines are skipped, a number or bullet at the
start of a line can be taken off, and a limit stops a list that ran long.
2026-11-11 19:41:41 -06:00
12 changed files with 403 additions and 0 deletions
+6
View File
@@ -18,6 +18,12 @@ All notable changes to ComfyUI-AusBoss are documented here.
Edit Lock's `edit_lock` output. It also gives a mask of what the edit
changed and a one-line report.
- **New node: Text Lines.** It runs what comes after once for each line of
your text. Type five prompts, one on each line, and one run makes five
pictures. It also takes a list a text model wrote: empty lines are
skipped, a leading number or bullet comes off, and **Limit** stops a list
that ran long.
- **Mask by Touch: type a name to take it out of the mask.** The name field
under the picture now has a sign in front. Click it to switch between **+**
(add by name) and **−** (take away by name), then type "face" or "hair" and
+4
View File
@@ -357,6 +357,10 @@ Puts batch `b` after `a`. When the sizes match, nothing is resized. When they di
A text box that grows with the node and sends its text, unchanged, down a `STRING` wire. Use it for a prompt, caption, path or other text that several nodes share. Spaces and line breaks are kept. Ctrl + Up and Ctrl + Down change the weight of the selected words, as in ComfyUI's own prompt boxes.
### Text Lines
Runs what comes after once for each line of your text. Type five prompts, one on each line, and one run makes five pictures. It also takes a list another node wrote, skips empty lines, and can take a leading `1.` or `-` off each line. `number` counts the lines from 1, to give each its own seed.
### H3 Prompt Writer
Type what happens, one line for each moment, and leave a blank line to cut to a new place. Connect up to four pictures and give each one a job: a person, a place, an outfit, a thing or a style. Copy the moves from a video, keep a clip going, or add a voice or a song. A timeline on the node shows when each moment plays before you run it. It needs no extra model and you never see H3's prompt format. Connect a small text model and it fills in a one-line idea.
+1
View File
@@ -53,6 +53,7 @@ NODE_MODULES = [
"node_select_frame",
"node_show_text",
"node_step_preview",
"node_text_lines",
"node_tiled_upscale",
"node_video_crop_rotate_pad",
"node_video_crop_rotate_pad_clip",
+60
View File
@@ -0,0 +1,60 @@
# Text Lines
Runs what comes after once for each line of your text. Type five prompts,
one on each line, press Run once, and you get five pictures.
Use it for a list of prompts you typed, or for a list another node wrote,
such as a text model asked for "five different poses".
## How to use it
1. Type your lines in the box, or wire text into **text**.
2. Wire **line** to where one prompt would go, such as a text encoder's
prompt.
3. Run. Every node after it runs once for each line, and a Save Image at the
end saves one picture for each.
Empty lines are skipped. Models and pictures that do not depend on the line
are loaded once, not once for each line.
## Controls
- The text box (`text`): One line for each run.
- **Split at** (`split`): **each line** makes every line its own run.
**blank line** keeps lines together and starts the next run at an empty
line. Pick it when one prompt is several lines long.
- **Numbering** (`remove_numbering`): **remove** takes a number or bullet
off the start of each line, such as `1.`, `2)`, `-` or `[prompt 3]`.
Lists a text model writes usually have them. **keep** leaves every line
as written.
- **Limit** (`limit`): The most lines to run. 0 runs them all. Set it when
a text model might write more lines than you asked for.
## Outputs
- **line**: Each line, one at a time.
- **number**: The line's place in the list, starting at 1. Add it to a seed
with Math Expression (`a + b`) to give each line its own seed.
- **count**: How many lines run.
## Notes
- With no lines at all, the run stops at this node and says so.
- Every run uses the same seed unless you add **number** to it. Different
prompts still give different pictures.
- A line such as "2 women on a beach" or "1.5x zoom" keeps its number: only
a number followed by a period, bracket or colon counts as numbering.
## Technical details
**line** and **number** are ComfyUI lists (`OUTPUT_IS_LIST`), the same way
Image Folder hands out pictures. ComfyUI runs each later node once for each
item and keeps the results in order. A node that takes a whole list at once,
such as core Rebatch Images, gets all of them together.
Numbering that is removed: a number in square, round or CJK brackets with an
optional word before it (`[prompt 1]`, `(2)`), a word and a number followed by
a colon, period, bracket or dash (`Shot 3:`), a bare number followed by a
period, bracket or colon that has no digit right after it, and the bullets
`-`, `*` and `•` followed by a space. Only one marker is removed from each
line.
+1
View File
@@ -39,6 +39,7 @@ help panel.
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
| Run a workflow once for each line of text, such as a list of prompts | Text Lines (`AUSBOSS_NODES_TextLines`) |
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
| Do maths on numbers | Math Expression (`AUSBOSS_NODES_MathExpression`) |
| Show text on the node, or the picture while it is made | Show Text (`AUSBOSS_NODES_ShowText`), Step Preview (`AUSBOSS_NODES_StepPreview`) |
+11
View File
@@ -340,6 +340,17 @@ const CARDS = {
rows: [{ widget: "text", kind: "textarea", placeholder: "Prompt, caption, or shared text", height: 100, grow: true,
looks: { big: { height: 62, placeholder: "type here" } } }],
},
// One line for each run. The three settings under the box say where a run
// ends, whether a leading "1." comes off, and how many lines run at most.
AUSBOSS_NODES_TextLines: {
minWidth: 320,
rows: [
{ widget: "text", kind: "textarea", placeholder: "One line for each run", height: 100, grow: true },
{ widget: "split", label: "Split at", kind: "segment" },
{ widget: "remove_numbering", label: "Numbering", onText: "remove", offText: "keep" },
{ widget: "limit", label: "Limit" },
],
},
// All the words go in the box, and the strip under it shows when each
// moment plays. The pictures, the video, the voice and the song arrive on
// sockets, and each picture's job sits under the words.
+29
View File
@@ -1298,6 +1298,35 @@
}
}
},
"AUSBOSS_NODES_TextLines": {
"display_name": "逐行文本 🆎",
"description": "文本的每一行都会让后面的节点运行一次。输入五条提示词,每行一条,运行一次就得到五张图。空行会跳过,行首的序号或项目符号可以去掉。",
"inputs": {
"text": {
"name": "文本"
},
"split": {
"name": "分隔方式"
},
"remove_numbering": {
"name": "去掉序号"
},
"limit": {
"name": "上限"
}
},
"outputs": {
"0": {
"name": "行"
},
"1": {
"name": "序号"
},
"2": {
"name": "数量"
}
}
},
"AUSBOSS_NODES_TiledUpscale": {
"display_name": "分块放大 🆎",
"description": "以百万像素设置成品尺寸,并把图片切成模型可以逐块重画的图块。一块就能装下的图片保持完整。把图块送进采样器,再和 stitcher 一起送进分块放大拼合 🆎。小图不会被重画到远超其细节的程度:最多重画到原百万像素的 max_growth 倍,再用普通方式放大。report 会说明对你的图片做了什么。",
+67
View File
@@ -0,0 +1,67 @@
"""Cut a block of text into the items Text Lines runs one at a time.
Pure text handling with no ComfyUI imports, so it runs under the
standard-library tests.
"""
from __future__ import annotations
import re
SPLIT_EACH_LINE = "each line"
SPLIT_BLANK_LINE = "blank line"
SPLIT_MODES = (SPLIT_EACH_LINE, SPLIT_BLANK_LINE)
# The words a list writer puts before an item's number: "Prompt 3:", "Shot 2 -".
# Not "image" or "picture": an edit prompt may open with "Image 1: ...".
_LABEL = r"(?:prompt|shot|scene|line|step|item)"
# A marker at the start of an item that only counts or bullets it. A bare
# number needs punctuation after it, and that punctuation must not be followed
# by a digit, so "2 women on a beach", "1.5x zoom" and "10:30 at night" stay
# whole. A bullet needs a space after it, so "*bold* text" stays whole.
_MARKER = re.compile(
r"""^\s*(?:
[\[【(]\s*(?:%(label)s[\s_#-]*)?\d+\s*[\]】)]\s*[:.\-–—]? # [prompt 1] (2)
| %(label)s[\s_#-]*\d+\s*[:.)\-–—] # Prompt 1: Shot 2 -
| \#?\d+\s*[.):](?!\d) # 1. 2) #3:
| [-*•·–—](?=\s|$) # - bullet
)\s*"""
% {"label": _LABEL},
re.IGNORECASE | re.VERBOSE,
)
_BLANK_LINE = re.compile(r"\n[ \t]*(?:\n[ \t]*)+")
def strip_marker(item: str) -> str:
"""Take one leading number or bullet off an item."""
return _MARKER.sub("", item, count=1).strip()
def split_items(text, split=SPLIT_EACH_LINE, remove_numbering=True, limit=0) -> list[str]:
"""The items of `text`, in order, with empty ones left out.
`split` picks where one item ends: at every line break, or only at an
empty line (so an item can hold several lines). `remove_numbering` takes
a leading "1." or "-" off each item. `limit` keeps the first so many;
0 keeps them all.
"""
text = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
if split == SPLIT_BLANK_LINE:
parts = _BLANK_LINE.split(text)
else:
parts = text.split("\n")
items = []
for part in parts:
item = part.strip()
if remove_numbering:
item = strip_marker(item)
if item:
items.append(item)
limit = int(limit or 0)
if limit > 0:
items = items[:limit]
return items
+100
View File
@@ -0,0 +1,100 @@
"""Text Lines 🆎 — run what comes after once for each line of text."""
from __future__ import annotations
from ._text_lines_helpers import SPLIT_EACH_LINE, SPLIT_MODES, split_items
MAX_LIMIT = 1000
class AusBossTextLines:
CATEGORY = "🆎 AusBoss/Utility"
DESCRIPTION = (
"Runs what comes after once for each line of your text. Type five "
"prompts, one on each line, and one run makes five pictures. Empty "
"lines are skipped, and a number or bullet at the start of a line "
"can be taken off."
)
SEARCH_ALIASES = [
"prompt list",
"one prompt per line",
"each line",
"split text",
"text to list",
"batch prompts",
"queue prompts",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "One line for each run. Type it here, or wire in text another node wrote.",
},
),
"split": (
list(SPLIT_MODES),
{
"default": SPLIT_EACH_LINE,
"tooltip": (
"each line: every line is its own run. blank line: lines stay "
"together, and an empty line starts the next run."
),
},
),
"remove_numbering": (
"BOOLEAN",
{
"default": True,
"tooltip": (
'On: a number or bullet at the start is taken off, like "1.", "-" '
'or "[prompt 2]". Off: every line stays as written.'
),
},
),
"limit": (
"INT",
{
"default": 0,
"min": 0,
"max": MAX_LIMIT,
"step": 1,
"tooltip": "The most lines to run. 0 runs them all.",
},
),
},
}
RETURN_TYPES = ("STRING", "INT", "INT")
RETURN_NAMES = ("line", "number", "count")
OUTPUT_IS_LIST = (True, True, False)
OUTPUT_TOOLTIPS = (
"Each line, one at a time. What comes after runs once for each.",
"The line's place in the list, starting at 1. Add it to a seed to give each run its own.",
"How many lines run.",
)
FUNCTION = "split"
def split(self, text, split=SPLIT_EACH_LINE, remove_numbering=True, limit=0):
items = split_items(text, split, remove_numbering, limit)
if not items:
raise ValueError(
"Text Lines has no lines to run. Type at least one line, or check the node that writes its text."
)
return (items, list(range(1, len(items) + 1)), len(items))
NODE_CLASS_MAPPINGS = {
"AUSBOSS_NODES_TextLines": AusBossTextLines,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AUSBOSS_NODES_TextLines": "Text Lines 🆎",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+1
View File
@@ -94,6 +94,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_StitchInpaint",
"AUSBOSS_NODES_TiledUpscaleStitch",
"AUSBOSS_NODES_Text",
"AUSBOSS_NODES_TextLines",
"AUSBOSS_NODES_TiledUpscale",
"AUSBOSS_NODES_VideoCropRotatePad",
"AUSBOSS_NODES_VideoCropRotatePadClip",
+23
View File
@@ -1036,6 +1036,29 @@
]
]
},
"AUSBOSS_NODES_TextLines": {
"required": [
"text",
"split",
"remove_numbering",
"limit"
],
"optional": [],
"outputs": [
[
"STRING",
"line"
],
[
"INT",
"number"
],
[
"INT",
"count"
]
]
},
"AUSBOSS_NODES_TiledUpscale": {
"required": [
"image",
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._text_lines_helpers import split_items, strip_marker
from nodes.node_text_lines import AusBossTextLines
class SplitItemsTests(unittest.TestCase):
def test_each_line_is_an_item_and_empty_lines_are_skipped(self):
text = "a red car\n\n \na blue boat \r\n\tgreen hills\n"
self.assertEqual(split_items(text), ["a red car", "a blue boat", "green hills"])
def test_blank_line_keeps_lines_together(self):
text = "first line\nsecond line\n\n\nnext item\n \nlast"
self.assertEqual(
split_items(text, "blank line"),
["first line\nsecond line", "next item", "last"],
)
def test_limit_keeps_the_first_items(self):
self.assertEqual(split_items("a\nb\nc\nd", limit=2), ["a", "b"])
self.assertEqual(split_items("a\nb", limit=0), ["a", "b"])
self.assertEqual(split_items("a\nb", limit=9), ["a", "b"])
def test_numbers_and_bullets_come_off(self):
cases = {
"1. close-up, soft smile": "close-up, soft smile",
"2) medium shot": "medium shot",
"#3: from above": "from above",
"- a bullet": "a bullet",
"* a star": "a star",
"• a dot": "a dot",
"[prompt 4] from the side": "from the side",
"【prompt_5】 looking back": "looking back",
"(6) arms crossed": "arms crossed",
"Prompt 7: laughing": "laughing",
"Shot 8 - wide": "wide",
"12. twelve": "twelve",
}
for line, want in cases.items():
self.assertEqual(strip_marker(line), want, line)
def test_text_that_only_starts_like_a_marker_stays(self):
for line in [
"2 women on a beach",
"1.5x zoom on the face",
"10:30 at night, a diner",
"3D render of a robot",
"*bold* light from the left",
"-90 degrees, seen from below",
"1920s street, a man in a hat",
"shot from below",
"Image 1: the woman, in the coat from image 2",
]:
self.assertEqual(strip_marker(line), line, line)
def test_numbering_stays_when_asked(self):
self.assertEqual(split_items("1. a\n- b", remove_numbering=False), ["1. a", "- b"])
def test_a_line_that_is_only_a_marker_is_skipped(self):
self.assertEqual(split_items("1.\n2. real\n-"), ["real"])
def test_nothing_gives_nothing(self):
self.assertEqual(split_items(""), [])
self.assertEqual(split_items(None), [])
self.assertEqual(split_items("\n \n"), [])
class TextLinesNodeTests(unittest.TestCase):
def test_outputs_lines_numbers_and_count(self):
lines, numbers, count = AusBossTextLines().split("1. one\n2. two\n\n3. three")
self.assertEqual(lines, ["one", "two", "three"])
self.assertEqual(numbers, [1, 2, 3])
self.assertEqual(count, 3)
def test_the_line_and_number_outputs_are_lists(self):
self.assertEqual(AusBossTextLines.OUTPUT_IS_LIST, (True, True, False))
self.assertEqual(AusBossTextLines.RETURN_NAMES, ("line", "number", "count"))
def test_no_lines_stops_the_run_with_plain_words(self):
with self.assertRaises(ValueError) as caught:
AusBossTextLines().split(" \n")
self.assertIn("no lines to run", str(caught.exception))
def test_inputs_and_their_order(self):
inputs = AusBossTextLines.INPUT_TYPES()
self.assertEqual(list(inputs), ["required"])
self.assertEqual(list(inputs["required"]), ["text", "split", "remove_numbering", "limit"])
if __name__ == "__main__":
unittest.main()