Add the Multy_prompts node and text-to-image sample

This commit is contained in:
JDJoe
2026-11-05 16:05:50 +02:00
commit 330c306932
8 changed files with 1864 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+128
View File
@@ -0,0 +1,128 @@
# Multy_prompts
![Text-to-image sample](screenshot.webp)
A ComfyUI node that turns one character box, one shared prefix, and one prompt wall into one finished prompt and one seed per image. The node does not load a model and does not sample. Your existing graph runs once per plate.
Search for **Multy_prompts**. It is under **utilities → prompt**.
## Install
Copy this folder into `ComfyUI/custom_nodes/` and restart ComfyUI.
The graph also uses three built-in nodes:
| Node | Menu |
|---|---|
| Start Loop | utilities → looping |
| End Loop | utilities → looping |
| Get Item From List | utilities |
## What to add
Keep the model loader, CLIP, VAE, LoRAs, empty latent, negative prompt, sampler, decode, and Save Image that you already use. Add these four nodes:
1. **Multy_prompts**
2. **Start Loop**
3. **Get Item From List**
4. **End Loop**
Set **Start Loop** mode to **List**. On the sampler, set the seed control to **fixed**. **randomize** replaces the seed from this node.
Leave **Start Loop** `parent_iteration` and `initial_iteration_value` empty. Those are for a loop inside another loop, and for a value carried from pass to pass.
## Connections
**Multy_prompts** has two outputs. **Start Loop** can walk only one list, so the prompts use the loop and the seeds use **Get Item From List**.
| From | To |
|---|---|
| Multy_prompts `prompts` | Start Loop `list` |
| Start Loop `list_item` | positive text encoder `prompt` |
| Multy_prompts `seeds` | Get Item From List `list` |
| Start Loop `iteration_index` | Get Item From List `index` |
| Get Item From List output | sampler `seed` |
| Save Image `images` | End Loop `termination` |
Drag `list_item` onto the prompt text box, and drag the **Get Item From List** output onto the sampler seed box. That turns each widget into an input.
The positive encode, the sampler, the VAE decode, and Save Image sit between **Start Loop** and **End Loop**. One queue then writes one image per plate.
Model, CLIP, VAE, LoRAs, latent size, and the negative prompt stay outside the loop. They load once and are reused for every plate.
```
Multy_prompts.prompts ──► Start Loop.list
Start Loop.list_item ──► positive text encoder.prompt ──► sampler ──► VAE Decode ──► Save Image
│
Multy_prompts.seeds ──► Get Item From List.list │
Start Loop.iteration_index ──► Get Item From List.index │
Get Item From List ──► sampler.seed │
▼
End Loop.termination
```
## Prefix
Optional. Style text, LoRA triggers, or anything else that stays the same. It is pasted in front of every plate. A blank box adds nothing.
```
35mm photo, natural light, fine grain
```
`CHAR` tokens in the prefix are replaced the same way as in a plate. The plate id is still not included.
## Characters
Optional. Leave the box empty and the prompts go out unchanged.
One character per block. A blank line separates blocks. The block starts with the token. The description can follow on that same line, or on the lines under it. Write a phrase, because that phrase is pasted in place of the token.
```
CHAR1 A woman in her thirties with short black hair and a grey coat.
CHAR2
A tall man with a scar on his left cheek.
```
Tokens are `CHAR` plus a positive integer: `CHAR1`, `CHAR2`, `CHAR10`. `CHAR10` is not treated as `CHAR1`. A token with no character block is left as written. A prompt that never names a character is not rewritten.
## Prompts
Same headers as a PlatePress shot wall. A plate id on its own line starts the next image. Blank lines inside a plate stay in the text. The id is not sent to the sampler.
`p01` and `p01_station` both count. `p1` and `p001` are the same plate. Text after the id on that same line is the first line of the prompt.
```
p01
CHAR1 stands in the rain outside a station.
p02_letter
CHAR1 hands CHAR2 a letter in a dark hallway.
The hall is dark. A single bulb hangs over the door.
```
## Seeds
One line per plate, in the same order as the plates. An integer keeps that seed. An empty line chooses a seed when you queue. An empty box chooses a seed for every plate.
```
657539441289641
1001
```
The blank second line is a chosen seed for `p02`. The number of lines must match the number of plates.
## Sample workflow
`krea2_t2i_multy_prompts.json` is the text-to-image sample. Start Loop is in List mode, the sampler seed control is fixed, and the output is 768×1376. There is no reference image.
The positive encoder is **Text Encode Qwen Image Edit Plus**. Its image slots stay empty. The plate from Start Loop connects to its prompt. The negative stays on **CLIP Text Encode**, taken straight from the CLIP loader.
The model is `KREA2/krea2_turbo_fp8_scaled.safetensors`. **Lora Loader Stack (rgthree)** sits on the model and on the positive CLIP. Slot 1 is `KREA2/Krea_2_Xenoflora_Trees_tcrzit_BF16-3K.safetensors` at 0.75. That stack needs [rgthree-comfy](https://github.com/rgthree/rgthree-comfy).
The identity-edit graph, which keeps a person from a reference photo, is [krea2-ref-image-multy-prompts](https://github.com/JDJoe/krea2-ref-image-multy-prompts).
## Not in this version
Reference images are not an input on this node. A later version can attach a character image to every plate that names that character, and a scene image to one plate. The text path above does not have to change for that.
+147
View File
@@ -0,0 +1,147 @@
"""Prompt Batch custom node for ComfyUI.
Copy this folder to ComfyUI/custom_nodes/ and restart ComfyUI.
"""
from __future__ import annotations
import random
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
from .prompt_batch import SEED_MAX, build_batch, seeds_are_rolled
class PromptBatch(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="PromptBatch",
display_name="Multy_prompts",
category="utilities/prompt",
description=(
"Build one finished prompt and one seed per image. "
"A prefix is pasted in front of every plate. "
"Connect prompts to Start Loop (List mode). "
"Connect seeds to Get Item From List with index from iteration_index."
),
search_aliases=[
"Multyprompts",
"Multy prompts",
"Multy_prompts",
"Prompt Batch",
"multi prompt",
"characters",
"prompt list",
"seed list",
],
inputs=[
io.String.Input(
"characters",
display_name="Characters",
multiline=True,
default="",
dynamic_prompts=False,
placeholder="CHAR1\nA woman in her thirties with short black hair.\n\nCHAR2\nA tall man with a scar on his left cheek.",
tooltip=(
"Optional. One character per block, blank line between blocks. "
"The block starts with the token (CHAR1, CHAR2, CHAR10). "
"The description can follow on that same line, or on the lines under it. "
"It is pasted in place of the token, so write a phrase. "
"An empty box leaves the prompts unchanged."
),
),
io.String.Input(
"prompts",
display_name="Prompts",
multiline=True,
default="",
dynamic_prompts=False,
placeholder="p01\nCHAR1 stands in the rain outside a station.\n\np02\nCHAR1 hands CHAR2 a letter in a dark hallway.",
tooltip=(
"One image per plate. A line like p01 or p01_station starts the next prompt. "
"Blank lines inside a plate stay in the text. The plate id is not sent to the sampler. "
"CHAR tokens are replaced with that character's description."
),
),
io.String.Input(
"seeds",
display_name="Seeds",
multiline=True,
default="",
dynamic_prompts=False,
placeholder="657539441289641\n\n1001",
tooltip=(
"One line per plate, in the same order as p01, p02, and so on. "
"An integer keeps that seed. An empty line chooses a seed when you queue. "
"An empty box chooses a seed for every prompt. "
"On the sampler, set the seed control to fixed so its widget does not replace this seed."
),
),
io.String.Input(
"prefix",
display_name="Prefix",
multiline=True,
default="",
dynamic_prompts=False,
placeholder="35mm photo, natural light, fine grain",
tooltip=(
"Optional. Pasted in front of every plate, before that plate's own text. "
"Use it for a style line, LoRA triggers, or any text that stays the same. "
"An empty box adds nothing. CHAR tokens here are replaced too."
),
),
],
outputs=[
io.String.Output(
id="prompts",
display_name="prompts",
is_output_list=True,
tooltip="One finished prompt per image. Connect to Start Loop, mode List.",
),
io.Int.Output(
id="seeds",
display_name="seeds",
is_output_list=True,
tooltip=(
"One integer seed per prompt. "
"Connect to Get Item From List. Set its index from Start Loop iteration_index, "
"then connect the item to the sampler seed."
),
),
],
)
@classmethod
def fingerprint_inputs(cls, characters, prompts, seeds, prefix):
# Blank seeds are chosen at queue time, so that queue must not reuse
# the previous list. Explicit seeds stay cached with the text.
if seeds_are_rolled(seeds):
return float("nan")
return False
@classmethod
def execute(cls, characters, prompts, seeds, prefix) -> io.NodeOutput:
shots = build_batch(
characters,
prompts,
seeds,
lambda: random.randrange(0, SEED_MAX + 1),
prefix,
)
return io.NodeOutput(
[shot.prompt for shot in shots],
[shot.seed for shot in shots],
)
class PromptBatchExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [PromptBatch]
async def comfy_entrypoint() -> PromptBatchExtension:
return PromptBatchExtension()
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
"""Turn character blocks, plate prompts, and seed lines into one shot per image.
Each shot is a finished prompt and an integer seed. Prompts use PlatePress
plate ids: a line such as p01 or p01_station starts the next image, and blank
lines inside that plate stay in the prompt. A later version can hang reference
images on the same shot. This version does not read images. Character
descriptions are plain text, so a token written inside a description is not
expanded.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# ComfyUI seed sockets use this range. The node does not load a model.
SEED_MAX = 0xFFFFFFFFFFFFFFFF
# A token is CHAR plus a positive integer. CHAR10 is one token, not CHAR1.
# The lookbehind keeps CHAR100 intact when only CHAR1 or CHAR10 is defined.
TOKEN_RE = re.compile(r"(?<![A-Za-z0-9])CHAR[1-9]\d*")
# CHAR1 on its own line, or CHAR1 followed by the description on that same line.
CHAR_HEADER_RE = re.compile(r"^(CHAR[1-9]\d*)(?:\s+(\S.*))?$")
# p01, p001, p09A, p01_station, p09A_morning. Same plate ids as PlatePress.
PLATE_HEADER_RE = re.compile(
r"^(?P<id>[Pp]\d+[A-Za-z]?(?:_[A-Za-z0-9_]+)?)(?:\s+(?P<rest>\S.*))?$"
)
class PromptBatchError(ValueError):
"""Text the node can show in ComfyUI without a traceback."""
@dataclass(frozen=True)
class Shot:
prompt: str
seed: int
plate_id: str
# Tokens that were replaced in this prompt, in the order they appear.
# Unused by the node outputs. The next version uses this to attach each
# character's reference images to the shots that name that character.
character_tokens: tuple[str, ...]
def build_batch(characters: str, prompts: str, seeds: str, roll_seed, prefix: str = "") -> list[Shot]:
"""Parse the boxes. roll_seed() returns one new seed."""
character_map = _parse_characters(_as_text(characters, "Characters"))
prefix_text, prefix_tokens = _apply_characters(
_as_text(prefix, "Prefix").strip(),
character_map,
)
plates = _split_prompts(_as_text(prompts, "Prompts"))
seed_values = _parse_seeds(_as_text(seeds, "Seeds"), len(plates), roll_seed)
shots = []
for (plate_id, prompt), seed in zip(plates, seed_values):
finished, tokens = _apply_characters(prompt, character_map)
if prefix_text:
finished = f"{prefix_text}\n{finished}"
shots.append(
Shot(
prompt=finished,
seed=seed,
plate_id=plate_id,
character_tokens=_merge_tokens(prefix_tokens, tokens),
)
)
return shots
def seeds_are_rolled(seeds: str) -> bool:
"""True when at least one seed is chosen at queue time.
An empty Seeds box chooses a seed for every prompt. A blank line chooses
one seed. Explicit integers stay cached between queues.
"""
text = _as_text(seeds, "Seeds")
if text.strip() == "":
return True
return any(line.strip() == "" for line in text.splitlines())
def _as_text(value, box_name: str) -> str:
if value is None:
return ""
if not isinstance(value, str):
raise PromptBatchError(f"{box_name} must be text.")
return value.replace("\r\n", "\n").replace("\r", "\n")
def _plate_key(plate_id: str) -> str:
"""p1_station and p001_station are the same plate. p01 and p01_station are not."""
match = re.fullmatch(r"[Pp](\d+)([A-Za-z])?(?:_([A-Za-z0-9_]+))?", plate_id)
if match is None:
return plate_id.lower()
letter = (match.group(2) or "").lower()
rest = (match.group(3) or "").lower()
core = f"p{int(match.group(1)):03d}{letter}"
return f"{core}_{rest}" if rest else core
def _split_prompts(text: str) -> list[tuple[str, str]]:
"""Return (plate id, body) in wall order. The id is not part of the body."""
if text.strip() == "":
raise PromptBatchError(
"Prompts is empty. Start each prompt with a plate id on its own line, such as p01 or p01_station."
)
plates: list[tuple[str, str]] = []
seen: dict[str, str] = {}
current_id: str | None = None
buf: list[str] = []
def flush() -> None:
nonlocal current_id, buf
if current_id is None:
buf = []
return
while buf and buf[0].strip() == "":
buf.pop(0)
while buf and buf[-1].strip() == "":
buf.pop()
body = "\n".join(buf)
if body.strip() == "":
raise PromptBatchError(f"{current_id} has no prompt text.")
plates.append((current_id, body))
current_id = None
buf = []
for line in text.split("\n"):
match = PLATE_HEADER_RE.match(line.strip())
if match is not None:
flush()
plate_id = match.group("id")
key = _plate_key(plate_id)
if key in seen:
raise PromptBatchError(f"{plate_id} is the same plate as {seen[key]}.")
seen[key] = plate_id
current_id = plate_id
rest = (match.group("rest") or "").strip()
if rest:
buf.append(rest)
continue
if current_id is None:
if line.strip():
raise PromptBatchError(
"Prompts has text before the first plate id. "
"Start with a line like p01 or p01_station."
)
continue
buf.append(line)
flush()
return plates
def _split_blocks(text: str) -> list[str]:
blocks = []
current: list[str] = []
for line in text.split("\n"):
if line.strip() == "":
if current:
blocks.append("\n".join(current))
current = []
continue
current.append(line)
if current:
blocks.append("\n".join(current))
return blocks
def _parse_characters(text: str) -> dict[str, str]:
if text.strip() == "":
return {}
characters: dict[str, str] = {}
for index, block in enumerate(_split_blocks(text), start=1):
lines = block.split("\n")
header = CHAR_HEADER_RE.match(lines[0].strip())
if header is None:
raise PromptBatchError(
f"Characters block {index} starts with {lines[0].strip()!r}. "
"Start the block with CHAR and a positive integer, such as CHAR1 or CHAR10. "
"The description can follow on that same line."
)
token = header.group(1)
description = re.sub(
r"\s+",
" ",
"\n".join([header.group(2) or "", *lines[1:]]),
).strip()
if description == "":
raise PromptBatchError(f"{token} has no description under the token line.")
if token in characters:
raise PromptBatchError(f"{token} is defined more than once.")
characters[token] = description
return characters
def _merge_tokens(first: tuple[str, ...], second: tuple[str, ...]) -> tuple[str, ...]:
merged: list[str] = []
for token in (*first, *second):
if token not in merged:
merged.append(token)
return tuple(merged)
def _apply_characters(prompt: str, characters: dict[str, str]) -> tuple[str, tuple[str, ...]]:
if not characters:
return prompt, ()
used: list[str] = []
def replace(match: re.Match) -> str:
token = match.group(0)
description = characters.get(token)
if description is None:
return token
if token not in used:
used.append(token)
return description
return TOKEN_RE.sub(replace, prompt), tuple(used)
def _parse_seeds(text: str, count: int, roll_seed) -> list[int]:
if text.strip() == "":
return [_rolled_seed(roll_seed) for _ in range(count)]
# splitlines() drops a trailing newline, so the last Enter in the box
# does not add an extra seed. A blank line in the middle still counts.
lines = text.splitlines()
if len(lines) != count:
seed_word = "line" if len(lines) == 1 else "lines"
prompt_word = "prompt" if count == 1 else "prompts"
raise PromptBatchError(
f"Seeds has {len(lines)} {seed_word} and Prompts has {count} {prompt_word}. "
"Use one seed line per plate, in the same order, or leave Seeds empty."
)
values = []
for index, line in enumerate(lines, start=1):
stripped = line.strip()
if stripped == "":
values.append(_rolled_seed(roll_seed))
continue
if re.fullmatch(r"\d+", stripped) is None:
raise PromptBatchError(
f"Seed line {index} is {stripped!r}. "
f"Each line must be an integer from 0 to {SEED_MAX}, or empty to choose a seed."
)
value = int(stripped)
if value > SEED_MAX:
raise PromptBatchError(
f"Seed line {index} is {value}. The largest seed is {SEED_MAX}."
)
values.append(value)
return values
def _rolled_seed(roll_seed) -> int:
value = roll_seed()
if isinstance(value, bool) or not isinstance(value, int):
raise PromptBatchError("The seed picker returned a value that is not an integer.")
if value < 0 or value > SEED_MAX:
raise PromptBatchError(f"The seed picker returned {value}, outside 0 to {SEED_MAX}.")
return value
+8
View File
@@ -0,0 +1,8 @@
[project]
name = "comfyui-prompt-batch"
version = "0.1.0"
description = "One finished prompt and one seed per plate. Pair with Start Loop and Get Item From List."
requires-python = ">=3.10"
[tool.comfy]
DisplayName = "Multy_prompts"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+178
View File
@@ -0,0 +1,178 @@
import unittest
from prompt_batch import PromptBatchError, SEED_MAX, build_batch, seeds_are_rolled
CHARACTERS = """
CHAR1
A woman in her thirties with short black hair and a grey coat.
CHAR2
A tall man with a scar on his left cheek.
"""
PROMPTS = """
p01
CHAR1 stands in the rain outside a station.
p02
CHAR1 hands CHAR2 a letter in a dark hallway.
"""
def batch(characters, prompts, seeds, rolls=(), prefix=""):
pending = iter(rolls)
def roll():
return next(pending)
return build_batch(characters, prompts, seeds, roll, prefix)
class BuildBatchTests(unittest.TestCase):
def test_example_from_the_spec(self):
shots = batch(CHARACTERS, PROMPTS, "657539441289641\n\n", rolls=[42])
self.assertEqual(
[shot.prompt for shot in shots],
[
"A woman in her thirties with short black hair and a grey coat. stands in the rain outside a station.",
"A woman in her thirties with short black hair and a grey coat. hands A tall man with a scar on his left cheek. a letter in a dark hallway.",
],
)
self.assertEqual([shot.seed for shot in shots], [657539441289641, 42])
self.assertEqual(shots[0].character_tokens, ("CHAR1",))
self.assertEqual(shots[1].character_tokens, ("CHAR1", "CHAR2"))
self.assertEqual([shot.plate_id for shot in shots], ["p01", "p02"])
def test_empty_characters_leave_prompts_unchanged(self):
shots = batch("", "p01\nA red door.\n\np02\nA blue door.", "1\n2")
self.assertEqual([shot.prompt for shot in shots], ["A red door.", "A blue door."])
self.assertEqual(shots[0].character_tokens, ())
def test_prompt_that_names_nobody_is_not_rewritten(self):
shots = batch(CHARACTERS, "p01\nAn empty platform.", "7")
self.assertEqual(shots[0].prompt, "An empty platform.")
self.assertEqual(shots[0].character_tokens, ())
def test_unknown_token_stays(self):
shots = batch(CHARACTERS, "p01\nCHAR3 waits.", "7")
self.assertEqual(shots[0].prompt, "CHAR3 waits.")
self.assertEqual(shots[0].character_tokens, ())
def test_longest_token_wins(self):
characters = "CHAR1\nred coat\n\nCHAR10\nblue coat"
shots = batch(characters, "p01\nCHAR10 meets CHAR1.\n\np02\nCHAR100 waves.", "1\n2")
self.assertEqual(shots[0].prompt, "blue coat meets red coat.")
self.assertEqual(shots[0].character_tokens, ("CHAR10", "CHAR1"))
self.assertEqual(shots[1].prompt, "CHAR100 waves.")
def test_description_can_share_the_token_line(self):
shots = batch("CHAR1 blaa blaa blaa", "p01\nCHAR1 waits.", "1")
self.assertEqual(shots[0].prompt, "blaa blaa blaa waits.")
def test_description_is_one_phrase_and_is_not_expanded(self):
characters = "CHAR1\nshort black hair\nand a grey coat, friend of CHAR2\n\nCHAR2\nscarred man"
shots = batch(characters, "p01\nCHAR1.", "1")
self.assertEqual(shots[0].prompt, "short black hair and a grey coat, friend of CHAR2.")
self.assertEqual(shots[0].character_tokens, ("CHAR1",))
def test_blank_lines_inside_a_plate_stay(self):
shots = batch("", "p01\nline one\n\nline two\n\np02_letter\nstill one plate", "3\n4")
self.assertEqual(shots[0].prompt, "line one\n\nline two")
self.assertEqual(shots[1].prompt, "still one plate")
self.assertEqual(shots[1].plate_id, "p02_letter")
def test_text_on_the_header_line_is_the_prompt(self):
shots = batch("", "p01 CHAR1 stands in the rain.\n\np1_station\nsecond", "1\n2")
self.assertEqual(shots[0].prompt, "CHAR1 stands in the rain.")
self.assertEqual(shots[1].plate_id, "p1_station")
def test_p1_and_p001_are_the_same_plate(self):
with self.assertRaises(PromptBatchError) as caught:
batch("", "p1\nfirst\n\np001\nsecond", "1\n2")
self.assertIn("same plate", str(caught.exception))
def test_text_before_the_first_plate_is_an_error(self):
with self.assertRaises(PromptBatchError) as caught:
batch("", "CHAR1 stands here.\n\np01\nmore", "1")
self.assertIn("before the first plate id", str(caught.exception))
def test_empty_plate_body(self):
with self.assertRaises(PromptBatchError) as caught:
batch("", "p01\n\np02\nhello", "1\n2")
self.assertIn("p01 has no prompt text", str(caught.exception))
def test_empty_seed_box_rolls_one_seed_per_prompt(self):
shots = batch("", "p01\none\n\np02\ntwo\n\np03\nthree", "", rolls=[4, 5, 6])
self.assertEqual([shot.seed for shot in shots], [4, 5, 6])
def test_blank_seed_line_rolls_and_trailing_newline_does_not_add_a_line(self):
shots = batch("", "p01\none\n\np02\ntwo", "8\n\n", rolls=[9])
self.assertEqual([shot.seed for shot in shots], [8, 9])
def test_seed_count_must_match(self):
with self.assertRaises(PromptBatchError) as caught:
batch("", "p01\none\n\np02\ntwo", "1")
self.assertIn("Seeds has 1 line", str(caught.exception))
self.assertIn("Prompts has 2 prompts", str(caught.exception))
def test_bad_seed(self):
with self.assertRaises(PromptBatchError) as caught:
batch("", "p01\none", "nope")
self.assertIn("Seed line 1", str(caught.exception))
def test_seed_above_ksampler_max(self):
with self.assertRaises(PromptBatchError):
batch("", "p01\none", str(SEED_MAX + 1))
def test_empty_prompts(self):
with self.assertRaises(PromptBatchError) as caught:
batch("", "\n\n", "")
self.assertIn("Prompts is empty", str(caught.exception))
def test_bad_character_token(self):
with self.assertRaises(PromptBatchError) as caught:
batch("Hero\ntall", "Hero walks.", "1")
self.assertIn("Characters block 1", str(caught.exception))
def test_char_zero_is_rejected(self):
with self.assertRaises(PromptBatchError):
batch("CHAR0\nnobody", "CHAR0.", "1")
def test_duplicate_character(self):
with self.assertRaises(PromptBatchError) as caught:
batch("CHAR1\nred\n\nCHAR1\nblue", "CHAR1.", "1")
self.assertIn("defined more than once", str(caught.exception))
def test_character_without_description(self):
with self.assertRaises(PromptBatchError) as caught:
batch("CHAR1\n", "CHAR1.", "1")
self.assertIn("no description", str(caught.exception))
def test_prefix_is_pasted_in_front_of_every_plate(self):
shots = batch(
CHARACTERS,
PROMPTS,
"1\n2",
prefix="35mm photo, natural light\nCHAR1 in frame",
)
self.assertTrue(shots[0].prompt.startswith("35mm photo, natural light\n"))
self.assertIn("stands in the rain", shots[0].prompt)
self.assertIn("short black hair", shots[0].prompt)
self.assertTrue(shots[1].prompt.startswith("35mm photo, natural light\n"))
self.assertIn("CHAR1", shots[0].character_tokens)
self.assertNotIn("p01", shots[0].prompt)
def test_empty_prefix_adds_nothing(self):
shots = batch("", "p01\nA red door.", "1", prefix=" \n")
self.assertEqual(shots[0].prompt, "A red door.")
def test_seeds_are_rolled_only_for_blank_lines(self):
self.assertTrue(seeds_are_rolled(""))
self.assertTrue(seeds_are_rolled("1\n\n2"))
self.assertFalse(seeds_are_rolled("1\n2\n"))
self.assertFalse(seeds_are_rolled("0"))
if __name__ == "__main__":
unittest.main()