Add the Multy_prompts node and text-to-image sample
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,128 @@
|
||||
# Multy_prompts
|
||||
|
||||

|
||||
|
||||
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
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -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()
|
||||
Reference in New Issue
Block a user