fix(characters): ground the concept sheet's lower pictures in the character and the book's world

The story moment was a ~5,400-character prompt whose period rules sat at
the end, out of reach of the 4-step model; it opened with "ears clearly
visible" and asked for studio photography by a photographer with a real
actor, and drew a modern man with headphones and a camera in a garden,
captioned with pseudo-text. The prop tile was a stock catalog photo. The
negative prompts were inert because those samplers run at cfg 1.0.

Story moment and signature-item prompts are now short, world-first and
positive-only: The Dark Eye, 900-1300 AD and the book profile's setting
(never the title), the same identity, appearance and costume as the
turnaround, the book moment, a period background and a hand-made,
unmarked world; the item is the character's own, in the craftsmanship
and colours of their costume, on a table in a medieval room. Text and
modern objects are added to the scene and prop negatives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-02 08:00:53 +02:00
parent 6d1c6c1b88
commit 1a527c3b41
4 changed files with 266 additions and 39 deletions

View File

@ -11,6 +11,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Fixed
- **No divider lines on the character concept sheet.** The Konzeptbild composer drew light lines between the three poses and between the top and bottom rows, which cut through capes and swords. They are gone. Sheets already stored have the lines baked into the image file and need to be regenerated to lose them.
- **The two lower pictures of the concept sheet now show the character in the book's world.** For Marcian (a knight in plate and a red cloak, *Das Jahr des Greifen*) the story picture showed a modern man in a polo shirt with headphones and a camera in a garden, covered in pseudo-text captions, and the item tile a stock crossbow on a white backdrop. The story prompt was about 5,400 characters with the period rules at the very end, which the fast 4-step model never reaches; it opened with "ears clearly visible" and asked for "studio photography by a production photographer with a real actor", and words such as "unlettered", "overlays" and "Output 768x512" invited writing. The negative prompt that lists text and modern clothing had no effect, because these samplers run at cfg 1.0, where ComfyUI ignores it. The story prompt is now short and starts with the world (medieval high fantasy, The Dark Eye, 900-1300 AD, plus the book profile's setting, without the book title, which was read as lettering), then the same character as the turnaround (identity, appearance, costume, what the portrait shows), the moment from the book, a period background, and a hand-made, unmarked world; the item is the character's own, made in the colours and craftsmanship of their costume, on an oak table in a medieval room. Both are worded positively only; text, captions, headphones, cameras and modern clothing and buildings are added to the scene and item negative prompts for when the cfg is raised. Checked with two renders of Marcian on the local ComfyUI: a knight in plate and red cloak with a crossbow in a medieval village, and his crossbow on a table, with no text and nothing modern. Existing sheets need to be regenerated.
- **Review opens at the top.** Opening a page in review no longer keeps the scroll position of the page grid, which could leave the sticky page bar covering the top of the page picture.

View File

@ -64,6 +64,40 @@ character merge in the cast view for those.
the right speaker. Similar-looking characters in the same scene are the usual
mix-up; check those in review.
### Character concept sheet (Konzeptbild)
**Purpose:** one picture per character for costume continuity: the character from the
front, the side and the back on top; below, the same character in a moment from the
book, and their own items (up to three, e.g. Marcian's crossbow).
**Prerequisites:** a local ComfyUI (Settings > Engines > Image Generation) and the
character's Image and Concept Art prompts (Cast Studio > character > Generation Prompts).
**Step by step:** in Cast Studio tick the characters and choose **Consistent profile +
reference**, or open a character and regenerate its Konzeptbild. Pick *Cinematic* or
*Concept art* in the style menu first.
**Expected result:** the lower pictures show the same person as the poses (face, hair,
armour or clothing, colours), in the medieval high-fantasy world of the book (stone,
timber, tents, torch or daylight, other people in medieval dress), and the item lies on
a table in that world, made in the style and colours of its owner's costume. They contain
no writing, captions or modern things. The face in the story moment is taken from the
profile picture.
**Errors and limits:** the fast local model still gets details wrong now and then (a
crossbow with a misshapen stock, a second weapon in the background, *Concept art* coming
out close to photographic). Regenerate the sheet; each run uses a new seed. The automatic
image check looks at gender, ears and wounds in the story moment, not at modern objects
or text, so look at the lower pictures yourself. Sheets made before 2026-10-02 keep their
old lower pictures until they are regenerated.
**Privacy and offline:** only the prompts and the profile picture go to your own
ComfyUI; nothing leaves your network. Without a reachable ComfyUI nothing is generated.
**Accessibility and mobile:** the sheet is shown as one image with the character's name
as its alternative text; the character's written profile next to it carries the same
information.
---
## Magazines

View File

@ -2771,52 +2771,118 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
back_view = (
"Unused: the rear view is generated in the controlled turnaround triptych."
)
scene_gender = _visual_prompt_value(sheet.get("gender")).strip().lower()
scene_gender_lock = (
"FOCAL GENDER LOCK: the single prominent foreground character is unmistakably female, with clearly feminine facial features and build; "
if scene_gender in {"female", "weiblich"} else
"FOCAL GENDER LOCK: the single prominent foreground character is unmistakably male, with clearly masculine facial features and build; "
if scene_gender in {"male", "männlich", "maennlich"} else ""
)
scene_period_lock = (
"HISTORICAL SCENE LOCK: this is a scene from the book's pre-industrial early-Renaissance fantasy world, "
"with period-authentic wool, linen, leather, forged metal, timber, stone, earth, and candle or daylight illumination; "
"all visible people wear the established period costume and the setting contains only the book's historical-fantasy world. "
)
# The weapon-integrity sentence belongs only to characters who carry one; for everyone else it
# planted a hilt and blade into the scene.
scene_weapon_integrity = (
"Every held or worn weapon is one single complete object with exactly one hilt and one blade or head, "
"correctly proportioned and physically attached, never a duplicated or fused shape. "
) if _character_has_established_combat_gear(sheet) else ""
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. "
f"EXACT CHARACTER: {sheet_subject}" + (f"; exactly as in the portrait: {portrait_look}" if portrait_look else "") + ". "
f"BOOK-SOURCE EVIDENCE: {scene_evidence}. "
+ scene_period_lock
+ scene_gender_lock
+ f"FOCAL PERSON: {_character_identity_phrase(name, sheet)}, exactly as described above, the single clear subject in the foreground. Place the focal person's fully visible head, face, upper body, and both hands prominently in the near foreground so the character identity is immediately readable; the location and any supporting figures remain secondary background context. "
"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, with exactly one head and exactly two hands, each holding at most one item, and the same species, skin, and facial features as the turnaround sheet — never rendered as an unmarked ordinary human when a non-human species is established. Preserve the stated face, age, species, build, hair, costume, materials, and period. "
+ scene_weapon_integrity
+ "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. "
scene_prompt = _build_story_moment_prompt(
name, sheet, book_profile, visual_style, turnaround_subject,
turnaround_rules, scene_evidence, weapon_handling_lock + ranged_weapon_lock,
)
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"{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."
)
_build_signature_item_prompt(name, sheet, book_profile, visual_style, item, established_costume)
for item in props
]
return portrait, front_view, side_view, back_view, scene_prompt, prop_prompts
# ── The two lower panels of the reference sheet: story moment and signature items ──
#
# The story moment used to be a ~5 400-character prompt (the whole portrait rule set appended to
# it) that opened with "every person's ... ears ... clearly visible" and asked for
# "PHOTOREALISTIC ... costume-continuity studio photography by that same production
# photographer, with a real actor". The 4-step model keeps the start of a prompt and loses the
# tail (measured on the turnaround, see above), so the period rules at the end never arrived:
# for Marcian (a knight in plate and a red cloak, Das Jahr des Greifen) it drew a modern man in
# a polo shirt with headphones over the "visible ears" and a camera, in a suburban garden,
# captioned with pseudo-text ("Keep all surfaces unlettered", "Output 768x512" and the
# all-caps "EVIDENCE"/"LOCK" headings are text cues in themselves). The prop tile was a stock
# catalog photograph on a white sweep, with nothing tying it to its owner or his world.
#
# Both are now short, world-and-identity first, and positive only: the Qwen encoder draws a noun
# it reads even after "no"/"without" (see _positive_conditioning_text), so nothing to avoid is
# named in them. Exclusions go to the negative prompt (_LOWER_PANEL_NEGATIVE) instead.
# Merged into the scene and prop negative prompts. Note: those KSamplers run the 4-step
# Lightning model at cfg 1.0, where ComfyUI skips the negative conditioning entirely, so this
# only takes effect if the cfg is ever raised; the positive wording carries the load.
_LOWER_PANEL_NEGATIVE = (
"text, letters, words, writing, caption, subtitle, label, sign, banner text, pseudo-text, "
"Chinese characters, watermark, logo, signature, frame, border, collage, split screen, "
"headphones, headset, earbuds, camera, microphone, phone, wristwatch, eyeglasses, "
"modern clothing, t-shirt, polo shirt, jeans, hoodie, zipper, modern hairstyle, "
"modern building, street, car, power line, lamp post, suburban garden, plastic"
)
_LOWER_PANEL_WORLD = "the medieval high-fantasy world of The Dark Eye (Aventuria), roughly 900-1300 AD"
def _lower_panel_world(book_profile: dict) -> str:
"""The book's world for the lower panels. The book title is left out on purpose: a quoted
title is read as lettering to draw, and "Der Sturm" as weather."""
values = [_visual_prompt_value((book_profile or {}).get(key)) for key in ("setting", "era", "visual_context")]
context = "; ".join(value for value in values if value)
context = re.sub(r"(?:early[- ]?)?renaissance|fr[üu]h(?:-)?renaissance", "high-fantasy medieval", context, flags=re.IGNORECASE)
context = re.sub(r"\b1420\s*[–-]\s*1500\b", "roughly 900-1300 AD", context)
context = _positive_conditioning_text(_period_wording(context))[:240].rstrip(" ,;")
return f"{_LOWER_PANEL_WORLD} ({context})" if context else _LOWER_PANEL_WORLD
def _lower_panel_medium(visual_style: str) -> str:
# "Film still", not "photograph": photo-shoot words drew a photographer's camera and a modern
# model into the frame.
if visual_style == "concept":
return "A semi-realistic hand-painted medieval high-fantasy illustration"
return "A cinematic film still from a medieval high-fantasy epic, live action, natural light"
_LOWER_PANEL_CRAFT = (
"Everything in the picture is hand-made and pre-industrial: forged iron and steel, carved wood, "
"stitched leather, wool, linen, stone. All surfaces are plain and unmarked, and the image is one "
"single seamless picture."
)
def _build_story_moment_prompt(name: str, sheet: dict, book_profile: dict, visual_style: str,
subject: str, rules: str, evidence: str,
weapon_rules: str) -> str:
"""The lower-left panel: the same character as the turnaround, in a moment from the book."""
sheet = _character_form_sheet(sheet)
gender = _visual_prompt_value(sheet.get("gender")).strip().lower()
pronoun = {"female": "her", "weiblich": "her", "male": "him", "männlich": "him", "maennlich": "him"}.get(gender, "them")
evidence = evidence.strip().rstrip(" .")
if len(evidence) > 360:
evidence = evidence[:360].rsplit(" ", 1)[0].rstrip(" ,;:")
return " ".join(filter(None, (
f"{_lower_panel_medium(visual_style)}: one moment from the story, set in {_lower_panel_world(book_profile)}.",
f"In the foreground, seen from the waist up with the whole head in frame: {_character_identity_phrase(name, sheet)}, "
f"exactly the character of the turnaround, with the same face, hair, costume and colours — {subject}.",
rules,
f"The moment: {evidence}." if evidence else "",
f"Behind {pronoun}: a place from this world — stone walls, timber, thatch, field tents or open country — "
"lit by daylight, torches or candles; any other people stay small in the background and wear medieval clothing.",
weapon_rules.strip(),
_LOWER_PANEL_CRAFT,
)))
def _build_signature_item_prompt(name: str, sheet: dict, book_profile: dict, visual_style: str,
item: str, costume: str) -> str:
"""A lower-right tile: the character's own item, at home in the book's world."""
sheet = _character_form_sheet(sheet)
description = _prop_render_description(item).replace("on the sweep", "on the table")
role = _primary_visual_role(sheet)
owner = f"{name}, {role}" if name and role else (name or role or "its owner")
costume = costume.strip().rstrip(" .…")
if len(costume) > 200:
costume = costume[:200].rsplit(" ", 1)[0].rstrip(" ,;:")
match = f" ({costume})" if costume else ""
return " ".join((
f"{_lower_panel_medium(visual_style)}: a still life of a single object in {_lower_panel_world(book_profile)}.",
f"The object: {description}. It is the personal, well-used possession of {owner}, made with the same "
f"craftsmanship, materials and colours as the owner's costume{match}.",
"It lies whole and fully visible in the centre of the frame on a rough oak table in a medieval room, "
"the object alone on the table, with a softly blurred background of stone, timber and candle light.",
_LOWER_PANEL_CRAFT,
))
# ── Automatic image check ───────────────────────────────────────────────────
_IMAGE_CHECK_MAX_URI = 12_000_000 # a data URI; a 1536x1536 PNG is ~3-5 MB
@ -3695,6 +3761,11 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str,
graph["4"].setdefault("inputs", {})["image"] = _comfyui_upload_data_uri(comfyui_url, identity_image)
graph["66"].setdefault("inputs", {})["text"] = front_prompt
graph["70"].setdefault("inputs", {})["text"] = scene_prompt
# Scene (71) and prop (24) negatives: see _LOWER_PANEL_NEGATIVE for why this alone is not enough.
for negative_node in ("71", "24"):
if negative_node in graph:
inputs = graph[negative_node].setdefault("inputs", {})
inputs["text"] = ", ".join(filter(None, (str(inputs.get("text") or "").strip(" ,"), _LOWER_PANEL_NEGATIVE)))
# Disconnect the failed image-edit camera branches. Structural camera and
# action changes are handled by native T2I branches below.
for old_output in ("front_save", "side_save", "back_save", "scene_save"):

View File

@ -330,5 +330,126 @@ class RenderDefectTests(unittest.TestCase):
self.assertNotIn("hilt", self.prompts({"clothing": "Schlichtes Leinenkleid."})[4])
class LowerPanelPromptTests(unittest.TestCase):
"""The story moment and the signature items under the turnaround.
Marcian (a knight in plate and a red cloak, Das Jahr des Greifen) got a modern man with
headphones and a camera in a garden, captioned with pseudo-text, and a stock crossbow on a
white sweep: the scene prompt was ~5 400 characters with the period rules at the end, opened
with "ears ... clearly visible" and asked for studio photography by a photographer.
"""
BOOK = "Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm"
MARCIAN = {
"gender": "male", "race_species": "Mensch", "title": "Ritter",
"profession": "Inquisitor, Agent der KGIA",
"visual_appearance": "Ein markanter Mann mittleren Alters mit dunklem, leicht ergrautem Haar.",
"visual_costume": "Prächtige Plattenrüstung mit einem flammend roten Umhang.",
"visual_scene": "Marcian gibt den Verteidigern von Greifenfurt einen präzisen Befehl.",
"visual_props": ["Armbrust"],
}
def prompts(self, style="cinematic", look="", book_profile=None):
from routes.conversation import _build_compact_character_render_prompts
return _build_compact_character_render_prompts("Marcian", self.BOOK, self.MARCIAN, book_profile or {},
style, "", "", look)
def test_the_scene_is_the_same_character_in_the_books_world(self):
scene = self.prompts(look="hair: shoulder-length dark brown hair")[4]
self.assertIn("medieval high-fantasy world of The Dark Eye", scene)
self.assertIn("900-1300 AD", scene)
self.assertIn("one adult man named Marcian", scene)
self.assertIn("exactly the character of the turnaround", scene)
self.assertIn("dunklem, leicht ergrautem Haar", scene)
self.assertIn("Plattenrüstung mit einem flammend roten Umhang", scene)
self.assertIn("exactly as in the portrait: hair: shoulder-length dark brown hair", scene)
self.assertIn("Greifenfurt", scene)
def test_the_scene_opens_with_the_world_and_stays_short(self):
scene = self.prompts()[4]
self.assertTrue(scene.startswith("A cinematic film still from a medieval high-fantasy epic"))
self.assertLess(scene.index("The Dark Eye"), scene.index("Marcian"))
self.assertLess(len(scene), 3200)
long_sheet = dict(self.MARCIAN, visual_scene="Er spricht sehr lange. " * 80)
from routes.conversation import _build_compact_character_render_prompts
self.assertLess(len(_build_compact_character_render_prompts(
"Marcian", self.BOOK, long_sheet, {}, "cinematic", "", "")[4]), 3200)
def test_the_lower_panels_state_a_hand_made_unmarked_world(self):
_p, _t, _s, _b, scene, props = self.prompts()
for prompt in [scene] + props:
self.assertIn("hand-made and pre-industrial", prompt)
self.assertIn("All surfaces are plain and unmarked", prompt)
self.assertIn("one single seamless picture", prompt)
def test_the_lower_panels_carry_no_modern_or_text_cue(self):
"""The encoder draws what it reads, even after "no": nothing to avoid is named positively."""
_p, _t, _s, _b, scene, props = self.prompts()
for prompt in [scene] + props:
lower = prompt.lower()
for cue in ("photograph", "studio", "actor", "camera", "headphone", "label", "caption",
"lettered", "overlay", "output", "museum", "catalog", "head continuity",
"evidence", "lock:", "der sturm"):
self.assertNotIn(cue, lower, f"{cue!r} in {prompt[:80]}…")
def test_the_concept_style_is_painted(self):
_p, _t, _s, _b, scene, props = self.prompts(style="concept")
for prompt in [scene] + props:
self.assertTrue(prompt.startswith("A semi-realistic hand-painted medieval high-fantasy illustration"))
def test_the_book_profile_setting_reaches_the_world(self):
scene = self.prompts(book_profile={"setting": "Greifenfurt, eine belagerte Stadt", "era": "Frührenaissance"})[4]
self.assertIn("Greifenfurt, eine belagerte Stadt", scene)
self.assertNotIn("renaissance", scene.lower())
def test_the_prop_is_the_characters_own_item_in_context(self):
props = self.prompts()[5]
self.assertEqual(len(props), 1)
prop = props[0]
self.assertIn("one complete medieval crossbow", prop)
self.assertIn("personal, well-used possession of Marcian", prop)
self.assertIn("Plattenrüstung mit einem flammend roten Umhang", prop)
self.assertIn("rough oak table in a medieval room", prop)
self.assertIn("The Dark Eye", prop)
def test_a_still_life_item_rests_on_the_table_not_the_sweep(self):
from routes.conversation import _build_compact_character_render_prompts
sheet = dict(self.MARCIAN, visual_props=["Amulett"])
prop = _build_compact_character_render_prompts("Marcian", self.BOOK, sheet, {}, "cinematic", "", "")[5][0]
self.assertIn("coiled flat on the table", prop)
self.assertNotIn("sweep", prop)
def test_the_negative_prompt_names_text_and_modern_objects(self):
from routes.conversation import _LOWER_PANEL_NEGATIVE
for word in ("text", "caption", "label", "watermark", "logo", "headphones", "camera",
"modern clothing", "modern building", "suburban garden"):
self.assertIn(word, _LOWER_PANEL_NEGATIVE)
def test_the_scene_and_prop_negatives_reach_the_workflow(self):
import requests
from fastapi import HTTPException
import routes.conversation as conversation
sent = {}
def fake_post(url, json=None, **_kw):
sent["graph"] = json["prompt"]
raise requests.exceptions.ConnectionError("stop here")
with mock.patch.object(conversation, "_comfyui_upload_data_uri", return_value="x.png"), \
mock.patch.object(conversation.requests, "post", side_effect=fake_post):
with self.assertRaises(HTTPException):
conversation._comfyui_run_consistent_images(
"http://comfy", conversation._bundled_consistent_comfyui_workflow(), "data:image/png;base64,",
"p", "front", "side", "back", "scene", ["prop"], face_transfer=False)
graph = sent["graph"]
for node in ("71", "24"):
text = graph[node]["inputs"]["text"]
self.assertTrue(text.startswith("low quality")) # the workflow's own list is kept
self.assertIn("headphones", text)
self.assertIn("caption", text)
self.assertEqual(graph["70"]["inputs"]["text"], "scene")
self.assertEqual(graph["23"]["inputs"]["text"], "prop")
if __name__ == "__main__":
unittest.main()