fix(characters): elf ears on humans, gender drift in the scene panel, bleeding scars, firearm sword grip
Each fix pins a defect seen in a real render. The species test read the raw sheet field, so a hedged "Mensch (oder Halbelfe...)" matched the elf pattern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
52e5c14f18
commit
16df95ec97
@ -16,6 +16,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
- **Book setting reworded to Dark Eye high-fantasy medieval** (was "early Renaissance 1420-1500", which produced musketeer/wartime looks), soot/dirt removed like injuries, and prop tiles are object-only (they used to show a person). Existing image pairs are unchanged until regenerated.
|
- **Book setting reworded to Dark Eye high-fantasy medieval** (was "early Renaissance 1420-1500", which produced musketeer/wartime looks), 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
|
### Fixed
|
||||||
|
- **Non-elves no longer get pointed ears.** The species test read the raw sheet field, so a hedged value such as "Mensch (oder Halbelfe, je nach Kontext, hier nicht spezifiziert)" matched "Halbelfe" and gave a human character elf ears — and that whole sentence went into the prompt as her species. Speculative sentences are now dropped from the species before either use, with more hedge wording recognised ("nicht spezifiziert", "je nach Kontext", "not specified", "depending on").
|
||||||
|
- **The story-moment panel keeps the character's gender, age and species.** It restates them ("one adult woman named Karyla") instead of relying on the subject clause, after a female character rendered as a man among male extras.
|
||||||
|
- **An established scar renders as healed** — an old, pale, closed line — rather than as a fresh red wound. Scars are permanent and deliberately survive the injury filter, but the model drew them bleeding.
|
||||||
|
- **Weapons are held as weapons.** A sword was rendered shouldered and aimed forward like a firearm; period arms are now described as gripped low by the hilt, resting on the shoulder, hanging at the side, or sheathed.
|
||||||
- **Reverted the per-view turnaround renders.** Drawing front/side/rear separately with the edit model produced three near-identical front views, mixed styles between panels, cut-off bodies and a wireframe pose guide rendered into the costume. The turnaround is generated as a single three-panel image again, as it was before.
|
- **Reverted the per-view turnaround renders.** Drawing front/side/rear separately with the edit model produced three near-identical front views, mixed styles between panels, cut-off bodies and a wireframe pose guide rendered into the costume. The turnaround is generated as a single three-panel image again, as it was before.
|
||||||
- **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).
|
- **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).
|
||||||
|
|
||||||
|
|||||||
@ -1788,6 +1788,8 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str,
|
|||||||
r"\b(probably|possibly|perhaps|likely|assumed|inferred|uncertain|not described|"
|
r"\b(probably|possibly|perhaps|likely|assumed|inferred|uncertain|not described|"
|
||||||
r"wahrscheinlich|vermutlich|vielleicht|möglicherweise|angenommen|unsicher|"
|
r"wahrscheinlich|vermutlich|vielleicht|möglicherweise|angenommen|unsicher|"
|
||||||
r"nicht (?:näher |genau )?beschrieben|nicht detailliert beschrieben|"
|
r"nicht (?:näher |genau )?beschrieben|nicht detailliert beschrieben|"
|
||||||
|
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"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)\b",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
@ -1870,7 +1872,7 @@ def _character_ear_shape(sheet: dict) -> str:
|
|||||||
gets ordinary rounded human ears, since a missing/uncertain species is
|
gets ordinary rounded human ears, since a missing/uncertain species is
|
||||||
far more likely human than elf in this cast.
|
far more likely human than elf in this cast.
|
||||||
"""
|
"""
|
||||||
species_text = _visual_prompt_value(sheet.get("visual_species") or sheet.get("race_species"))
|
species_text = _established_visual_value(sheet, "visual_species", "race_species", 100)
|
||||||
if _ELF_SPECIES_RE.search(species_text) or _ORC_SPECIES_RE.search(species_text):
|
if _ELF_SPECIES_RE.search(species_text) or _ORC_SPECIES_RE.search(species_text):
|
||||||
return "pointed elf-shaped ears"
|
return "pointed elf-shaped ears"
|
||||||
return "ordinary rounded human ears"
|
return "ordinary rounded human ears"
|
||||||
@ -2383,6 +2385,40 @@ def _character_design_variation(name: str, sheet: dict) -> str:
|
|||||||
return f"{cut}, in {_GARMENT_COLOURS[digest[1] % len(_GARMENT_COLOURS)]}"
|
return f"{cut}, in {_GARMENT_COLOURS[digest[1] % len(_GARMENT_COLOURS)]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _character_identity_phrase(name: str, sheet: dict) -> str:
|
||||||
|
"""One short "who this is" clause (gender, age, species) for panels that drift."""
|
||||||
|
sheet = _character_form_sheet(sheet)
|
||||||
|
gender = _visual_prompt_value(sheet.get("gender")).strip().lower()
|
||||||
|
who = {"female": "one adult woman", "weiblich": "one adult woman",
|
||||||
|
"male": "one adult man", "männlich": "one adult man"}.get(gender, "one adult person")
|
||||||
|
category = _character_age_category(sheet)
|
||||||
|
if category == "child":
|
||||||
|
who = who.replace("adult woman", "girl").replace("adult man", "boy").replace("adult person", "child")
|
||||||
|
elif category == "teen":
|
||||||
|
who = who.replace("adult ", "teenage ")
|
||||||
|
species = _established_visual_value(sheet, "visual_species", "race_species", 60)
|
||||||
|
parts = [f"{who} named {name}" if name else who]
|
||||||
|
if species:
|
||||||
|
parts.append(species)
|
||||||
|
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,
|
def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||||
book_profile: dict, visual_style: str,
|
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]]:
|
||||||
@ -2498,8 +2534,12 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
# permanently established mark (an old scar, tattoo, missing finger) is
|
# permanently established mark (an old scar, tattoo, missing finger) is
|
||||||
# still covered by "appearance" above and isn't contradicted by this.
|
# still covered by "appearance" above and isn't contradicted by this.
|
||||||
baseline_lock = (
|
baseline_lock = (
|
||||||
"PORTRAIT BASELINE: a calm, composed, healthy resting expression and unmarked skin, showing the "
|
"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, unmarked skin and clean, well-kept garments. "
|
"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
|
# baseline_lock covers the character's own skin/expression; the model's
|
||||||
# "gritty realistic fantasy" style bias separately adds blood staining to
|
# "gritty realistic fantasy" style bias separately adds blood staining to
|
||||||
@ -2508,6 +2548,12 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
# any wound (the strip_injury filter above removed it), yet the render
|
# any wound (the strip_injury filter above removed it), yet the render
|
||||||
# still showed a bloodstain across her chest armor. Same positive-only
|
# still showed a bloodstain across her chest armor. Same positive-only
|
||||||
# approach, aimed at the armor's surface condition instead of the skin.
|
# approach, aimed at the armor's surface condition instead of the skin.
|
||||||
|
# A sword was rendered shouldered and aimed forward like a firearm (confirmed live on
|
||||||
|
# Karyla's scene panel). Positive-only wording for how period arms actually sit in the hand.
|
||||||
|
weapon_handling_lock = (
|
||||||
|
"Any blade or hafted weapon is held as it was truly used: gripped low by its hilt or haft with a relaxed "
|
||||||
|
"wrist, resting against the shoulder, hanging at the side, or sheathed at the belt. "
|
||||||
|
) if _character_has_established_combat_gear(sheet) else ""
|
||||||
gear_condition_lock = (
|
gear_condition_lock = (
|
||||||
"Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from "
|
"Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from "
|
||||||
"daily use. "
|
"daily use. "
|
||||||
@ -2519,6 +2565,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
f"{authority_lock}"
|
f"{authority_lock}"
|
||||||
f"{baseline_lock}"
|
f"{baseline_lock}"
|
||||||
f"{gear_condition_lock}"
|
f"{gear_condition_lock}"
|
||||||
|
f"{weapon_handling_lock}"
|
||||||
f"{clothing_lock}"
|
f"{clothing_lock}"
|
||||||
f"{no_headwear}"
|
f"{no_headwear}"
|
||||||
)
|
)
|
||||||
@ -2575,6 +2622,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
|||||||
+ "CREATE ONE HORIZONTAL PHOTOREALISTIC SOURCE-GROUNDED STORY MOMENT WITH NATURAL ACTION. "
|
+ "CREATE ONE HORIZONTAL PHOTOREALISTIC SOURCE-GROUNDED STORY MOMENT WITH NATURAL ACTION. "
|
||||||
f"EXACT CHARACTER: {sheet_subject}. "
|
f"EXACT CHARACTER: {sheet_subject}. "
|
||||||
f"BOOK-SOURCE EVIDENCE: {scene_evidence}. "
|
f"BOOK-SOURCE EVIDENCE: {scene_evidence}. "
|
||||||
|
f"FOCAL PERSON: {_character_identity_phrase(name, sheet)}, exactly as described above, the single clear subject in the foreground. "
|
||||||
"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. "
|
"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."
|
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."
|
||||||
)
|
)
|
||||||
|
|||||||
@ -249,5 +249,49 @@ class TurnaroundIdentityTests(unittest.TestCase):
|
|||||||
self.assertIn("The Dark Eye", portrait)
|
self.assertIn("The Dark Eye", portrait)
|
||||||
|
|
||||||
|
|
||||||
|
class RenderDefectTests(unittest.TestCase):
|
||||||
|
"""Each test pins one defect seen in a real render."""
|
||||||
|
|
||||||
|
KARYLA = {"gender": "female",
|
||||||
|
"race_species": "Mensch (oder Halbelfe, je nach Kontext, hier nicht spezifiziert)",
|
||||||
|
"physical": "Kurzes blondes Haar; drahtige Statur einer Kriegerin.",
|
||||||
|
"clothing": "Robuste, abgenutzte Lederrüstung mit Waffengürtel und Schwert."}
|
||||||
|
|
||||||
|
def prompts(self, sheet, name="Test"):
|
||||||
|
from routes.conversation import _build_compact_character_render_prompts
|
||||||
|
return _build_compact_character_render_prompts(name, "Buch", sheet, {}, "cinematic", "", "")
|
||||||
|
|
||||||
|
def test_a_hedged_species_does_not_give_a_human_elf_ears(self):
|
||||||
|
from routes.conversation import _character_ear_shape, _character_visual_facts
|
||||||
|
self.assertEqual(_character_ear_shape(self.KARYLA), "ordinary rounded human ears")
|
||||||
|
self.assertNotIn("Halbelfe", _character_visual_facts("Karyla", self.KARYLA))
|
||||||
|
|
||||||
|
def test_a_real_elf_keeps_pointed_ears(self):
|
||||||
|
from routes.conversation import _character_ear_shape
|
||||||
|
self.assertEqual(_character_ear_shape({"race_species": "Elfe"}), "pointed elf-shaped ears")
|
||||||
|
|
||||||
|
def test_the_scene_panel_restates_gender_and_species(self):
|
||||||
|
scene = self.prompts(self.KARYLA, "Karyla")[4]
|
||||||
|
self.assertIn("one adult woman named Karyla", scene)
|
||||||
|
|
||||||
|
def test_a_child_is_not_described_as_an_adult_in_the_scene(self):
|
||||||
|
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_a_character_without_a_scar_gets_no_scar_wording(self):
|
||||||
|
self.assertNotIn("scar", self.prompts({"physical": "Blondes Haar."})[0].lower())
|
||||||
|
|
||||||
|
def test_an_armed_character_is_told_how_to_hold_the_weapon(self):
|
||||||
|
portrait = self.prompts(self.KARYLA, "Karyla")[0]
|
||||||
|
self.assertIn("gripped low by its hilt", portrait)
|
||||||
|
|
||||||
|
def test_an_unarmed_character_gets_no_weapon_wording(self):
|
||||||
|
self.assertNotIn("hilt", self.prompts({"clothing": "Schlichtes Leinenkleid."})[0])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user