diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e39c49..f07a081 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
+## [1.40.0] — 2026-09-21
+
+### Fixed
+- **Character sheets: the turnaround showed figures cut off at the knees, in a forest.** The turnaround prompt is now short and puts the framing first (three full-body views, head to shoes, plain white studio). Measured with the automatic image check: full bodies went from 0 of 5 renders to 10 of 10, and 12 of 12 on three different characters.
+- **No more wounds or odd faces from one-off moments in a character's description.** Anger ("rote Stirn bei Wut"), tears, trembling, twitching or a tongue over the lips were drawn as permanent features (red marks, a tongue sticking out); they are left out now, like wounds. Scars are left out of images entirely — even a faded scar described as fully healed came out as a fresh gash.
+- Removing a wound from a sentence no longer leaves half-sentences behind; a faded coat is not mistaken for a faded scar.
+
## [1.39.0] — 2026-09-21
### Added
diff --git a/VERSION b/VERSION
index 9c235b4..ebc91b4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.39.0
\ No newline at end of file
+1.40.0
\ No newline at end of file
diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md
index 20143c9..dff1220 100644
--- a/architecture/character-visual-identity.md
+++ b/architecture/character-visual-identity.md
@@ -122,3 +122,36 @@ text-to-image triptych gets the poses right; swapping only the face afterwards f
Settings: `image_check_enabled` (default on), `image_check_max_attempts` (1–5, default 3),
`image_check_model` (default: the active LLM).
+
+
+## The turnaround prompt is short and framing-first (2026-09-21)
+
+Measured with turnaround-only renders scored by the image check (`scratchpad feet_experiment.py`):
+
+| turnaround prompt | full body, head to shoes | plain backdrop |
+|---|---|---|
+| long compact prompt (~3 000 chars, framing rules mid-prompt), 1536x1024 | 0/5 | 2/5 |
+| same, taller 1536x1280 canvas | 2/5 | 0/5 |
+| same + "visible floor, contact shadow" wording | 0/5 | — |
+| **short, framing first (~770 chars)**, 1536x1024 and 1536x1280 | **10/10** | **10/10** |
+| the app's new prompt on Karyla, Darrag, Eolan (4 seeds each, after the fixes below) | **12/12** | **12/12** |
+
+The 4-step Qwen-Image model follows the start of a prompt and loses the tail, so wording changes inside the
+long prompt did nothing. The prompt now opens with the framing (three full-body views side by side,
+head to soles, empty space above and below, plain white studio), then the character (subject capped at
+700 chars), then only the rules a sheet needs, one short sentence each: leather-only armour, headwear,
+elf ears (only for elves), child/teen proportions, clean skin and clean well-kept clothes.
+
+What the validation runs found, each fixed and pinned by a test:
+* **Passing states drawn as features** — "rote Stirn bei Wut, Tränen … vor Wut" became red marks (Darrag);
+ "Zunge streicht über die Lippen" a tongue sticking out (Eolan). Anger, tears, trembling, twitching,
+ blushing, sweating, licking are stripped like wounds.
+* **Scars** — even a scar the sheet calls almost faded, with "fully healed, pale silvery line" added,
+ rendered as a fresh red gash, and that sentence put slashes on hands and robe. Scars are left out of
+ image prompts entirely; this model cannot draw a subtle one.
+* **A dropped clause starting a sentence** left fragments ("und die rechte Augenbraue"): the whole sentence
+ now goes. "faded blue wool coat" stays (a costume, not a scar).
+* Hedges "unklar" / "wird erwähnt" drop speculative sentences.
+
+Open: robes can still come out with ragged hems; the portrait and story-moment prompts are still long and
+get the same framing-first treatment next, measured the same way.
diff --git a/routes/conversation.py b/routes/conversation.py
index 6017d28..93df80d 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -1702,7 +1702,17 @@ _TRANSIENT_INJURY_RE = re.compile(
r"blood\w*|wound\w*|bruis\w*|injur\w*|bleeding|swollen|disfigured|gash\w*|laceration\w*|"
r"bandag\w*|limp\w*|torn|scrape\w*|burn(?:s|ed|t)|broken|"
r"ru[ßs]\w*|verschmiert\w*|schmutz\w*|dreck\w*|verdreckt\w*|staubig\w*|soot\w*|smear\w*|smudge\w*|grim(?:e|y)|dirt\w*|muddy|"
- r"exhaust(?:ed|ion))\b",
+ r"exhaust(?:ed|ion)|"
+ # A passing emotion or reaction from one scene is not how someone looks: "rote Stirn bei Wut,
+ # Tränen in den Augen vor Wut" rendered as red marks on the face, every seed (Darrag).
+ r"(?:bei|vor)\s+(?:wut|zorn|angst|schreck|aufregung|anstrengung|schmerz\w*|scham)|tr[äa]nen\w*|weint\w*|"
+ r"zitter\w*|zuck\w*|err[öo]t\w*|schwitz\w*|keuch\w*|when angry|in tears|trembl\w*|twitch\w*|blush\w*|sweat\w*|"
+ r"zunge\w*|leck\w*|tongue\w*|lick\w*|"
+ # Scars are left out of image prompts altogether. Measured on Eolan (4 seeds each): a scar his
+ # own sheet calls almost faded rendered as a fresh red gash, and the sentence "every scar is an
+ # old, fully healed, pale line" put red slashes on his face, hands and robe. This model cannot
+ # draw a subtle scar; any mention becomes a wound.
+ r"narben?|scars?)\b",
re.IGNORECASE,
)
@@ -1717,13 +1727,28 @@ _INJURY_CLAUSE_RE = re.compile(
def _strip_injury_clauses(text: str) -> str:
- """Drop only the clauses that describe an acute injury; the rest of the text stays."""
- out = _INJURY_CLAUSE_RE.sub("", text)
- out = re.sub(r"^\s*[,;]\s*", "", out)
- out = re.sub(r"\s+([,;.])", r"\1", out)
- out = re.sub(r"([:])\s*[,;]\s*", r"\1 ", out)
- out = re.sub(r"\.{2,}", ".", out)
- return re.sub(r"\s{2,}", " ", out).strip()
+ """Drop only the clauses about an injury or a passing state; the rest of the text stays.
+
+ When the dropped clause starts a sentence, the whole sentence goes: what follows it ("und die
+ rechte Augenbraue") only makes sense with it. In the middle of a sentence ("Grüne Augen,
+ blutverschmierte Stirn und schwarzes Haar") only that clause goes.
+ """
+ kept = []
+ for sentence in re.split(r"(?<=[.!?])\s+", text or ""):
+ match = _INJURY_CLAUSE_RE.search(sentence)
+ if not match:
+ kept.append(sentence)
+ continue
+ if not sentence[:match.start()].strip(" ,;:"):
+ continue
+ cleaned = _INJURY_CLAUSE_RE.sub("", sentence)
+ cleaned = re.sub(r"^\s*[,;]\s*", "", cleaned)
+ cleaned = re.sub(r"\s+([,;.])", r"\1", cleaned)
+ cleaned = re.sub(r"([:])\s*[,;]\s*", r"\1 ", cleaned)
+ cleaned = re.sub(r"\.{2,}", ".", cleaned).strip()
+ if cleaned.strip(" .,;:"):
+ kept.append(cleaned)
+ return re.sub(r"\s{2,}", " ", " ".join(kept)).strip()
# Extraction copies the book's own words, and some of them read as modern or
@@ -1792,7 +1817,7 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str,
r"nicht spezifiziert|nicht festgelegt|nicht genannt|je nach kontext|sofern|falls|"
r"unspecified|not specified|depending on|if applicable|"
r"wir müssen vorsichtig|es ist unklar|laut (?:dem )?text|im kontext|"
- r"wird bei einem anderen|could be|might be)\b",
+ r"wird bei einem anderen|could be|might be|unklar|wird erwähnt|unclear|is mentioned)\b",
re.IGNORECASE,
)
sentences = re.split(r"(?<=[.!?])\s+", raw)
@@ -2404,22 +2429,6 @@ def _character_identity_phrase(name: str, sheet: dict) -> str:
return ", ".join(parts)
-_SCAR_RE = re.compile(r"\b(narben?|scars?)\b", re.IGNORECASE)
-
-
-def _character_scar_condition(*texts: str) -> str:
- """Positive wording for an established scar, so it renders healed instead of bleeding.
-
- A scar is permanent and deliberately survives the injury filter, but the model reads
- "Narbe"/"scar" as a fresh red gash — confirmed live on Eolan, whose own sheet calls his
- scar almost faded. Naming a wound to avoid would only draw one (see
- _positive_conditioning_text), so this states the healed condition instead.
- """
- if any(_SCAR_RE.search(text or "") for text in texts):
- return ("Every scar is an old, fully healed, pale silvery line flush with the surrounding skin. ")
- return ""
-
-
def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
book_profile: dict, visual_style: str,
saved_profile: str, saved_reference: str) -> tuple[str, str, str, str, str, list[str]]:
@@ -2537,10 +2546,6 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
baseline_lock = (
"PORTRAIT BASELINE: a calm, composed, healthy resting expression and healthy skin, showing the "
"character's everyday default appearance — not any single dramatic moment from the story. Clean skin and clean, well-kept garments. "
- + _character_scar_condition(
- _established_visual_value(sheet, "visual_appearance", "physical", 320, strip_injury=True),
- _visual_prompt_value(sheet.get("marks")),
- )
)
# baseline_lock covers the character's own skin/expression; the model's
# "gritty realistic fantasy" style bias separately adds blood staining to
@@ -2603,14 +2608,30 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
# guide into the costume. A single native triptych gives Qwen explicit
# spatial slots while keeping one face/costume interpretation across all
# three views. The app then crops the fixed thirds and assembles them.
+ # Short and framing-first. The long version (~3 000 characters, framing rules buried in the middle)
+ # was measured against this one on the same character, 5 seeds each, scored by the image check:
+ # full bodies 0/5 vs 5/5 on a 1536x1024 canvas (also 5/5 at 1536x1280), plain backdrop 2/5 vs 5/5.
+ # The fast 4-step model follows the start of a prompt and loses the tail, so wording changes inside
+ # the long prompt ("visible floor") did nothing. Only the rules a sheet actually needs are added,
+ # one short sentence each.
+ turnaround_subject = sheet_subject if len(sheet_subject) <= 700 else sheet_subject[:700].rsplit(" ", 1)[0]
+ turnaround_subject = turnaround_subject.rstrip(" .;,")
+ turnaround_rules = " ".join(filter(None, (
+ "Leather and cloth armour only, with small metal buckles." if leather_only else "",
+ "Bare head, the whole hairstyle visible." if bareheaded else "Only the established headwear, the same in every view.",
+ f"{ear_shape.capitalize()}." if ear_shape.startswith("pointed") else "",
+ proportion_lock.strip() if identical_noun != "adult person" else "",
+ "Clean healthy skin and clean, well-kept clothes.",
+ )))
+ turnaround_style = ("Photorealistic, medieval high-fantasy costume, hand-made materials."
+ if visual_style != "concept" else
+ "Semi-realistic hand-painted concept art, medieval high-fantasy costume, hand-made materials.")
front_view = (
- f"{head_lock}CREATE ONE CLEAN THREE-PANEL ORTHOGRAPHIC CHARACTER TURNAROUND, WITH EXACTLY THREE FULL-BODY FIGURES IN ONE HORIZONTAL ROW. "
- f"EXACT CHARACTER: {sheet_subject}. {period} {sheet_style} "
- "LEFT THIRD: one complete full-body strict front view, shoulders hips knees and feet square to camera. "
- "CENTER THIRD: one complete full-body strict side view with the entire body rotated exactly 90 degrees toward image-left. "
- "RIGHT THIRD: one complete full-body strict rear view looking directly away, showing the back of head, back garment seams, backs of legs and heels. "
- f"All three figures are the identical {identical_noun} with identical face, species, age, body proportions, hair, facial hair, costume construction, seams, palette, material wear, and footwear. {proportion_lock}Keep the exact anatomically established facial structure and species traits from Subject in every view. Use the same full-length 50mm camera distance in every panel. Each figure is a tall, upright, fully grown adult whose height is about four times the width of the body at the shoulders. Align all three crowns and all three soles horizontally; each complete head-to-boots silhouette occupies the same height and remains centered in its equal-width third. Use a neutral stance, relaxed arms, visibly empty hands, unobstructed belts, and unobstructed backs. "
- "Plain empty warm-white seamless studio background, neutral museum-soft light, soft contact shadows. Keep generous margin above every head and below every foot. The frame contains exactly the three required figures and the plain seamless background. Keep every surface unlettered and free of graphic overlays. Output 1536x1024."
+ f"Character turnaround reference sheet: three full-body views of the same one {identical_noun} standing side by side, "
+ "left a front view, middle a side profile, right a back view. Each figure is shown whole from the top of the head "
+ "to the soles of the shoes, feet standing on the floor, with empty space above the head and below the feet. "
+ "Plain empty white seamless studio background, soft even light. "
+ f"The {identical_noun}: {turnaround_subject}. {turnaround_rules} {turnaround_style}"
)
side_view = (
"Unused: the side view is generated in the controlled turnaround triptych."
diff --git a/static/index.html b/static/index.html
index 0ec6475..a7eb601 100644
--- a/static/index.html
+++ b/static/index.html
@@ -27,12 +27,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/tests/test_character_identity.py b/tests/test_character_identity.py
index a349f22..18fd91c 100644
--- a/tests/test_character_identity.py
+++ b/tests/test_character_identity.py
@@ -229,13 +229,34 @@ class TurnaroundIdentityTests(unittest.TestCase):
def test_the_three_views_are_generated_as_one_triptych(self):
"""Per-view edit renders returned three front views and copied the pose guide into the costume."""
_portrait, triptych, side, back, *_rest = self.prompts(clothing="Wollrock")
- self.assertIn("THREE-PANEL ORTHOGRAPHIC CHARACTER TURNAROUND", triptych)
- self.assertIn("LEFT THIRD", triptych)
- self.assertIn("CENTER THIRD", triptych)
- self.assertIn("RIGHT THIRD", triptych)
+ self.assertIn("three full-body views of the same one", triptych)
+ self.assertIn("left a front view, middle a side profile, right a back view", triptych)
for unused in (side, back):
self.assertIn("Unused", unused)
+ def test_the_turnaround_prompt_is_short_and_framing_first(self):
+ """Measured: framing rules buried in a ~3 000-character prompt were ignored (feet cut off 5/5,
+ scenery backdrop); the short framing-first prompt passed 10/10."""
+ triptych = self.prompts(clothing="Wollrock", physical="Kurzes Haar.")[1]
+ self.assertLess(len(triptych), 1500)
+ self.assertTrue(triptych.startswith("Character turnaround reference sheet"))
+ framing = triptych.index("top of the head to the soles of the shoes")
+ self.assertLess(framing, triptych.index("The adult person:"))
+ self.assertIn("Plain empty white seamless studio background", triptych)
+ self.assertNotIn("..", triptych)
+
+ def test_a_long_saved_subject_is_trimmed_to_keep_the_framing_on_top(self):
+ triptych = self.prompts(physical="Sehr lange Beschreibung. " * 80)[1]
+ self.assertLess(len(triptych), 1500)
+
+ def test_turnaround_keeps_the_rules_that_matter(self):
+ leather = self.prompts(clothing="Abgenutzte Lederrüstung.")[1]
+ self.assertIn("Leather and cloth armour only", leather)
+ elf = self.prompts(race_species="Elfe")[1]
+ self.assertIn("Pointed elf-shaped ears", elf)
+ child = self.prompts(physical="Ein Kind von acht Jahren.")[1]
+ self.assertIn("same one child", child)
+
def test_prop_prompts_are_object_only(self):
*_, props = self.prompts(clothing="Rüstung", inventory="Schwert")
self.assertTrue(props)
@@ -278,12 +299,19 @@ class RenderDefectTests(unittest.TestCase):
scene = self.prompts({"gender": "male", "physical": "Ein Kind von acht Jahren."}, "Jorin")[4]
self.assertIn("boy named Jorin", scene)
- def test_an_established_scar_is_stated_as_healed(self):
- portrait = self.prompts({"physical": "Eine fast verblaßte Narbe zieht sich über die Stirn."})[0]
- self.assertIn("fully healed", portrait)
+ def test_scars_are_left_out_of_image_prompts(self):
+ """Any scar wording -- even "fully healed, pale" -- rendered as fresh red gashes (Eolan)."""
+ prompts = self.prompts({"physical": "Weißhaarig. Eine lange Narbe zieht sich über die Wange.", "marks": "scar"})
+ for prompt in (prompts[0], prompts[1]):
+ self.assertIn("Weißhaarig", prompt)
+ self.assertNotIn("Narbe", prompt)
+ self.assertNotIn("scar", prompt.lower())
- def test_a_character_without_a_scar_gets_no_scar_wording(self):
- self.assertNotIn("scar", self.prompts({"physical": "Blondes Haar."})[0].lower())
+ def test_a_gesture_of_the_moment_is_not_drawn(self):
+ """"Zunge streicht über die Lippen" rendered as a tongue sticking out (Eolan)."""
+ triptych = self.prompts({"physical": "Asketisches Gesicht. Zunge streicht über die Lippen."})[1]
+ self.assertIn("Asketisches Gesicht", triptych)
+ self.assertNotIn("Zunge", triptych)
def test_an_armed_character_is_told_how_to_hold_the_weapon(self):
portrait = self.prompts(self.KARYLA, "Karyla")[0]