aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-05 13:41:10 +0200
committerDanilo M. <danix@danix.xyz>2026-10-05 13:41:10 +0200
commitc07c3df46fddad7a91fe0c3038400f14f632ed0d (patch)
treed9ed7b14fb9ed641bff44097294d6f83203f6a87
parent3c07c0e394ec60408e7856f1e51855a11043e3e6 (diff)
downloadfanfictioner-c07c3df46fddad7a91fe0c3038400f14f632ed0d.tar.gz
fanfictioner-c07c3df46fddad7a91fe0c3038400f14f632ed0d.zip
Move clothing to Outfit lines that a page can override
Putting the scene text after the character descriptions did not help: a t-shirt in the description and a formal suit in the scene both reached the prompt and the model blended them. Clothing now lives on a character's Outfit: line, and a page may carry its own "Outfit: Name: ...; Name: ..." line, so each prompt holds exactly one outfit per character. The draft instructions tell Gemma to keep clothing out of the fixed description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rwxr-xr-xfanfictioner53
1 files changed, 37 insertions, 16 deletions
diff --git a/fanfictioner b/fanfictioner
index 132e46a..23c06b9 100755
--- a/fanfictioner
+++ b/fanfictioner
@@ -70,8 +70,11 @@ through action, expression and setting. The only exception is the cover.
Page 1 is the book cover: the main characters in a striking composition, portrait, with a
Title: line holding the book title. No other page has a Title: line.
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.
+(e.g. "Young woman", "Old man", "Little girl"), then age, hair, face, eyes, build,
+and never vary it between pages. Clothing never goes in that description: put the
+character's usual clothes on their Outfit: line. When a page needs different clothes
+(a formal event, pajamas, swimwear, a uniform), give that page an Outfit: line for the
+characters who change; their clothes then stay changed only on that page.
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.
@@ -86,7 +89,8 @@ Style: <one line describing the art style of every page>
### <Name>
Pronoun: <she, he or they>
-<fixed visual description>
+Outfit: <usual clothes>
+<fixed visual description, no clothing>
## Pages
@@ -94,6 +98,7 @@ Pronoun: <she, he or they>
<what happens, setting, mood>
Characters: <Name>, <Name> (or: none)
Pose: <Name>: <position in the frame, body orientation and pose>; <Name>: <...>
+Outfit: <Name>: <clothes on this page>; <Name>: <...> (only for characters whose clothes differ)
Framing: <camera angle, shot size>, <portrait or landscape>
Title: <book title, cover page only>
"""
@@ -168,7 +173,7 @@ def keep(src, dst):
def parse_plan(text):
plan = {"title": "", "series": "", "book": "", "chapter": "", "style": "", "characters": {}, "pronouns": {},
- "pages": []}
+ "outfits": {}, "pages": []}
section = cur = cname = None
for line in text.splitlines():
s = line.strip()
@@ -185,16 +190,20 @@ def parse_plan(text):
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": [],
- "heading": "" if m else head, "characters": None, "pose": "", "framing": "", "model": "",
- "lettering": ""}
+ "heading": "" if m else head, "characters": None, "pose": "", "outfit": "", "framing": "",
+ "model": "", "lettering": ""}
plan["pages"].append(cur)
elif section is None and (m := re.match(r"[-*\s]*\**(Series|Book|Chapter|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()
+ (m := re.match(r"[-*\s]*\**(Pronoun|Outfit)\**:\**\s*(.*)", s)):
+ val = m[2].strip().strip("*").strip()
+ if m[1] == "Pronoun":
+ plan["pronouns"][cname] = val.lower()
+ else:
+ plan["outfits"][cname] = val
elif section == "pages" and cur is not None and \
- (m := re.match(r"[-*\s]*\**(Characters|Pose|Framing|Model|Title)\**:\**\s*(.*)", s)):
+ (m := re.match(r"[-*\s]*\**(Characters|Pose|Outfit|Framing|Model|Title)\**:\**\s*(.*)", s)):
key, val = m[1].lower(), m[2].strip().strip("*").strip()
if key == "title": # the page heading already uses "title"
key, val = "lettering", val.strip('"\u201c\u201d').strip()
@@ -245,6 +254,8 @@ def validate_plan(plan):
errs.append(f"page {p['n']}: duplicate character")
poses = parse_poses(p["pose"])
errs += [f"page {p['n']}: Pose missing for {c}" for c in chars if c not in poses]
+ errs += [f"page {p['n']}: Outfit for {c}, who is not on the page" for c in parse_poses(p["outfit"])
+ if c not in chars]
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"):
@@ -266,7 +277,8 @@ green eyes, grey coat.
### Jon
Pronoun: he
-Tall man, short beard, blue jacket.
+Outfit: blue jacket
+Tall man, short beard.
### Kit
Pronoun: she
@@ -278,6 +290,7 @@ Small girl, black bob, yellow raincoat.
They reach the roof at dusk.
Characters: Mara, Jon, Kit
Pose: Mara: left, facing right; Jon: center, facing camera; Kit: right, sitting.
+Outfit: Jon: wearing a black tuxedo
Framing: wide shot, eye level, landscape
### 2. Empty roof
@@ -301,16 +314,21 @@ def sentence(s):
def build_json(plan):
"""plan.md -> plan.json. Every image prompt comes from a fixed template, no LLM involved."""
style = sentence(plan["style"])
+
+ def look(n, outfit=""):
+ """Fixed description plus one outfit: the page's when it has one, else the usual one."""
+ o = re.sub(r"(?i)^(wearing|wears)\s+", "", outfit or plan["outfits"].get(n, ""))
+ return sentence(plan["characters"][n]) + (f" Wearing {o.rstrip('.')}." if o else "")
+
chars = [{"name": n, "turnaround_prompt":
- f"Character turnaround reference sheet of one person: {sentence(d)} The same person shown "
+ f"Character turnaround reference sheet of one person: {look(n)} 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()]
+ for n in plan["characters"]]
pages = []
for p in plan["pages"]:
- poses = parse_poses(p["pose"])
- # Characters before the scene text, so a scene detail (a change of dress) overrides the sheet.
- parts = [style] + [f"{sentence(plan['characters'][n])} {sentence(poses[n])}" for n in p["characters"]]
+ poses, outfits = parse_poses(p["pose"]), parse_poses(p["outfit"])
+ parts = [style] + [f"{look(n, outfits.get(n))} {sentence(poses[n])}" for n in p["characters"]]
parts.append(sentence(p["text"]))
parts.append(sentence(p["framing"]))
if p["lettering"]:
@@ -713,13 +731,16 @@ def selftest():
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.")
+ assert "one person: Tall man, short beard. Wearing blue jacket. The same" in pj["characters"][1]["turnaround_prompt"]
+ assert "page 1: Outfit for Zed, who is not on the page" in validate_plan(
+ parse_plan(SAMPLE_PLAN.replace("Outfit: Jon:", "Outfit: Zed:")))
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. "
"Young woman, long red hair, green eyes, grey coat. Left, facing right. "
- "Tall man, short beard, blue jacket. Center, facing camera. "
+ "Tall man, short beard. Wearing a black tuxedo. Center, facing camera. "
"Small girl, black bob, yellow raincoat. Right, sitting. They reach the roof at dusk. "
"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. "