Some checks are pending
Test and SonarQube / quality (push) Waiting to run
Portrait backdrop measured 0/4 -> 4/4 plain on the failing character, no change on the one that already passed. After the portrait passes, the vision model describes hair, facial hair and eyes, and the turnaround and story prompts are rebuilt with it -- a character with no hair in his sheet got a different haircut on the sheet and failed the same-person check 3/3. Costume cut and colours restored for armed characters. v1.41.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
335 lines
17 KiB
Python
335 lines
17 KiB
Python
"""Character visual identity: fingerprint parity with the browser, and the form-aware pair review."""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import tempfile
|
||
import unittest
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
|
||
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-charid-test-config-"))
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
import routes.characters as characters
|
||
from routes.conversation import (
|
||
_CHARACTER_IDENTITY_FIELDS, _CHARACTER_IDENTITY_OVERRIDE_FIELDS, _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
_character_identity_fingerprint,
|
||
)
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
JS = (ROOT / "static/js/library-characters.js").read_text(encoding="utf-8")
|
||
|
||
|
||
def _js_list(name: str) -> list[str]:
|
||
m = re.search(rf"const {name} = \[(.*?)\];", JS, re.S)
|
||
return re.findall(r"'([^']+)'", m.group(1))
|
||
|
||
|
||
def _js_fingerprint(sheets: list[dict]) -> list[str]:
|
||
"""Run the browser's own fingerprint code in Node."""
|
||
def grab(pattern: str) -> str:
|
||
return re.search(pattern, JS, re.S).group(0)
|
||
src = "\n".join([
|
||
grab(r"function _libStr\(v\) \{.*?\n\}"),
|
||
grab(r"const LIB_IDENTITY_FIELDS = \[.*?\];"),
|
||
grab(r"const LIB_IDENTITY_OVERRIDE_FIELDS = \[.*?\];"),
|
||
grab(r"function _libIdentityFingerprint\(sheet\) \{.*?\n\}"),
|
||
f"console.log(JSON.stringify({json.dumps(sheets)}.map(_libIdentityFingerprint)));",
|
||
])
|
||
out = subprocess.run(["node", "-e", src], capture_output=True, text=True, check=True, timeout=30)
|
||
return json.loads(out.stdout)
|
||
|
||
|
||
def _legacy_fingerprint(sheet: dict) -> str:
|
||
"""The algorithm before override fields existed, for backward-compat checks."""
|
||
raw = "|".join(f"{k}:{sheet.get(k) if sheet.get(k) is not None else ''}" for k in _CHARACTER_IDENTITY_FIELDS)
|
||
value = 0
|
||
for ch in raw:
|
||
value = ((value << 5) - value + ord(ch)) & 0xFFFFFFFF
|
||
return format(value, "x")
|
||
|
||
|
||
class FingerprintTests(unittest.TestCase):
|
||
def test_browser_and_server_use_the_same_field_lists(self):
|
||
self.assertEqual(_js_list("LIB_IDENTITY_FIELDS"), list(_CHARACTER_IDENTITY_FIELDS))
|
||
self.assertEqual(_js_list("LIB_IDENTITY_OVERRIDE_FIELDS"), list(_CHARACTER_IDENTITY_OVERRIDE_FIELDS))
|
||
|
||
def test_browser_and_server_compute_identical_fingerprints(self):
|
||
sheets = [
|
||
{},
|
||
{"physical": "tall, white hair", "clothing": "grey coat", "gender": "female", "age_estimate": "40"},
|
||
{"physical": "x", "visual_appearance": "copper hair, green eyes", "visual_costume": "blue wool coat"},
|
||
{"physical": "ä ö ü ß – »Zitat« 😀", "marks": ["scar", "tattoo"], "visual_species": "Elf"},
|
||
]
|
||
self.assertEqual(_js_fingerprint(sheets), [_character_identity_fingerprint(s) for s in sheets])
|
||
|
||
def test_a_pair_made_before_overrides_existed_keeps_a_valid_fingerprint(self):
|
||
sheet = {"physical": "tall, white hair", "clothing": "grey coat", "gender": "female"}
|
||
self.assertEqual(_character_identity_fingerprint(sheet), _legacy_fingerprint(sheet))
|
||
|
||
def test_editing_an_override_marks_the_pair_stale(self):
|
||
base = {"physical": "tall", "clothing": "coat"}
|
||
before = _character_identity_fingerprint(base)
|
||
for key in _CHARACTER_IDENTITY_OVERRIDE_FIELDS:
|
||
with self.subTest(key=key):
|
||
self.assertNotEqual(_character_identity_fingerprint({**base, key: "changed"}), before)
|
||
|
||
def test_active_form_changes_the_fingerprint_but_not_the_base(self):
|
||
sheet = {"physical": "human", "forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}]}
|
||
base = _character_identity_fingerprint({**sheet, "active_form_id": ""})
|
||
self.assertNotEqual(_character_identity_fingerprint({**sheet, "active_form_id": "wolf"}), base)
|
||
|
||
|
||
_PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||
|
||
|
||
def _img(n: int) -> str:
|
||
"""A distinct valid data URI per n (digests differ)."""
|
||
return "data:image/png;base64," + base64.b64encode(_PNG + bytes([n])).decode()
|
||
|
||
|
||
def _pair_meta(sheet: dict) -> dict:
|
||
return {
|
||
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
"consistent_identity_fingerprint": _character_identity_fingerprint(sheet),
|
||
"identity_consistent": True,
|
||
}
|
||
|
||
|
||
class VisualPairReviewTests(unittest.TestCase):
|
||
def setUp(self):
|
||
app = FastAPI()
|
||
app.include_router(characters.router)
|
||
self.client = TestClient(app)
|
||
|
||
def review(self, records):
|
||
with mock.patch.object(characters, "char_get_all", return_value=records):
|
||
r = self.client.get("/api/characters/visual-pair-review")
|
||
self.assertEqual(r.status_code, 200)
|
||
return r.json()
|
||
|
||
def test_a_healthy_base_pair_and_a_healthy_form_pair_pass(self):
|
||
base = {"physical": "human", "clothing": "coat"}
|
||
form = {"id": "wolf", "name": "Wolf", "physical": "grey fur"}
|
||
form_fp = _character_identity_fingerprint({**base, "forms": [form], "active_form_id": "wolf"})
|
||
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
|
||
"forms": [{**form, "image": _img(3), "concept_art_image": _img(4),
|
||
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
"consistent_identity_fingerprint": form_fp, "identity_consistent": True}],
|
||
"active_form_id": "wolf"}}
|
||
self.assertEqual(self.review([rec])["issues"], [])
|
||
|
||
def test_a_stale_form_pair_is_reported_under_the_form_name(self):
|
||
base = {"physical": "human"}
|
||
form = {"id": "wolf", "name": "Wolf", "physical": "grey fur", "image": _img(3), "concept_art_image": _img(4),
|
||
"consistent_prompt_version": _CHARACTER_VISUAL_PROMPT_VERSION,
|
||
"consistent_identity_fingerprint": "deadbeef", "identity_consistent": True}
|
||
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2), "forms": [form]}}
|
||
issues = self.review([rec])["issues"]
|
||
self.assertEqual(len(issues), 1)
|
||
self.assertEqual(issues[0]["form_id"], "wolf")
|
||
self.assertEqual(issues[0]["form_name"], "Wolf")
|
||
self.assertIn("appearance_changed_since_generation", issues[0]["issues"])
|
||
|
||
def test_an_active_form_no_longer_makes_the_base_pair_look_unfingerprinted(self):
|
||
base = {"physical": "human"}
|
||
rec = {"id": "c1", "name": "Zerwas", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
|
||
"forms": [{"id": "wolf", "name": "Wolf", "physical": "grey fur"}], "active_form_id": "wolf"}}
|
||
self.assertEqual(self.review([rec])["issues"], [])
|
||
|
||
def test_a_form_without_images_is_not_reviewed(self):
|
||
base = {"physical": "human"}
|
||
rec = {"id": "c1", "name": "A", "image": _img(1),
|
||
"sheet": {**base, **_pair_meta(base), "concept_art_image": _img(2),
|
||
"forms": [{"id": "f", "name": "F", "physical": "x"}]}}
|
||
self.assertEqual(self.review([rec])["issues"], [])
|
||
|
||
def test_the_same_character_reusing_its_picture_in_another_book_is_not_flagged(self):
|
||
s = {"physical": "a"}
|
||
recs = [{"id": f"b{i}::darrag", "book": f"Book {i}", "name": "Darrag", "image": _img(9),
|
||
"sheet": {**s, **_pair_meta(s), "concept_art_image": _img(20 + i)}} for i in range(2)]
|
||
recs[1]["name"] = " darrag "
|
||
self.assertEqual(self.review(recs)["issues"], [])
|
||
|
||
def test_the_same_asset_on_two_characters_is_flagged(self):
|
||
s = {"physical": "a"}
|
||
recs = [{"id": f"c{i}", "name": f"N{i}", "image": _img(9),
|
||
"sheet": {**s, **_pair_meta(s), "concept_art_image": _img(10 + i)}} for i in range(2)]
|
||
issues = self.review(recs)["issues"]
|
||
self.assertEqual({i["id"] for i in issues}, {"c0", "c1"})
|
||
self.assertTrue(all("asset_reused_across_characters" in i["issues"] for i in issues))
|
||
|
||
|
||
class PromptHygieneTests(unittest.TestCase):
|
||
"""Facts that reach an image prompt must be period-safe, uninjured, and not identical between characters."""
|
||
|
||
def facts(self, **sheet):
|
||
from routes.conversation import _character_visual_facts
|
||
return _character_visual_facts("Test", sheet)
|
||
|
||
def test_modern_wardrobe_words_become_period_garments(self):
|
||
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("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.")
|
||
self.assertIn("Grüne Augen", text)
|
||
self.assertIn("schwarzes Haar", text)
|
||
self.assertNotIn("blut", text.lower())
|
||
self.assertNotIn("verbunden", text.lower())
|
||
|
||
def test_a_stored_renaissance_book_profile_is_reworded(self):
|
||
from routes.conversation import _book_visual_context
|
||
text = _book_visual_context("B", {"era": "Frühren aissance".replace(" ", ""), "setting": "early-Renaissance fantasy world, approximately 1420–1500"})
|
||
self.assertNotIn("enaissance", text)
|
||
self.assertNotIn("1420", text)
|
||
self.assertIn("medieval", text)
|
||
|
||
def test_a_tricorn_hat_becomes_a_helm(self):
|
||
self.assertNotIn("dreispitz", self.facts(clothing="Rüstung mit Dreispitz").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)
|
||
self.assertNotIn("blutig", text.lower())
|
||
|
||
def test_soldiers_get_different_cuts_and_colours(self):
|
||
from routes.conversation import _character_design_variation
|
||
sheet = {"clothing": "Rüstung und Schwert"}
|
||
designs = {_character_design_variation(n, sheet) for n in ("Alrik", "Baron", "Coran", "Darrag", "Elda", "Fenn", "Garbag", "Himgi")}
|
||
self.assertGreaterEqual(len(designs), 5)
|
||
self.assertEqual(_character_design_variation("Alrik", sheet), _character_design_variation("Alrik", sheet))
|
||
|
||
def test_colours_the_sheet_already_names_are_kept(self):
|
||
from routes.conversation import _character_design_variation
|
||
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_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 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_the_portrait_look_is_carried_into_the_turnaround_and_the_scene(self):
|
||
from routes.conversation import _build_compact_character_render_prompts
|
||
out = _build_compact_character_render_prompts("Darrag", "Buch", {"physical": "Kräftig."}, {}, "cinematic", "", "",
|
||
"hair: shoulder-length brown hair")
|
||
self.assertIn("exactly as in the portrait: hair: shoulder-length brown hair", out[1])
|
||
self.assertIn("exactly as in the portrait: hair: shoulder-length brown hair", out[4])
|
||
self.assertLess(out[1].index("head to the soles"), out[1].index("exactly as in the portrait"))
|
||
|
||
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)
|
||
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)
|
||
|
||
|
||
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_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_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):
|
||
scene = self.prompts(self.KARYLA, "Karyla")[4]
|
||
self.assertIn("gripped low by its hilt", scene)
|
||
self.assertIn("both hands empty", self.prompts(self.KARYLA, "Karyla")[0])
|
||
|
||
def test_an_unarmed_character_gets_no_weapon_wording(self):
|
||
self.assertNotIn("hilt", self.prompts({"clothing": "Schlichtes Leinenkleid."})[4])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|