diff options
| -rw-r--r-- | docs/superpowers/specs/2026-10-04-fanfictioner-design.md | 69 | ||||
| -rwxr-xr-x | fanfictioner | 230 |
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" |
