aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/superpowers/specs/2026-10-04-fanfictioner-design.md69
-rwxr-xr-xfanfictioner230
2 files changed, 141 insertions, 158 deletions
diff --git a/docs/superpowers/specs/2026-10-04-fanfictioner-design.md b/docs/superpowers/specs/2026-10-04-fanfictioner-design.md
index cd620e8..6d054e6 100644
--- a/docs/superpowers/specs/2026-10-04-fanfictioner-design.md
+++ b/docs/superpowers/specs/2026-10-04-fanfictioner-design.md
@@ -5,8 +5,9 @@ Date: 2026-10-04
## Goal
Turn a free-prose story idea (`story.md`) into an illustrated, text-free book of
-page images, read with `../book-reader`. A local Gemma model plans the pages and
-writes the image prompts; `sd-cli` (stable-diffusion.cpp) draws them. The user
+page images, read with `../book-reader`. A local Gemma model plans the pages; the
+script turns the plan into image prompts from fixed templates; `sd-cli`
+(stable-diffusion.cpp) draws them. The user
reviews and picks among candidates at every image stage.
## Constraints
@@ -25,7 +26,7 @@ reviews and picks among candidates at every image stage.
```
story.md ──Gemma draft──▶ plan.md ◀─┐ [c]onfirm / [e]dit in $EDITOR / [g]emma revise / [q]uit
│
- confirm ──Gemma compile (json_schema)──▶ plan.json
+ confirm ──script compile (templates, no LLM)──▶ plan.json
│
unload Gemma
│
@@ -67,21 +68,24 @@ Style: <global art style line>
## Characters
### <Name>
-<fixed visual description: age, hair, face, eyes, build, outfit>
+Pronoun: she|he|they
+<fixed visual description: who (young woman, old man...), age, hair, face, eyes, build, outfit>
## Pages
### 1. <short title>
<what happens, setting, mood>
Characters: <Name>, <Name> (or "none")
-Pose: <body orientation and pose of each named character>
-Framing: <camera angle, shot size, portrait|landscape>
+Pose: <Name>: <position in frame, orientation, pose>; <Name>: <...>
+Framing: <camera angle, shot size>, portrait|landscape
Model: krea2 (optional, overrides the base model for this page)
```
Validation before confirm: title, Series, Book, at least one character and
-one page; every name in `Characters:` exists under `## Characters`; every page
-has `Pose:` when it lists characters. Failures are listed; the user edits or
+one page; every character has `Pronoun:` she, he or they; every page has a
+`Characters:` line and every name in it exists under `## Characters`; `Pose:`
+names a pose for each listed character; `Framing:` says portrait or landscape;
+page numbers run 1..N; names are safe as folder and file names. Failures are listed; the user edits or
asks Gemma to revise.
## Gemma calls
@@ -89,39 +93,48 @@ asks Gemma to revise.
All via `POST localhost:8181/v1/chat/completions`, `model: Gemma4-12B-qat-mtp`.
1. **Draft**: system prompt holds the plan.md format and rules (no text in
- images, fixed character descriptions, one page = one image, a scene may span
- several pages). User message: `story.md`. Output: plan.md.
-2. **Revise**: current plan.md + user's instruction → complete new plan.md.
-3. **Compile**: plan.md → plan.json with `response_format` `json_schema`:
+ images, fixed character descriptions starting with who they are, characters
+ are people only while animals and objects belong to the scene, one page = one
+ image, a scene may span several pages). User message: `story.md`. Output: plan.md.
+2. **Revise**: current plan.md + user's instruction -> complete new plan.md. A
+ revision that fails validation is saved as `plan.rejected.md` and plan.md is
+ kept; an accepted one keeps the previous version as `plan.md.bak`.
+
+## Compile (script, no LLM)
+
+On confirm the script builds plan.json from the parsed plan.md:
```json
{
"title": "", "series": "", "book": "",
"characters": [{"name": "", "turnaround_prompt": ""}],
"pages": [{
- "n": 1, "model": "zimage", "orientation": "portrait",
- "characters": ["Mara", "Jon"],
+ "n": 1, "model": "", "orientation": "portrait",
"base_prompt": "",
"edits": [{"characters": ["Mara", "Jon"], "prompt": ""}]
}]
}
```
-Prompt-writing rules given to Gemma for compile:
+Templates:
-- `turnaround_prompt`: "Character turnaround reference sheet … the same <person>
- shown three times side by side, full body: front view, side view, back view …
- plain light-grey background … No text, no labels."
-- `base_prompt`: Style line + scene + full description of every character
- present + Pose + Framing + "No text, no speech bubbles."
+- `turnaround_prompt`: "Character turnaround reference sheet of one person:
+ <description> The same person shown three times side by side, full body: front
+ view, side view, back view. Neutral standing pose, arms relaxed. Plain
+ light-grey background, even studio lighting. <Style>. No text, no labels."
+- `base_prompt`: Style, page text, then for each character present its
+ description followed by its pose, then Framing, then "No text, no speech
+ bubbles."
- `edits`: one entry per group of at most 2 characters (Qwen takes at most 3
- reference images: the scene plus 2 refs). Prompt changes only heads/faces:
- "In image 1, change only <who>'s head: give her/him the face, … and hair of
- the <person> in image N. Keep <who>'s exact pose from image 1: <Pose>. Keep
- bodies, clothing, other people, background, lighting and art style of image 1
- unchanged." Pages with no characters have no edits.
+ reference images: the scene plus 2 refs), in `Characters:` order. Per
+ character: "In image 1, change only <Name>'s head: give her/him/them the face,
+ eyes and hair of the person in image N. Keep <Name>'s exact pose from image 1:
+ <pose>." Then "Keep bodies, clothing, other people, background, lighting and
+ art style of image 1 unchanged." Pages with no characters have no edits.
+- `orientation` comes from Framing; `model` from the page's `Model:` line
+ (empty means `--base`).
-Gemma writes prompts only; the script owns every sd-cli setting.
+Gemma writes the plan only; the script owns every prompt and sd-cli setting.
## Model profiles
@@ -184,8 +197,8 @@ fanfictioner story.md [--base zimage|krea2] [--selftest]
## Testing
`fanfictioner --selftest`: plain asserts, no GPU, no server. Covers plan.md
-validation, edit grouping for more than 2 characters, resume detection, and
-sd-cli argument building per profile.
+parsing and validation, edit grouping for more than 2 characters, plan.json
+templates, resume detection, and sd-cli argument building per profile.
## Out of scope
diff --git a/fanfictioner b/fanfictioner
index db434be..24d3a41 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -67,8 +67,11 @@ DRAFT_SYS = """You turn a story idea into a page plan for a text-free illustrate
Each page is one full-page image. A scene may span several pages.
Images never contain text: no speech bubbles, captions, signs or labels. Tell the story
through action, expression and setting.
-Give every character one fixed visual description (age, hair, face, eyes, build, outfit)
+Give every character one fixed visual description that starts with who they are
+(e.g. "Young woman", "Old man", "Little girl"), then age, hair, face, eyes, build, outfit,
and never vary it between pages.
+Characters are people only. Animals and objects belong to the scene: describe them in the
+page text, never under ## Characters.
If a page has a Model: line, keep it unchanged.
Write the plan in exactly this Markdown format, with nothing before or after it:
@@ -80,6 +83,7 @@ Style: <one line describing the art style of every page>
## Characters
### <Name>
+Pronoun: <she, he or they>
<fixed visual description>
## Pages
@@ -87,52 +91,11 @@ Style: <one line describing the art style of every page>
### 1. <short title>
<what happens, setting, mood>
Characters: <Name>, <Name> (or: none)
-Pose: <body orientation and pose of each named character>
-Framing: <camera angle, shot size, portrait or landscape>
+Pose: <Name>: <position in the frame, body orientation and pose>; <Name>: <...>
+Framing: <camera angle, shot size>, <portrait or landscape>
"""
-COMPILE_SYS = """You convert an illustrated-book plan into image-generation prompts, as JSON.
-
-characters: one entry per character of the plan, in plan order, name exactly as written.
-turnaround_prompt: "Character turnaround reference sheet. The same <full visual description>
-shown three times side by side, full body: front view, side view, back view. Neutral standing
-pose, arms relaxed. Plain light-grey background, even studio lighting. <Style line>.
-No text, no labels."
-
-pages: one entry per page, n as in the plan. orientation: portrait or landscape, from Framing.
-base_prompt: the Style line, then the scene, then the full visual description of every
-character present, then their Pose, then the Framing, ending with
-"No text, no speech bubbles."
-
-edits: exactly the edit groups listed after the plan, in that order, names exactly as written.
-In an edit, image 1 is the scene, image 2 is the reference sheet of the first listed character,
-image 3 that of the second. For each character the prompt says:
-"In image 1, change only <name>'s head: give her/him the face, eyes and hair of the person in
-image <2 or 3>. Keep <name>'s exact pose from image 1: <that character's Pose>."
-and it ends with: "Keep bodies, clothing, other people, background, lighting and art style of
-image 1 unchanged."
-A page with no characters has an empty edits list.
-"""
-
-SCHEMA = {
- "type": "object", "required": ["characters", "pages"],
- "properties": {
- "characters": {"type": "array", "items": {
- "type": "object", "required": ["name", "turnaround_prompt"],
- "properties": {"name": {"type": "string"}, "turnaround_prompt": {"type": "string"}}}},
- "pages": {"type": "array", "items": {
- "type": "object", "required": ["n", "orientation", "base_prompt", "edits"],
- "properties": {
- "n": {"type": "integer"},
- "orientation": {"enum": ["portrait", "landscape"]},
- "base_prompt": {"type": "string"},
- "edits": {"type": "array", "items": {
- "type": "object", "required": ["characters", "prompt"],
- "properties": {"characters": {"type": "array", "items": {"type": "string"},
- "maxItems": 2},
- "prompt": {"type": "string"}}}}}}},
- },
-}
+OBJ = {"she": "her", "he": "him", "they": "them"} # pronoun -> object form for edit prompts
def load_conf(path=CONF):
@@ -165,8 +128,8 @@ def keep(src, dst):
def parse_plan(text):
- plan = {"title": "", "series": "", "book": "", "style": "", "characters": {}, "pages": []}
- section = cur = None
+ plan = {"title": "", "series": "", "book": "", "style": "", "characters": {}, "pronouns": {}, "pages": []}
+ section = cur = cname = None
for line in text.splitlines():
s = line.strip()
if s.startswith("```") or s == "---":
@@ -178,7 +141,7 @@ def parse_plan(text):
elif s.startswith("### "):
head = s[4:].strip()
if section == "characters":
- cur = plan["characters"].setdefault(head, [])
+ cname, cur = head, plan["characters"].setdefault(head, [])
elif section == "pages":
m = re.match(r"(\d+)\.\s*(.*)", head)
cur = {"n": int(m[1]) if m else 0, "title": m[2] if m else head, "text": [],
@@ -186,6 +149,9 @@ def parse_plan(text):
plan["pages"].append(cur)
elif section is None and (m := re.match(r"[-*\s]*\**(Series|Book|Style)\**:\**\s*(.*)", s)):
plan[m[1].lower()] = m[2].strip().strip("*").strip()
+ elif section == "characters" and cur is not None and \
+ (m := re.match(r"[-*\s]*\**Pronoun\**:\**\s*(.*)", s)):
+ plan["pronouns"][cname] = m[1].strip().strip("*").strip().lower()
elif section == "pages" and cur is not None and \
(m := re.match(r"[-*\s]*\**(Characters|Pose|Framing|Model)\**:\**\s*(.*)", s)):
key, val = m[1].lower(), m[2].strip().strip("*").strip()
@@ -202,6 +168,12 @@ def parse_plan(text):
return plan
+def parse_poses(pose):
+ """'Mara: left, facing right; Jon: center' -> {"Mara": "left, facing right", "Jon": "center"}"""
+ return {k.strip(): v.strip().rstrip(".") for k, _, v in (x.partition(":") for x in pose.split(";"))
+ if v.strip()}
+
+
def validate_plan(plan):
errs = [f"missing {k}" for k in ("title", "series", "book") if not plan[k]]
if not plan["characters"]:
@@ -211,6 +183,8 @@ def validate_plan(plan):
errs += [f'bad {k} name "{plan[k]}"' for k in ("series", "book")
if plan[k] and (("/" in plan[k]) or plan[k] in (".", ".."))]
errs += [f'bad character name "{c}"' for c in plan["characters"] if "/" in c or c in (".", "..")]
+ errs += [f"character {c}: Pronoun must be she, he or they" for c in plan["characters"]
+ if plan["pronouns"].get(c) not in OBJ]
ns = [p["n"] for p in plan["pages"]]
if sorted(ns) != list(range(1, len(ns) + 1)):
errs.append("page numbers must be unique and start at 1")
@@ -224,8 +198,10 @@ def validate_plan(plan):
if c not in plan["characters"]]
if len(set(chars)) != len(chars):
errs.append(f"page {p['n']}: duplicate character")
- if chars and not p["pose"]:
- errs.append(f"page {p['n']}: Pose missing")
+ poses = parse_poses(p["pose"])
+ errs += [f"page {p['n']}: Pose missing for {c}" for c in chars if c not in poses]
+ if not re.search(r"portrait|landscape", p["framing"], re.I):
+ errs.append(f"page {p['n']}: Framing must say portrait or landscape")
if p["model"] and p["model"] not in ("zimage", "krea2"):
errs.append(f"page {p['n']}: unknown model {p['model']}")
return errs
@@ -239,13 +215,16 @@ Style: soft watercolor, muted colors
## Characters
### Mara
+Pronoun: she
Young woman, long red hair,
green eyes, grey coat.
### Jon
+Pronoun: he
Tall man, short beard, blue jacket.
### Kit
+Pronoun: she
Small girl, black bob, yellow raincoat.
## Pages
@@ -253,7 +232,7 @@ Small girl, black bob, yellow raincoat.
### 1. Arrival
They reach the roof at dusk.
Characters: Mara, Jon, Kit
-Pose: Mara left facing right, Jon center facing camera, Kit right sitting.
+Pose: Mara: left, facing right; Jon: center, facing camera; Kit: right, sitting.
Framing: wide shot, eye level, landscape
### 2. Empty roof
@@ -269,27 +248,35 @@ def edit_groups(chars):
return [chars[i:i + 2] for i in range(0, len(chars), 2)]
-def edit_hint(plan):
- return "\n".join(f"Page {p['n']}: " + (" then ".join("[" + ", ".join(g) + "]"
- for g in edit_groups(p["characters"] or []))
- or "no edits")
- for p in plan["pages"])
-
-
-def check_json(pj, plan):
- errs = []
- if [c["name"] for c in pj["characters"]] != list(plan["characters"]):
- errs.append("characters differ from plan.md")
- want = {p["n"]: p["characters"] or [] for p in plan["pages"]}
- if sorted(p["n"] for p in pj["pages"]) != sorted(want):
- errs.append("pages differ from plan.md")
- for p in pj["pages"]:
- if p["orientation"] not in ("portrait", "landscape"):
- errs.append(f"page {p['n']}: bad orientation {p['orientation']}")
- got, exp = [e["characters"] for e in p["edits"]], edit_groups(want.get(p["n"], []))
- if got != exp:
- errs.append(f"page {p['n']}: edit groups {got}, expected {exp}")
- return errs
+def sentence(s):
+ s = s.strip()
+ return s[:1].upper() + s[1:] + ("" if not s or s[-1] in ".!?" else ".")
+
+
+def build_json(plan):
+ """plan.md -> plan.json. Every image prompt comes from a fixed template, no LLM involved."""
+ style = sentence(plan["style"])
+ chars = [{"name": n, "turnaround_prompt":
+ f"Character turnaround reference sheet of one person: {sentence(d)} The same person shown "
+ "three times side by side, full body: front view, side view, back view. Neutral standing pose, "
+ f"arms relaxed. Plain light-grey background, even studio lighting. {style} No text, no labels."}
+ for n, d in plan["characters"].items()]
+ pages = []
+ for p in plan["pages"]:
+ poses = parse_poses(p["pose"])
+ parts = [style, sentence(p["text"])]
+ parts += [f"{sentence(plan['characters'][n])} {sentence(poses[n])}" for n in p["characters"]]
+ parts += [sentence(p["framing"]), "No text, no speech bubbles."]
+ edits = [{"characters": g, "prompt": " ".join(
+ f"In image 1, change only {n}'s head: give {OBJ[plan['pronouns'][n]]} the face, eyes and hair "
+ f"of the person in image {i + 2}. Keep {n}'s exact pose from image 1: {poses[n]}."
+ for i, n in enumerate(g))
+ + " Keep bodies, clothing, other people, background, lighting and art style of image 1 unchanged."}
+ for g in edit_groups(p["characters"])]
+ pages.append({"n": p["n"], "orientation": "landscape" if "landscape" in p["framing"].lower() else "portrait",
+ "model": p["model"], "base_prompt": " ".join(x for x in parts if x), "edits": edits})
+ return {"title": plan["title"], "series": plan["series"], "book": plan["book"],
+ "characters": chars, "pages": pages}
def sd_args(profile, prompt, out, size, seed, refs=()):
@@ -425,11 +412,9 @@ def unload_llm():
pass
-def chat(system, user, schema=None):
+def chat(system, user):
body = {"model": LLM_MODEL,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}]}
- if schema:
- body["response_format"] = {"type": "json_schema", "json_schema": {"name": "plan", "schema": schema}}
print("asking Gemma ...", flush=True)
return llm("/v1/chat/completions", body)["choices"][0]["message"]["content"]
@@ -438,26 +423,6 @@ def strip_fences(s):
return re.sub(r"^```\w*\n|\n```$", "", s.strip())
-def compile_plan(md, plan):
- """plan.md -> plan.json dict, or None when Gemma twice returns groups that do not match."""
- for _ in range(2):
- try:
- pj = json.loads(chat(COMPILE_SYS, f"{md}\n\nEdit groups, in order:\n{edit_hint(plan)}", SCHEMA))
- errs = check_json(pj, plan)
- except (ValueError, KeyError, TypeError) as e:
- errs = [f"bad JSON from Gemma: {e}"]
- if not errs:
- break
- print("compile mismatch:", *errs, sep="\n ")
- else:
- return None
- models = {p["n"]: p["model"] for p in plan["pages"]}
- pj.update(title=plan["title"], series=plan["series"], book=plan["book"])
- for p in pj["pages"]:
- p["model"] = models[p["n"]]
- return pj
-
-
GEMMA_ERRS = (OSError, ValueError, KeyError, http.client.HTTPException)
@@ -465,9 +430,9 @@ def plan_stage(story, wd):
md_path, js_path = wd / "plan.md", wd / "plan.json"
if js_path.exists() and md_path.exists() and js_path.stat().st_mtime >= md_path.stat().st_mtime:
return json.loads(js_path.read_text())
- check_llm()
- wd.mkdir(exist_ok=True)
if not md_path.exists():
+ check_llm()
+ wd.mkdir(exist_ok=True)
try:
draft = strip_fences(chat(DRAFT_SYS, story.read_text())) + "\n"
except GEMMA_ERRS as e:
@@ -487,16 +452,7 @@ def plan_stage(story, wd):
if k == "c" and errs:
print("fix the ! lines first")
elif k == "c":
- try:
- pj = compile_plan(md, plan)
- except GEMMA_ERRS as e:
- print(f"Gemma failed: {e}")
- continue
- if not pj:
- continue
- if md_path.read_text() != md:
- print("plan.md changed during compile, review again")
- continue
+ pj = build_json(plan)
tmp = js_path.with_name("plan.json.tmp")
tmp.write_text(json.dumps(pj, indent=2) + "\n")
os.replace(tmp, js_path)
@@ -569,7 +525,7 @@ def selftest():
assert plan["characters"]["Mara"] == "Young woman, long red hair, green eyes, grey coat."
p1, p2 = plan["pages"]
assert (p1["n"], p1["title"], p1["characters"]) == (1, "Arrival", ["Mara", "Jon", "Kit"])
- assert p1["text"] == "They reach the roof at dusk." and p1["pose"].startswith("Mara left")
+ assert p1["text"] == "They reach the roof at dusk." and p1["pose"].startswith("Mara: left")
assert p1["model"] == "" and p2["model"] == "krea2" and p2["characters"] == []
assert validate_plan(plan) == []
@@ -580,7 +536,7 @@ def selftest():
errs = validate_plan(bad)
assert "missing book" in errs, errs
assert "page 1: unknown character Zed" in errs, errs
- assert "page 1: Pose missing" in errs, errs
+ assert "page 1: Pose missing for Zed" in errs, errs
assert "page 1: unknown model sdxl" in errs, errs
assert "page numbers must be unique and start at 1" in errs, errs
assert validate_plan(parse_plan("")) == ["missing title", "missing series", "missing book",
@@ -591,10 +547,9 @@ def selftest():
.replace("Series:", "**Series:**").replace("Style:", "- Style:"))
assert md["series"] == "Test Series" and md["style"] == "soft watercolor, muted colors", md
assert md["pages"][0]["characters"] == ["Mara", "Jon", "Kit"], md
- assert md["pages"][0]["pose"].startswith("Mara left") and md["pages"][0]["framing"].startswith("wide"), md
+ assert md["pages"][0]["pose"].startswith("Mara: left") and md["pages"][0]["framing"].startswith("wide"), md
assert validate_plan(md) == []
assert "page 2: Characters missing" in validate_plan(parse_plan(SAMPLE_PLAN.replace("Characters: none\n", "")))
- assert edit_hint(parse_plan(SAMPLE_PLAN.replace("Characters: none\n", ""))).endswith("Page 2: no edits")
assert 'bad series name "../x"' in validate_plan(parse_plan(SAMPLE_PLAN.replace("Test Series", "../x")))
assert 'bad book name ".."' in validate_plan(parse_plan(SAMPLE_PLAN.replace("Book One", "..")))
assert 'bad character name "Jon/Kit"' in validate_plan(parse_plan(SAMPLE_PLAN.replace("### Jon", "### Jon/Kit")))
@@ -611,25 +566,40 @@ def selftest():
assert edit_groups([]) == []
assert edit_groups(["Mara", "Jon", "Kit"]) == [["Mara", "Jon"], ["Kit"]]
assert edit_groups(list("abcd")) == [["a", "b"], ["c", "d"]]
- assert edit_hint(plan) == "Page 1: [Mara, Jon] then [Kit]\nPage 2: no edits"
-
- good = {"characters": [{"name": n, "turnaround_prompt": "x"} for n in ("Mara", "Jon", "Kit")],
- "pages": [{"n": 1, "orientation": "landscape", "base_prompt": "x",
- "edits": [{"characters": ["Mara", "Jon"], "prompt": "x"},
- {"characters": ["Kit"], "prompt": "x"}]},
- {"n": 2, "orientation": "portrait", "base_prompt": "x", "edits": []}]}
- assert check_json(good, plan) == []
- regrouped = json.loads(json.dumps(good))
- regrouped["pages"][0]["edits"] = [{"characters": ["Mara"], "prompt": "x"},
- {"characters": ["Jon", "Kit"], "prompt": "x"}]
- assert check_json(regrouped, plan) == [
- "page 1: edit groups [['Mara'], ['Jon', 'Kit']], expected [['Mara', 'Jon'], ['Kit']]"]
- badori = json.loads(json.dumps(good))
- badori["pages"][1]["orientation"] = "square"
- assert check_json(badori, plan) == ["page 2: bad orientation square"]
- short = json.loads(json.dumps(good))
- del short["characters"][2], short["pages"][1]
- assert check_json(short, plan) == ["characters differ from plan.md", "pages differ from plan.md"]
+ assert parse_poses("Mara: left, facing right; Jon: center.") == {"Mara": "left, facing right", "Jon": "center"}
+ assert "character Jon: Pronoun must be she, he or they" in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("Pronoun: he\n", "")))
+ assert "page 1: Pose missing for Kit" in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("; Kit: right, sitting.", "")))
+ assert "page 2: Framing must say portrait or landscape" in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("wide shot, portrait", "wide shot")))
+
+ pj = build_json(plan)
+ assert (pj["title"], pj["series"], pj["book"]) == ("Rooftop", "Test Series", "Book One")
+ assert [c["name"] for c in pj["characters"]] == ["Mara", "Jon", "Kit"]
+ assert "of one person: Young woman, long red hair, green eyes, grey coat. The same person" in \
+ pj["characters"][0]["turnaround_prompt"]
+ assert pj["characters"][0]["turnaround_prompt"].endswith("Soft watercolor, muted colors. No text, no labels.")
+ j1, j2 = pj["pages"]
+ assert (j1["n"], j1["orientation"], j1["model"]) == (1, "landscape", "")
+ assert (j2["n"], j2["orientation"], j2["model"], j2["edits"]) == (2, "portrait", "krea2", [])
+ assert j1["base_prompt"] == (
+ "Soft watercolor, muted colors. They reach the roof at dusk. "
+ "Young woman, long red hair, green eyes, grey coat. Left, facing right. "
+ "Tall man, short beard, blue jacket. Center, facing camera. "
+ "Small girl, black bob, yellow raincoat. Right, sitting. "
+ "Wide shot, eye level, landscape. No text, no speech bubbles."), j1["base_prompt"]
+ assert j2["base_prompt"] == ("Soft watercolor, muted colors. Nobody is there any more. "
+ "Wide shot, portrait. No text, no speech bubbles."), j2["base_prompt"]
+ assert [e["characters"] for e in j1["edits"]] == [["Mara", "Jon"], ["Kit"]]
+ assert j1["edits"][0]["prompt"] == (
+ "In image 1, change only Mara's head: give her the face, eyes and hair of the person in image 2. "
+ "Keep Mara's exact pose from image 1: left, facing right. "
+ "In image 1, change only Jon's head: give him the face, eyes and hair of the person in image 3. "
+ "Keep Jon's exact pose from image 1: center, facing camera. "
+ "Keep bodies, clothing, other people, background, lighting and art style of image 1 unchanged.")
+ assert "Kit's head: give her the face, eyes and hair of the person in image 2. " \
+ "Keep Kit's exact pose from image 1: right, sitting." in j1["edits"][1]["prompt"]
q = sd_args("qwen_edit", "edit it", Path("c/7_%d.png"), (832, 1216), 7,
[Path("in1.png"), Path("mara.png")])
assert q[0] == "sd-cli"