From fb4cdbba0c319b8ee00108e8efc79d1f67985731 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 20 Sep 2026 06:19:56 +0200 Subject: [PATCH] feat(characters): turnaround drawn from the portrait, Dark Eye medieval setting, object-only prop tiles Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + architecture/character-visual-identity.md | 11 +++ routes/conversation.py | 94 +++++++++++++++-------- tests/test_character_identity.py | 33 +++++++- 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d482733..8b62e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ### Changed - **Character images: period-safe, uninjured, less uniform.** Costume and appearance text sent to the image model is now rewritten to period garments (Uniform, Weste, Hut, Sporen, Jacke, Schal … became livery tabard, jerkin, felt cap, doublet …) because the extracted sheets contained such words for ~45 characters and the images came out in wartime/cowboy dress; injury clauses (blood, wounds, bandages, burns, limping, torn clothes) are removed clause by clause from appearance, costume and the story-moment text instead of dropping whole sentences; characters with armour or weapons get an individual garment cut and colour scheme (stable per name, colours named in the sheet win) instead of one shared list of gambeson/brigandine/plate. Saved prompts are cleaned the same way at render time. Adult figures are described as eight head-heights tall with a small head, to counter the short, stubby turnaround figures. The stored sheets are unchanged. +- **The turnaround now shows the same person as the profile picture.** Front, side and rear views are drawn one by one by the edit model from the identity portrait (the old text-only triptych invented a new face). Book setting reworded to Dark Eye high-fantasy medieval (was "early Renaissance 1420-1500", which produced musketeer/wartime looks), shields/cloaks no longer added to everyone, soot/dirt removed like injuries, and prop tiles are object-only (they used to show a person). Existing image pairs are unchanged until regenerated. ### Fixed - **Character-sheet extraction returned nothing with a reasoning model**: the blocking `/api/character-sheets` call could spend its whole token budget thinking and answer with empty content. It now retries once with thinking switched off (and once more without the switch if the backend rejects it). diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md index da8f74d..a1723c5 100644 --- a/architecture/character-visual-identity.md +++ b/architecture/character-visual-identity.md @@ -53,3 +53,14 @@ the character, its voice and the base look stay), show, and generate matched ima for all forms. Generation rebuilds that form's prompts first (unless hand-edited), saves the pair on that form, then restores whichever appearance was active. Editing Physical/Clothing while a form is active changes only that form. + + +## Image prompt hygiene and the turnaround (1.34.x) + +* **Setting.** Prompts describe the books as *The Dark Eye* high-fantasy medieval dress (about 900-1300 AD: wool and linen tunics, belted robes, leather, mail where the sheet names it). The earlier "early Renaissance 1420-1500" wording pulled uniforms, coats, hats and boots that read as musketeer/cowboy/wartime. +* **Wording.** Extracted costume text is rewritten at prompt time (`_period_wording`: Uniform, Weste, Hut, Sporen, Jacke, Schal … become surcoat, jerkin, hood, boots, tunic …). Stored sheets are not changed. +* **Injuries and dirt.** `_strip_injury_clauses` removes clauses about blood, wounds, bandages, burns, limping, torn clothes, soot and dirt from appearance, costume and the story moment; the rest of the sentence stays. The portrait baseline asks for clean skin and garments. +* **Variety.** Characters with armour or weapons get an individual garment cut and colour scheme (`_character_design_variation`, stable per name; colours the sheet names win) instead of one shared list. +* **Same person in portrait and sheet.** The profile picture is a crop of the full-body identity image. The front, side and rear views are drawn one by one with the edit model using that identity image as the only reference (no pose guides), then stitched into the three-panel image the compositor already crops. The old text-only triptych produced a different face. +* **Props** get an object-only period sentence, so a tile no longer turns into a portrait of the character. +* Older saved prompts are cleaned the same way at render time. Existing image pairs are unchanged until regenerated. diff --git a/routes/conversation.py b/routes/conversation.py index 722643b..4621cde 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -1700,6 +1700,7 @@ _TRANSIENT_INJURY_RE = re.compile( r"schramm\w*|abschürfung\w*|striemen|zerfetzt\w*|zerrissen\w*|angeschlagen|" 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", re.IGNORECASE, ) @@ -1729,22 +1730,22 @@ def _strip_injury_clauses(text: str) -> str: # "Sporen"). Rewrite them to period garments before they reach a prompt; # the stored sheet is not changed. _PERIOD_WORDING = ( - (r"offiziers[- ]?uniform\w*|officer'?s? uniform", "tailored officer's doublet and tabard in house colours"), - (r"(?:soldaten|milit[äa]r|wach|garde)[- ]?uniform\w*|soldier'?s? uniform", "quilted gambeson with a livery tabard"), - (r"uniformjacke\w*|uniform jacket", "quilted arming doublet"), - (r"uniform(?:en|e)?\b|uniformiert\w*", "livery tabard and gambeson"), + (r"offiziers[- ]?uniform\w*|officer'?s? uniform", "long belted tunic under a heraldic surcoat"), + (r"(?:soldaten|milit[äa]r|wach|garde)[- ]?uniform\w*|soldier'?s? uniform", "mail shirt under a wool surcoat in house colours"), + (r"uniformjacke\w*|uniform jacket", "quilted gambeson"), + (r"uniform(?:en|e)?\b|uniformiert\w*", "mail shirt under a wool surcoat in house colours"), (r"soldatenkleidung|soldatische\w*", "period men-at-arms' dress"), (r"soldatenstiefel", "stitched leather boots"), (r"milit[äa]rische\w*|milit[äa]r\w*", "martial"), - (r"jacken?\b|jacket\w*", "doublet"), + (r"jacken?\b|jacket\w*", "wool tunic"), (r"westen?\b|waistcoat\w*|vest\b", "sleeveless leather jerkin"), (r"schals?\b|scarf|scarves", "wool neck-wrap"), - (r"h[üu]te?\b(?!\w)|\bhats?\b", "soft felt cap"), - (r"sporen|spurs?", "riding boots"), - (r"hosen?\b|trousers|pants", "hose"), + (r"h[üu]te?\b(?!\w)|\bhats?\b", "soft wool hood"), + (r"sporen|spurs?", "stitched leather boots"), + (r"hosen?\b|trousers|pants", "wool leggings"), (r"orden\b|medals?", "guild badge"), (r"kragen\b|collar\w*", "standing collar"), - (r"epauletten|schulterklappen|epaulet\w*", "shoulder pauldron"), + (r"epauletten|schulterklappen|epaulet\w*", "leather shoulder guard"), ) _PERIOD_WORDING_RES = tuple((re.compile(r"\b(?:" + pat + r")", re.IGNORECASE), repl) for pat, repl in _PERIOD_WORDING) @@ -2079,9 +2080,9 @@ def _period_lock(book: str, book_profile: dict, sheet: dict) -> str: ) return ( f"Book lock: {_book_visual_context(book, book_profile)}. " - f"Use museum-authentic Aventurian early-Renaissance c.1420-1500 {materials}. " + f"Use museum-authentic {_FANTASY_DRESS} {materials}. " "Garments use ties, lacing, toggles, hand-sewn buttons, hooks, or simple forged buckles; footwear uses stitched leather soles and period construction. " - "Every visible design element is pre-industrial and period-authentic, with natural materials, hand-worked construction, flat stitched-leather soles, and age-appropriate wear. " + "Every visible design element is hand-forged or hand-sewn in the manner of that world, with natural materials, hand-worked construction, flat stitched-leather soles, and age-appropriate wear. " f"{armor_note}" f"{headwear}" ) @@ -2167,7 +2168,7 @@ def _prop_render_description(item: str) -> str: value = _visual_prompt_value(item).strip() lowered = value.lower() rules = ( - (r"\bcrossbow\b|armbrust\b", "one complete early-Renaissance crossbow: a horizontal wooden bow mounted across a wooden stock, with visible string, nut, trigger, and stirrup"), + (r"\bcrossbow\b|armbrust\b", "one complete medieval crossbow: a horizontal wooden bow mounted across a wooden stock, with visible string, nut, trigger, and stirrup"), (r"\b(longbow|shortbow|bow)\b|bogen\b", "one complete traditional wooden archery bow: one long curved wooden stave with one taut bowstring running continuously from tip to tip"), (r"\b(zweihänder|sword|blade)\b|schwert\b", "one complete forged sword with exactly one blade, one guard, one grip, and one pommel"), (r"\b(dagger|knife)\b|(?:dolch|messer)\b", "one complete short dagger with exactly one blade, one guard, one grip, and one pommel"), @@ -2178,7 +2179,7 @@ def _prop_render_description(item: str) -> str: (r"\bhorseshoe\b|hufeisen\b", "one single U-shaped hand-forged iron horseshoe with evenly spaced nail holes"), (r"\b(rolle|schriftrolle|scroll|document|map|karte)\b", "one complete rolled parchment document tied with a plain cord, its surface blank and unlettered"), (r"\b(phiole|vial|fläschchen|bottle)\b", "one small complete period glass vial with a plain fitted stopper"), - (r"\b(laterne|lantern|lamp)\b", "one complete early-Renaissance hand lantern of forged iron and translucent horn panels"), + (r"\b(laterne|lantern|lamp)\b", "one complete medieval hand lantern of forged iron and translucent horn panels"), # Worn/held items are the ones most likely to pull a whole person into # frame — an amulet or crown alone is a weak object cue, but strongly # associated with someone wearing it. Naming the object as a @@ -2342,15 +2343,21 @@ def _positive_conditioning_text(value: str) -> str: return re.sub(r"\s{2,}", " ", value).strip(" ,;") +_FANTASY_DRESS = ( + "high-fantasy medieval (roughly 900-1300 AD, The Dark Eye) dress: wool and linen tunics, belted robes, " + "leather belts and jerkins, and simple boots, made from" +) + + _GARMENT_CUTS = ( - "a quilted gambeson with a knee-length tabard", - "a riveted leather brigandine over a linen shirt", + "a quilted gambeson under a knee-length surcoat", + "a boiled-leather cuirass over a linen shirt", "a mail hauberk under a hooded surcoat", "a scale-lined leather cuirass with a short fur-trimmed cloak", "a padded aketon with a heavy wool cloak clasped at one shoulder", "a lamellar leather harness over a long-sleeved tunic", - "a short arming doublet with laced sleeves and a wide leather belt", - "a long belted surcoat over a mail coif and hose", + "a short belted tunic with laced sleeves and a wide leather belt", + "a long belted surcoat over a mail coif and wool leggings", ) _GARMENT_COLOURS = ( "faded indigo and undyed linen", "oxblood red and dark brown leather", "moss green and buff", @@ -2374,7 +2381,7 @@ def _character_design_variation(name: str, sheet: dict) -> str: 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]]: + saved_profile: str, saved_reference: str) -> tuple[str, str, str, str, str, list[str], str]: """Short, layout-first prompts for the local Qwen/Flux encoder. The detailed masters remain visible/editable in the UI. These render @@ -2450,8 +2457,8 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, # actually established. authority_lock = ( ( - "Any military rank, command, or authority in this world is shown only through hand-forged period arms and " - f"dress, cut individually for this character: {_character_design_variation(name, sheet)}. " + "Any rank or authority is shown only through the cut and colours of the clothing, individual to this character: " + f"{_character_design_variation(name, sheet)}. " ) if _character_has_established_combat_gear(sheet) else "" ) @@ -2488,7 +2495,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, # still covered by "appearance" above and isn't contradicted by this. baseline_lock = ( "PORTRAIT BASELINE: a calm, composed, healthy resting expression and unmarked skin, showing the " - "character's everyday default appearance — not any single dramatic moment from the story. " + "character's everyday default appearance — not any single dramatic moment from the story. Clean, unmarked skin and clean, well-kept garments. " ) # baseline_lock covers the character's own skin/expression; the model's # "gritty realistic fantasy" style bias separately adds blood staining to @@ -2502,7 +2509,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, "daily use. " ) period = ( - f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. Aventurian early Renaissance c.1420-1500 only: " + f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. high-fantasy medieval costume design (The Dark Eye), roughly 900-1300 AD: homespun wool and linen tunics, long belted robes, leather belts and jerkins, and simple stitched-leather boots only: " f"{period_materials}. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. " f"{period_armor_note}" f"{authority_lock}" @@ -2553,12 +2560,20 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, 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." ) - side_view = ( - "Unused: the side view is generated in the controlled turnaround triptych." + # Per-view edit prompts: each view is drawn from the identity portrait as the reference person, so + # the turnaround shows the same face as the profile picture. + same_person = ( + "Use the person in the reference image exactly: the same face, hair, age, build, clothing, colours and materials. " + + head_lock ) - back_view = ( - "Unused: the rear view is generated in the controlled turnaround triptych." + view_tail = ( + f"{proportion_lock}One complete full-body figure from crown to boots with margin above and below, neutral relaxed stance, " + "arms at the sides, visibly empty hands, plain empty warm-white seamless studio background, soft even museum light. " + "Output 512x1024." ) + front_edit = same_person + "CAMERA: strict front view, shoulders hips knees and feet square to the camera. " + view_tail + side_view = same_person + "CAMERA: strict left side profile, the whole body turned exactly 90 degrees toward image-left. " + view_tail + back_view = same_person + "CAMERA: seen from directly behind, showing the back of the head, the back of the garments and the heels. " + view_tail scene_prompt = ( ("HEAD CONTINUITY: Every visible person's scalp, crown, hairline, ears, and natural hairstyle are clearly visible and unobstructed. " if bareheaded else "") + "CREATE ONE HORIZONTAL PHOTOREALISTIC SOURCE-GROUNDED STORY MOMENT WITH NATURAL ACTION. " @@ -2567,15 +2582,19 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict, "Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor. Preserve the stated face, age, species, build, hair, costume, materials, and period. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. " f"{period} {sheet_style} Use a believable location from the book, restrained cinematic staging, and complete uncropped head and hands. Keep all surfaces unlettered and free of graphic overlays. Output 768x512." ) + prop_period = ( + f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. " + "High-fantasy medieval (roughly 900-1300 AD, The Dark Eye) hand-forged and hand-worked objects. " + ) prop_prompts = [ ( "CREATE ONE SINGLE ISOLATED MUSEUM-CATALOG OBJECT PHOTOGRAPH. " f"OBJECT IDENTITY — FOLLOW LITERALLY: {_prop_render_description(item)}. Show exactly one complete assembled object, fully visible and centered on a plain uniform warm-white sweep with at least fifteen percent empty margin on every side. " - f"{period} Use neutral museum-soft product lighting and physically believable, hand-worked period materials. The frame is an uncluttered object-only catalog plate containing a single centered object and empty space around it. Keep every surface unlettered and free of graphic overlays. Output 512x512." + f"{prop_period} Use neutral museum-soft product lighting and physically believable, hand-worked period materials. The frame is an uncluttered object-only catalog plate containing a single centered object and empty space around it. Keep every surface unlettered and free of graphic overlays. Output 512x512." ) for item in props ] - return portrait, front_view, side_view, back_view, scene_prompt, prop_prompts + return portrait, front_view, side_view, back_view, scene_prompt, prop_prompts, front_edit @router.post("/api/character-generate-prompts") @@ -3281,7 +3300,7 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, front_prompt: str, side_prompt: str, back_prompt: str, scene_prompt: str, prop_prompts: list[str], style_key: str = "", - progress=None) -> dict: + progress=None, front_edit_prompt: str = "") -> dict: """Generate independently controlled views, scene and unique prop tiles.""" try: workflow = json.loads(workflow_json) @@ -3316,7 +3335,18 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, "big", ) graph["68"].setdefault("inputs", {})["seed"] = view_seed("identity-turnaround") - graph["72"].setdefault("inputs", {})["seed"] = view_seed("source-scene") + if front_edit_prompt: + # Front, side and rear are drawn separately by the edit model with the identity portrait as the only + # reference (no pose guides), then stitched into the same 3-panel image the compositor expects. + for text_node, sampler, seed_label, text in ( + ("10", "12", "front", front_edit_prompt), ("14", "16", "side", side_prompt), ("18", "40", "back", back_prompt), + ): + graph[text_node]["inputs"].pop("image2", None) + graph[text_node]["inputs"]["prompt"] = text + graph[sampler]["inputs"]["seed"] = view_seed("edit-" + seed_label) + graph["turnaround_save"]["inputs"]["images"] = ["32", 0] + for dead in ("turnaround_decode", "68", "69", "66", "67", "42", "43", "44", "45", "front_save", "side_save", "back_save"): + graph.pop(dead, None) for node_id, label in (("26", "prop-1"), ("52", "prop-2"), ("56", "prop-3")): graph[node_id].setdefault("inputs", {})["seed"] = view_seed(label) client_id = str(uuid.uuid4()) @@ -3540,7 +3570,7 @@ async def character_generate_consistent_images(request: Request): # The UI stores the full editable production masters. The local image # encoder receives compact, layout-first versions so its limited context # cannot truncate FRONT/SIDE/BACK or the period/headwear constraints. - profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts = _build_compact_character_render_prompts( + profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts, front_edit_prompt = _build_compact_character_render_prompts( name, book, sheet, book_profile, visual_style, profile_direction or portrait_master, reference_direction or sheet_master, @@ -3623,7 +3653,7 @@ async def character_generate_consistent_images(request: Request): result = _comfyui_run_consistent_images( comfy_url, _bundled_consistent_comfyui_workflow(), identity_image, profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts, - generation_key, + generation_key, front_edit_prompt=front_edit_prompt, progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)), ) result = _consistent_image_result_metadata(sheet, result) diff --git a/tests/test_character_identity.py b/tests/test_character_identity.py index 6e2897c..3b91039 100644 --- a/tests/test_character_identity.py +++ b/tests/test_character_identity.py @@ -180,7 +180,7 @@ class PromptHygieneTests(unittest.TestCase): text = self.facts(clothing="Offiziersuniform mit Weste, Hut und Sporen. Uniformjacke, Schal.") for modern in ("uniform", "weste", "hut", "sporen", "jacke", "schal"): self.assertNotIn(modern, text.lower(), modern) - self.assertIn("doublet", text) + self.assertIn("surcoat", text) def test_injury_clauses_are_dropped_but_the_rest_of_the_sentence_stays(self): text = self.facts(physical="Grüne Augen, blutverschmierte Stirn und schwarzes Haar. Sein Arm ist verbunden.") @@ -189,6 +189,11 @@ class PromptHygieneTests(unittest.TestCase): self.assertNotIn("blut", text.lower()) self.assertNotIn("verbunden", text.lower()) + def test_soot_and_dirt_are_not_painted_on(self): + text = self.facts(physical="Wallendes Haar, rußverschmierte Backen.") + self.assertIn("Wallendes Haar", text) + self.assertNotIn("ruß", text.lower()) + def test_injured_clothing_is_cleaned_too(self): text = self.facts(clothing="Graue Robe, an der Seite zerrissen und blutig.") self.assertIn("Graue Robe", text) @@ -206,5 +211,31 @@ class PromptHygieneTests(unittest.TestCase): self.assertIn("colours already described", _character_design_variation("Alrik", {"clothing": "rote Rüstung"})) +class TurnaroundIdentityTests(unittest.TestCase): + def prompts(self, **sheet): + from routes.conversation import _build_compact_character_render_prompts + return _build_compact_character_render_prompts("Test", "Buch", sheet, {}, "cinematic", "", "") + + def test_each_view_is_drawn_from_the_portrait_as_the_reference_person(self): + _, _front_triptych, side, back, _scene, _props, front_edit = self.prompts(clothing="Wollrock") + for view in (front_edit, side, back): + self.assertIn("reference image exactly", view) + self.assertIn("front view", front_edit) + self.assertIn("side profile", side) + self.assertIn("directly behind", back) + + def test_prop_prompts_are_object_only(self): + *_, props, _front_edit = self.prompts(clothing="Rüstung", inventory="Schwert") + self.assertTrue(props) + for prompt in props: + for person_word in ("scalp", "expression", "CLOTHING LOCK", "hairstyle"): + self.assertNotIn(person_word, prompt) + + def test_the_book_period_is_high_fantasy_medieval_not_renaissance(self): + portrait = self.prompts(clothing="Wollrock")[0] + self.assertNotIn("Renaissance", portrait) + self.assertIn("The Dark Eye", portrait) + + if __name__ == "__main__": unittest.main()