fix(characters): persist base-appearance image pairs, deterministic prompts, form-aware review; add forms panel

- fix: base-appearance matched pair lost its concept image + metadata on reload; visual save
  wrote base concept art to the record root; one save path for base and forms now
- fix: extraction no longer lets the LLM author image_prompt (source of avatar/concept drift);
  avatar-only generation uses the deterministic prompt and flags an existing pair
- fix: prompt writes and Physical/Clothing edits go to the active form; review endpoint checks
  each form's own pair; fingerprint parity with the browser incl. visual_* overrides
- feat: appearance forms panel (add/rename/delete/show, per-form stale flag, generate one/all)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-19 15:27:21 +02:00
parent e7cb92c572
commit 5877aa2edc
12 changed files with 889 additions and 130 deletions

View File

@ -5,6 +5,22 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.33.0] — 2026-09-19
### Fixed
- **A matched image pair for a character's base appearance was never fully saved.** `_charAutoGenerateConsistentImages` persisted only the avatar; the concept/reference image and the pair metadata (prompt version, fingerprint, digests, `identity_consistent`) were set on the in-memory object and lost on reload, and the generic visual save wrote a base-appearance concept image to the record root instead of `sheet`. All visual results now go through one save path (`_libSaveCharacterVisual`) that writes the active form or the base correctly, digests included.
- **Avatar-only generation could use a stale LLM-written prompt** and left an existing pair looking consistent. It now uses the deterministic portrait prompt (a hand-edited prompt is honoured) and marks an existing pair for review, since a lone regeneration gives a new face (same for reference-only).
- **Sheet extraction no longer asks the LLM for `image_prompt`** (server and browser prompts) — the root cause of avatar/concept drift. The deterministic prompt is generated right after extraction.
- **Prompts written while an appearance form was active landed on the base sheet** (regenerate and manual save) while the page kept showing the form's stale value; appearance prompts now go to the active form, character-wide prompts to the base. Inline Physical/Clothing edits likewise change only the active form.
- **Visual-pair review ignored forms**: a character with an active form always looked unfingerprinted. Each form with images is now checked against its own metadata and fingerprint and reported under the form name.
- **Fingerprint parity**: server and browser hashed list-valued fields and non-BMP characters differently, so such characters always looked stale; the fingerprint now also covers the `visual_*` overrides that change the prompt (appended only when set, so existing pairs stay valid). A test runs the browser code in Node and compares.
### Added
- **Appearance forms panel** in the character detail page: add (starts as a copy of the base look), rename, delete (confirmed), show, per-form stale flag, generate matched images for one form or every form (prompts rebuilt per form first, active appearance restored), max 8 (server-enforced).
- `architecture/character-visual-identity.md`; tests in `tests/test_character_identity.py` and `tests/characters.spec.js` (the persistence tests fail on the previous code).
---
## [1.32.0] — 2026-09-19
### Added

58
TODO.md
View File

@ -348,8 +348,7 @@ codebase; benefit is relative user/reliability impact.
- [x] **P1 / L — Very high benefit — Unify visual identity.** Portrait and concept-sheet generation now use one
canonical identity description, stored prompt version, identity fingerprint, asset digests, and stale-asset review
path; shared-anchor and metadata persistence are covered by automated tests.
- [ ] **P2 / M–L — Medium–high benefit — Enrich character-form UX.** Add richer inline form editing and per-form
bulk-generation controls while preserving one character ID and one voice.
- [x] **P2 / M–L — Medium–high benefit — Enrich character-form UX.** Done 2026-09-19: forms panel (add/rename/delete/show, per-form stale flag, per-form and all-forms image generation), Physical/Clothing edits scoped to the active form. See `architecture/character-visual-identity.md`.
## Magazine reader roadmap
@ -564,48 +563,25 @@ LLM writes it as one of the 53 character-sheet fields, while
facts the concept sheet uses. The deterministic one keeps both images consistent; the
LLM-written one omits identity anchors (age, hair, build) so the model invents them.
### Status — partially working
### Status — resolved 2026-09-19
**Working:**
Verified against the code and covered by tests (see `architecture/character-visual-identity.md`):
- Character records, structured profile fields and portrait images are saved and loaded
independently without losing the stored image data.
- The concept-sheet path already builds from structured character facts and is the more
consistent identity source.
- Portrait generation now uses baseline appearance guidance, explicit head-and-shoulders
framing, neutral backgrounds and book-period grounding.
- Existing scene/style/background contamination in saved portrait prompts is filtered in
the portrait path.
- [x] One canonical identity description feeds both the avatar and the concept sheet
(`_character_visual_facts`; portrait and reference prompts are built deterministically, the
active appearance form is overlaid first).
- [x] `image_prompt` is no longer authored by the LLM during sheet extraction (server and browser
prompts); the deterministic prompt is generated right after extraction, before avatar-only
generation and before per-form generation. Hand-edited prompts are never overwritten.
- [x] Prompt version, identity fingerprint and asset digests are stored with every generated pair
— for the base appearance too (they used to be lost on reload) and per form.
- [x] Review indicator: per-character banner and `GET /api/characters/visual-pair-review`, now
form-aware; a pair is also flagged when one image was regenerated alone.
- [x] Tests: server/browser fingerprint parity, prompt anchors shared by both builders, review
endpoint (base + forms), and Playwright persistence tests that fail on the previous code.
**Not working reliably:**
- The profile/avatar portrait and the character concept sheet can still depict different
hair colour, age, facial features, build or overall identity.
- Character-sheet extraction can write an LLM-authored `image_prompt` that lacks the
identity anchors used by the deterministic concept-sheet prompt.
- Regenerating a portrait does not guarantee that it uses exactly the same identity
description as the concept sheet.
- Evidence-window profile regeneration can produce behaviour or actions in `physical`
instead of stable appearance details.
**Needs to be fixed:**
- Make one canonical identity description the input for both avatar portraits and concept
sheets: age range, hair, eyes, face, build, skin tone, distinctive marks and clothing
era.
- Remove `image_prompt` from the general sheet-extraction output, or overwrite it only
after extraction using the canonical structured fields.
- Store the resolved identity prompt/version with each generated image so old images can
be identified as stale and regenerated deterministically.
- Add a consistency check or review indicator when the avatar and concept-sheet images
were generated from different prompt versions.
- Add an automated test covering extraction → prompt generation → avatar and concept
sheet generation, asserting that both image requests contain the same identity anchors.
The app's own flow already calls `/api/character-generate-prompts` after extraction, so
**this is mainly a process trap**: any script that regenerates sheets must call that
endpoint too, or it leaves an LLM-authored prompt in place. Consider removing
`image_prompt` from the sheet-extraction field list entirely so there is only one source.
Still true, and deliberately not "fixed": the resolved prompt *text* is not stored per image
(only its version and the fingerprint of the facts it was built from).
### Known regression from the 2026-09-02 batch
Regenerating the top-10 profiles from evidence windows (passages around each character's

View File

@ -1 +1 @@
1.32.0
1.33.0

View File

@ -0,0 +1,55 @@
# SOP — Character visual identity and appearance forms
Status: implemented. Covers how a character's avatar and concept (reference) sheet stay the same
person, and how one character holds several appearances ("forms") without splitting into several
characters or voices.
## Single source of truth
* Appearance facts come from the sheet's `physical`, `clothing`, `age_estimate`, `race_species`,
`gender`, `title`, `profession`, `archetype` (and the user overrides `visual_appearance`,
`visual_species`, `visual_costume`). Both prompts are built by
`_character_visual_facts` in `routes/conversation.py`; the active form is overlaid first
(`_character_form_sheet`, which never lets a form change its own `id`/`name`).
* `image_prompt` (portrait) and `concept_art_prompt` (reference sheet) are **deterministic** —
built server-side from those facts with fixed camera/lighting/period text. Sheet extraction no
longer asks the LLM to write `image_prompt` (it drifted from the concept sheet); the prompt is
generated right after extraction by `csAutoGenerateExternalPrompts`, and again before any
avatar-only or per-form generation. A hand-edited prompt (`*_user_edited`) is never overwritten.
* Only the two prompts describe an appearance. `voice_design_prompt` and the SillyTavern prompt
belong to the character as a whole, so they always live on the base sheet.
## Stale detection
`_character_identity_fingerprint` (server) and `_libIdentityFingerprint` (browser) hash the same
field list; a test runs the browser code in Node and compares, including list values and non-BMP
characters. The three `visual_*` overrides are appended only when set, so pairs made before they
existed keep a valid fingerprint. A pair needs review when its prompt version is not current, its
fingerprint is missing or differs, or `identity_consistent` is false. Regenerating **only** the
avatar or **only** the reference image sets `identity_consistent = false` on an existing pair,
because a lone regeneration produces a new face.
## Where the data lives
| | base appearance | a form |
|---|---|---|
| profile image | record `image` | `form.image` |
| reference image, version, fingerprint, digests, `identity_consistent` | `sheet.*` | `form.*` |
| Physical / Clothing text | `sheet.physical/clothing` | `form.physical/clothing` |
All visual results are written through one function (`_libSaveCharacterVisual`), which re-reads the
stored record, writes to the active form or the base, and saves. (It used to write the base
reference image to the record root, and the consistent-pair generator kept it in memory only, so a
base pair lost its reference image and metadata on reload.)
`GET /api/characters/visual-pair-review` checks the base pair and every form that has images, each
against its own metadata and its own fingerprint, and reports form pairs under the form name.
## Forms (max 8 per character, enforced in `core/validation.py`)
Add (starts as a copy of the base Physical/Clothing; only non-empty keys are stored because an
empty key would blank the base value when overlaid), rename (unique, ≤160 chars), delete (confirm;
the character, its voice and the base look stay), show, and generate matched images for one form or
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.

View File

@ -109,6 +109,32 @@ def _visual_asset_digest(value: object) -> str:
return ""
def _pair_scopes(record: dict) -> list[tuple[dict, str, str, str, dict, str]]:
"""The visual pairs one character can hold, as
(fingerprint sheet, form id, form name, profile image, stored metadata, reference image).
The base appearance keeps its pair on the record/sheet; every appearance form that
has images keeps its own on the form entry, so each is judged against its own
identity fingerprint (base fields overlaid with that form) and its own metadata.
"""
sheet = record.get("sheet") if isinstance(record.get("sheet"), dict) else {}
scopes = [(
{**sheet, "active_form_id": ""}, "", "",
record.get("image") or sheet.get("image"), sheet, sheet.get("concept_art_image"),
)]
forms = sheet.get("forms") if isinstance(sheet.get("forms"), list) else []
for form in forms:
if not isinstance(form, dict) or not form.get("id"):
continue
if not (form.get("image") or form.get("concept_art_image")):
continue
scopes.append((
{**sheet, "active_form_id": str(form["id"])}, str(form["id"]), str(form.get("name") or form["id"]),
form.get("image"), form, form.get("concept_art_image"),
))
return scopes
@router.get("/api/characters/visual-pair-review")
async def characters_visual_pair_review():
"""Report stored profile/reference identity problems without rewriting records."""
@ -118,9 +144,7 @@ async def characters_visual_pair_review():
digest_owners: dict[str, list[str]] = {}
reports = []
for record in records:
sheet = record.get("sheet") if isinstance(record.get("sheet"), dict) else {}
profile = record.get("image") or sheet.get("image")
reference = sheet.get("concept_art_image")
for fp_sheet, form_id, form_name, profile, meta, reference in _pair_scopes(record):
profile_digest = _visual_asset_digest(profile)
reference_digest = _visual_asset_digest(reference)
for kind, digest in (("profile", profile_digest), ("reference", reference_digest)):
@ -132,29 +156,32 @@ async def characters_visual_pair_review():
if profile and reference:
if profile_digest and profile_digest == reference_digest:
issues.append("same_asset_for_profile_and_reference")
stored_profile_digest = str(sheet.get("profile_asset_digest") or "")
stored_reference_digest = str(sheet.get("reference_asset_digest") or "")
stored_profile_digest = str(meta.get("profile_asset_digest") or "")
stored_reference_digest = str(meta.get("reference_asset_digest") or "")
if stored_profile_digest and profile_digest and stored_profile_digest != profile_digest:
issues.append("profile_asset_changed_since_generation")
if stored_reference_digest and reference_digest and stored_reference_digest != reference_digest:
issues.append("reference_asset_changed_since_generation")
expected = _character_identity_fingerprint(sheet)
if sheet.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
expected = _character_identity_fingerprint(fp_sheet)
if meta.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION:
issues.append("legacy_or_missing_prompt_version")
if not sheet.get("consistent_identity_fingerprint"):
if not meta.get("consistent_identity_fingerprint"):
issues.append("missing_identity_fingerprint")
elif sheet.get("consistent_identity_fingerprint") != expected:
elif meta.get("consistent_identity_fingerprint") != expected:
issues.append("appearance_changed_since_generation")
if sheet.get("identity_consistent") is False:
if meta.get("identity_consistent") is False:
issues.append("marked_inconsistent")
if issues:
reports.append({"id": record.get("id"), "name": record.get("name", ""), "issues": issues})
report = {"id": record.get("id"), "name": record.get("name", ""), "issues": issues}
if form_id:
report["form_id"], report["form_name"] = form_id, form_name
reports.append(report)
for owners in digest_owners.values():
character_ids = {owner.rsplit(":", 1)[0] for owner in owners}
if len(character_ids) > 1:
for owner in owners:
char_id = owner.rsplit(":", 1)[0]
report = next((item for item in reports if item["id"] == char_id), None)
report = next((item for item in reports if item["id"] == char_id and "form_id" not in item), None)
if report is None:
report = {"id": char_id, "name": next((r.get("name", "") for r in records if r.get("id") == char_id), ""), "issues": []}
reports.append(report)

View File

@ -80,6 +80,11 @@ _CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
"gender", "marks", "scars", "nationality_background", "clothing", "era", "profession",
)
# User overrides that _character_visual_facts prefers over the extracted text. They are
# appended to the fingerprint only when set, so a pair made before they existed keeps a
# valid fingerprint while editing an override now marks the pair stale. The browser keeps
# the same two lists (static/js/library-characters.js); tests assert they match.
_CHARACTER_IDENTITY_OVERRIDE_FIELDS = ("visual_appearance", "visual_species", "visual_costume")
class _CastJobRequest:
@ -151,15 +156,30 @@ async def _run_cast_job(job_id: str, data: dict, headers: dict[str, str]) -> Non
_cast_job_set(job_id, stage="error", detail="Failed", done=True, error=message)
def _fingerprint_text(value) -> str:
"""Text exactly as the browser's _libStr renders it, so both sides hash the same string."""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (list, tuple)):
return ", ".join(_fingerprint_text(item) for item in value if item)
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
def _character_identity_fingerprint(sheet: dict) -> str:
sheet = _character_form_sheet(sheet)
values = {key: sheet.get(key, "") for key in _CHARACTER_IDENTITY_FIELDS}
raw = "|".join(f"{key}:{values[key] if values[key] is not None else ''}" for key in _CHARACTER_IDENTITY_FIELDS)
# Keep this compact hash algorithm identical to the browser-side stale
# check; the fingerprint is only a change detector, not a security token.
raw = "|".join(f"{key}:{_fingerprint_text(sheet.get(key))}" for key in _CHARACTER_IDENTITY_FIELDS)
for key in _CHARACTER_IDENTITY_OVERRIDE_FIELDS:
text = _fingerprint_text(sheet.get(key))
if text:
raw += f"|{key}:{text}"
# Same compact hash as the browser-side stale check (which walks UTF-16 code
# units); the fingerprint is only a change detector, not a security token.
value = 0
for char in raw:
value = ((value << 5) - value + ord(char)) & 0xFFFFFFFF
units = raw.encode("utf-16-le")
for i in range(0, len(units), 2):
value = ((value << 5) - value + int.from_bytes(units[i:i + 2], "little")) & 0xFFFFFFFF
return format(value, "x")
@ -1003,7 +1023,7 @@ def _charsheets_prepare(data: dict) -> dict:
f"capabilities, backstory, relationships, motivation, fears, mannerisms, communication_style, voice_pattern, "
f"secret, conflict_style, win_condition, archetype, aliases, first_name, last_name, title, age_estimate, "
f"race_species, languages, nationality_background, social_class, profession, reputation, religious_beliefs, notes.\n"
f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n"
f"voice_design_prompt should be a concise English tool prompt for generation tools.\n"
f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n"
f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n"
) if language else ""
@ -1086,7 +1106,6 @@ def _charsheets_prepare(data: dict) -> dict:
"- voice_design_prompt: concise English voice-design prompt, maximum 480 characters. Use only objective, audible traits; no lore-only labels or repeated synonyms. Include gender as a hard audible constraint, age impression, pitch, timbre, pace, accent/register, emotion and audiobook use. Translate species/role into audible cues (orc: chest-led/gravel-edged; vampire: cool/velvety/controlled; elf: airy/precise/adult; demon: dark/subtly otherworldly; warrior: disciplined command cadence; blacksmith: work-worn/practical; mage: measured/precise). Give one distinctive vocal fingerprint. "
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though the prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
+ "Do NOT mention plot spoilers.\n"
"- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n"
"- inventory: 1-3 defining items/props/clothing (array of short strings)\n"
"- secret: dark secret or fatal flaw\n"
"- conflict_style: fight, flight, or manipulate — how they act when cornered\n"
@ -1107,7 +1126,7 @@ def _charsheets_prepare(data: dict) -> dict:
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"",'
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"",'
'"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"",'
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
)

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.32.0">
<meta name="app-version" content="1.33.0">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.32.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.32.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.32.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.32.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.32.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.32.0">
<link rel="stylesheet" href="/static/style.css?v=1.33.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.33.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.33.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.33.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.33.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.33.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -46,8 +46,7 @@ For each character output these fields:
- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.
- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)
- voice_design_prompt: concise English voice-design prompt, maximum 480 characters. Use only objective, audible traits; no lore-only labels or repeated synonyms. Include gender as a hard audible constraint, age impression, pitch, timbre, pace, accent/register, emotion and audiobook use. Translate species/role into audible cues (orc: chest-led/gravel-edged; vampire: cool/velvety/controlled; elf: airy/precise/adult; demon: dark/subtly otherworldly; warrior: disciplined command cadence; blacksmith: work-worn/practical; mage: measured/precise). Give one distinctive vocal fingerprint. Do NOT mention plot spoilers.
- image_prompt: detailed English image-generation prompt describing WHO this character is: face, age impression, build, hair/eyes/skin if known, clothing, posture, props, visible symbols, and overall bearing. Mark inferred traits with '*'. Describe their normal appearance, never a wound or death scene. Describe ONLY the person: face, hair, build, clothing and what they carry. Do NOT describe a setting, location, landscape, room, weather, background, or an action they are performing (no "standing in a grassy landscape", "riding a horse", "the background shows a campfire"), and do NOT specify an art style, medium, rendering technique, framing or lighting (no "painterly", "illustration", "concept art", "dramatic lighting"). The application appends its own framing and rendering directive; scene or style written here fights it and turns the casting portrait into a full-body scene.
- forms: array of distinct transformations, disguises, or physical appearances for this same character (empty when none). Each form is {id, name, physical, clothing, image_prompt, concept_art_prompt, inventory}; keep one character and one voice, and only add a form when the text establishes a genuinely different appearance.
- forms: array of distinct transformations, disguises, or physical appearances for this same character (empty when none). Each form is {id, name, physical, clothing, inventory}; keep one character and one voice, and only add a form when the text establishes a genuinely different appearance.
- inventory: 1-3 defining items/props/clothing (array of short strings)
- secret: dark secret or fatal flaw
- conflict_style: fight, flight, or manipulate — how they act when cornered
@ -61,7 +60,7 @@ IDENTITY MERGING: A character may appear under multiple names in the book (first
STRICT PER-CHARACTER ATTRIBUTION: a passage often describes several characters together (a leader addressing followers, a group scene, a comparison between two people). Attribute every fact — title, role, profession, race/species, physical trait, relationship, backstory detail, or any other field — ONLY to the character it explicitly and specifically describes. NEVER copy a detail onto another character's sheet just because that character appears in the same scene, sentence, or paragraph, is compared or contrasted with them, or belongs to the same faction/group (e.g. only the actual leader of a group gets 'leader' in their profession/title — the group's other members do NOT). If it is ambiguous which character a detail belongs to, leave it out of every sheet rather than guessing.
Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.
Respond with STRICT JSON only:
{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_design_prompt":"","image_prompt":"","forms":[],"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_design_prompt":"","forms":[],"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
/no-think`;
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
@ -2347,7 +2346,7 @@ async function csAutoGenerateExternalPrompts(book, sheets) {
if (!Array.isArray(sheets) || !sheets.length) return;
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
let done = 0, failed = 0, repeatMsg = '', repeatCount = 0;
toast(`Generating SillyTavern + Concept Art prompts for ${sheets.length} character${sheets.length !== 1 ? 's' : ''}…`, 'info');
toast(`Generating portrait, SillyTavern and Concept Art prompts for ${sheets.length} character${sheets.length !== 1 ? 's' : ''}…`, 'info');
for (const s of sheets) {
if (!s || !s.name) continue;
try {
@ -2358,11 +2357,15 @@ async function csAutoGenerateExternalPrompts(book, sheets) {
body: JSON.stringify({
name: s.name, book, sheet: s, language,
llm_url: target.url, model: target.model,
fields: ['silly_tavern_prompt', 'concept_art_prompt'],
fields: ['image_prompt', 'silly_tavern_prompt', 'concept_art_prompt'],
}),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
// The portrait prompt is built server-side from the same structured facts as the
// concept sheet (no LLM), so the two images cannot drift apart; extraction no
// longer writes one. A hand-edited prompt is never overwritten.
if (d.image_prompt && s.image_prompt_user_edited !== true) s.image_prompt = d.image_prompt;
if (d.silly_tavern_prompt) s.silly_tavern_prompt = d.silly_tavern_prompt;
if (d.concept_art_prompt) s.concept_art_prompt = d.concept_art_prompt;
done++; repeatCount = 0;

View File

@ -30,7 +30,7 @@ async function _libReviewVisualPairs(button, container) {
} else {
panel.innerHTML = '<strong><span class="mdi mdi-alert-outline"></span> Review ' + payload.issues.length + ' character pair' + (payload.issues.length === 1 ? '' : 's') + ':</strong> '
+ payload.issues.map(function (item) {
return '<span class="lib-chars-review-item"><b>' + escHtml(item.name || item.id) + '</b> — ' + escHtml(item.issues.join(', ')) + '</span>';
return '<span class="lib-chars-review-item"><b>' + escHtml(item.name || item.id) + (item.form_name ? ' / ' + escHtml(item.form_name) : '') + '</b> — ' + escHtml(item.issues.join(', ')) + '</span>';
}).join('');
if (typeof toast === 'function') toast(payload.issues.length + ' visual pair(s) need review', 'warning');
}
@ -650,6 +650,16 @@ function _charAlignHtml(sh) {
+ '</div>';
}
// Must stay identical to routes/conversation.py _character_identity_fingerprint (a test enforces the field lists).
const LIB_IDENTITY_FIELDS = ['age', 'age_estimate', 'hair', 'eyes', 'physical', 'build', 'skin_tone', 'race_species', 'gender', 'marks', 'scars', 'nationality_background', 'clothing', 'era', 'profession'];
const LIB_IDENTITY_OVERRIDE_FIELDS = ['visual_appearance', 'visual_species', 'visual_costume'];
function _libIdentityFingerprint(sheet) {
let raw = LIB_IDENTITY_FIELDS.map(function (key) { return key + ':' + _libStr(sheet[key]); }).join('|');
LIB_IDENTITY_OVERRIDE_FIELDS.forEach(function (key) { if (sheet[key]) raw += '|' + key + ':' + _libStr(sheet[key]); });
let hash = 0;
for (let i = 0; i < raw.length; i++) hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
return (hash >>> 0).toString(16);
}
function _libStr(v) {
if (v == null) return '';
if (typeof v === 'string') return v;
@ -1232,6 +1242,121 @@ function _charLineCount(c) {
return Number(c.sheet && c.sheet.line_count) || 0;
}
// ── Appearance forms: manage, edit, generate ────────────────────────────────
// One character keeps one ID and one voice; forms only change how they look. The pure helpers
// below take a sheet and return a new one (or throw an Error with a message meant for the user).
const LIB_MAX_FORMS = 8; // matches core/validation.py
function _libFormId(name, forms) {
const base = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'form';
const taken = new Set((forms || []).map(function (f) { return String(f && f.id); }));
let id = base, n = 2;
while (taken.has(id)) id = base + '-' + n++;
return id;
}
function _libCleanFormName(name, forms, exceptId) {
const clean = String(name || '').replace(/\s+/g, ' ').trim();
if (!clean) throw new Error('Give the form a name.');
if (clean.length > 160) throw new Error('The form name is too long (max 160 characters).');
const dupe = (forms || []).some(function (f) {
return String(f && f.id) !== String(exceptId || '') && String(f && f.name || '').trim().toLowerCase() === clean.toLowerCase();
});
if (dupe) throw new Error('Another form already uses that name.');
return clean;
}
// A new form starts as a copy of the base look (physical + clothing) so it is immediately usable;
// only non-empty keys are stored, because an empty key would blank the base value when overlaid.
function _libAddForm(sheet, name) {
const forms = Array.isArray(sheet.forms) ? sheet.forms : [];
if (forms.length >= LIB_MAX_FORMS) throw new Error('A character can have at most ' + LIB_MAX_FORMS + ' forms.');
const clean = _libCleanFormName(name, forms);
const form = { id: _libFormId(clean, forms), name: clean };
if (_libStr(sheet.physical)) form.physical = _libStr(sheet.physical);
if (_libStr(sheet.clothing)) form.clothing = _libStr(sheet.clothing);
return Object.assign({}, sheet, { forms: forms.concat([form]), active_form_id: form.id });
}
function _libRenameForm(sheet, id, name) {
const forms = Array.isArray(sheet.forms) ? sheet.forms : [];
const clean = _libCleanFormName(name, forms, id);
return Object.assign({}, sheet, { forms: forms.map(function (f) { return String(f && f.id) === String(id) ? Object.assign({}, f, { name: clean }) : f; }) });
}
function _libDeleteForm(sheet, id) {
const forms = Array.isArray(sheet.forms) ? sheet.forms : [];
const next = Object.assign({}, sheet, { forms: forms.filter(function (f) { return String(f && f.id) !== String(id); }) });
if (String(sheet.active_form_id || '') === String(id)) next.active_form_id = '';
return next;
}
// 'none' = no images yet, 'ok', or 'review' (older prompt version, changed appearance, or a
// pair that was broken by regenerating one image alone) - same rule the server review applies.
function _libFormPairState(sheet, form) {
if (!form || !form.image || !form.concept_art_image) return 'none';
const fingerprint = _libIdentityFingerprint(Object.assign({}, sheet, form));
const stale = form.consistent_prompt_version !== 'v3' || !form.consistent_identity_fingerprint
|| form.consistent_identity_fingerprint !== fingerprint || form.identity_consistent === false;
return stale ? 'review' : 'ok';
}
function _libSetFormField(rec, key, value) {
const form = _libActiveCharacterForm(rec);
rec.sheet = Object.assign({}, rec.sheet || {});
if (!form) { rec.sheet[key] = value; return; }
rec.sheet.forms = (rec.sheet.forms || []).map(function (f) {
return String(f && f.id) === String(form.id) ? Object.assign({}, f, { [key]: value }) : f;
});
}
async function _libPersistSheet(rec, sheet) {
rec.sheet = sheet;
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
}
// Rebuild the deterministic portrait + reference prompts for the appearance that is active now,
// unless the user hand-edited them. Needed before generating images for a form: without it the
// form would be drawn from the base look's prompts.
async function _libEnsureAppearancePrompts(rec) {
const form = _libActiveCharacterForm(rec);
const sh = form ? Object.assign({}, rec.sheet || {}, form) : (rec.sheet || {});
const fields = ['image_prompt', 'concept_art_prompt'].filter(function (k) { return sh[k + '_user_edited'] !== true; });
if (!fields.length) return;
const r = await fetch('/api/character-generate-prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: rec.name || '', book: rec.book || '', sheet: rec.sheet || {},
book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {},
visual_style: _libStr(sh.visual_style) === 'concept' ? 'concept' : 'cinematic', fields: fields,
}),
});
if (!r.ok) throw new Error('Could not build the prompts: ' + ((await r.json().catch(function () { return {}; })).detail || r.statusText));
const d = await r.json();
fields.forEach(function (k) { if (d[k]) _libSetSheetPrompt(rec, k, d[k], false); });
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
}
// Matched profile + reference images for the given forms, one after another, then restore
// whichever appearance was active. Stops at the first failure so nothing is silently skipped.
async function _libGenerateFormImages(rec, formIds, style, onProgress) {
const previous = String((rec.sheet || {}).active_form_id || '');
try {
for (let i = 0; i < formIds.length; i++) {
const form = ((rec.sheet || {}).forms || []).find(function (f) { return String(f && f.id) === String(formIds[i]); });
if (onProgress) onProgress(i + 1, formIds.length, form ? form.name : formIds[i]);
// The image save reads the active form from the stored record, so persist the switch first.
await _libPersistSheet(rec, Object.assign({}, rec.sheet, { active_form_id: String(formIds[i]) }));
await _libEnsureAppearancePrompts(rec);
await _charAutoGenerateConsistentImages(rec, style);
}
} finally {
await _libPersistSheet(rec, Object.assign({}, rec.sheet, { active_form_id: previous })).catch(function () {});
}
}
async function _charDetailPage(rec, allChars, opts) {
if (typeof window.studioRememberCharacter === 'function') window.studioRememberCharacter(rec.id);
opts = opts || {};
@ -1262,6 +1387,30 @@ async function _charDetailPage(rec, allChars, opts) {
}).join('')
+ '</select></label>'
) : '';
const formsManageHtml = '<details class="lcd-forms-manage"' + (characterForms.length ? ' open' : '') + '>'
+ '<summary><span class="mdi mdi-account-switch-outline"></span> Appearance forms (' + characterForms.length + ')</summary>'
+ '<p class="note">One character, one voice — a form only changes how they look (a disguise, a transformation). '
+ 'Select a form to edit its Physical / Clothing text below; the images are generated per form.</p>'
+ (characterForms.length ? '<ul class="lcd-forms-list">' + characterForms.map(function (form) {
const id = String(form?.id || '');
const state = _libFormPairState(baseSheet, form);
return '<li data-form-id="' + escHtml(id) + '"' + (id === activeFormId ? ' class="is-active"' : '') + '>'
+ '<input class="lcd-form-name" type="text" maxlength="160" value="' + escHtml(form?.name || '') + '" aria-label="Form name">'
+ (state === 'review' ? '<span class="lcd-form-flag is-review" title="This form\'s image pair is out of date or mismatched - regenerate it"><span class="mdi mdi-alert-outline" aria-hidden="true"></span> review</span>'
: state === 'ok' ? '<span class="lcd-form-flag is-ok" title="Profile and reference images match"><span class="mdi mdi-check-circle-outline" aria-hidden="true"></span> images ok</span>'
: '<span class="lcd-form-flag">no images</span>')
+ '<button type="button" class="btn-secondary btn-sm lcd-form-show"' + (id === activeFormId ? ' disabled' : '') + '>' + (id === activeFormId ? 'Showing' : 'Show') + '</button>'
+ '<button type="button" class="btn-secondary btn-sm lcd-form-gen" title="Generate matching profile + reference images for this form">Generate images</button>'
+ '<button type="button" class="btn-secondary btn-sm lcd-form-del" aria-label="Delete form ' + escHtml(form?.name || '') + '">Delete</button>'
+ '</li>';
}).join('') + '</ul>' : '')
+ '<div class="lcd-form-add">'
+ '<input class="lcd-form-new-name" type="text" maxlength="160" placeholder="New form, e.g. Wolf form" aria-label="New form name">'
+ '<button type="button" class="btn-primary btn-sm lcd-form-add-btn"' + (characterForms.length >= LIB_MAX_FORMS ? ' disabled' : '') + '>Add form</button>'
+ (characterForms.length > 1 ? '<button type="button" class="btn-secondary btn-sm lcd-forms-gen-all">Generate images for every form</button>' : '')
+ '</div>'
+ '<div class="note lcd-forms-status" role="status" aria-live="polite"></div>'
+ '</details>';
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book || '') : {};
const hue = _charHue(rec.name);
const hue2 = (hue + 40) % 360;
@ -1282,11 +1431,7 @@ async function _charDetailPage(rec, allChars, opts) {
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
};
const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral'];
const identityFields = ['age', 'age_estimate', 'hair', 'eyes', 'physical', 'build', 'skin_tone', 'race_species', 'gender', 'marks', 'scars', 'nationality_background', 'clothing', 'era', 'profession'];
const identityFingerprint = identityFields.map(function (key) { return key + ':' + _libStr(sh[key]); }).join('|');
let identityHash = 0;
for (let i = 0; i < identityFingerprint.length; i++) identityHash = ((identityHash << 5) - identityHash + identityFingerprint.charCodeAt(i)) | 0;
const identityFingerprintShort = (identityHash >>> 0).toString(16);
const identityFingerprintShort = _libIdentityFingerprint(sh);
const avatarImage = sh.image || rec.image;
const avatarHtml = '<div class="lcd-avatar-wrap">'
@ -1497,6 +1642,7 @@ async function _charDetailPage(rec, allChars, opts) {
+ '</div>'
+ '<div class="lcd-body">'
+ bookContextHtml
+ formsManageHtml
+ conceptArtHtml
+ '<div class="lcd-voice-top">'
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
@ -1608,6 +1754,68 @@ async function _charDetailPage(rec, allChars, opts) {
_charDetailPage(updated, all.filter(function (item) { return item.book === rec.book; }), opts);
});
// ── Form management ───────────────────────────────────────────────────────
async function rerenderDetail() {
const all = await clGetAll().catch(function () { return allChars; });
const updated = all.find(function (item) { return item.id === rec.id; }) || rec;
_charDetailPage(updated, all.filter(function (item) { return item.book === rec.book; }), opts);
}
const formsStatus = pg.querySelector('.lcd-forms-status');
const setFormsStatus = function (msg) { if (formsStatus) formsStatus.textContent = msg || ''; };
const guardForms = async function (work) {
try { await work(); } catch (err) { setFormsStatus(''); toast(err.message || String(err), 'error'); }
};
pg.querySelector('.lcd-form-add-btn')?.addEventListener('click', function () {
guardForms(async function () {
await _libPersistSheet(rec, _libAddForm(baseSheet, pg.querySelector('.lcd-form-new-name').value));
toast('Form added - it starts as a copy of the base look; edit it below.', 'success');
await rerenderDetail();
});
});
pg.querySelector('.lcd-form-new-name')?.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); pg.querySelector('.lcd-form-add-btn')?.click(); }
});
pg.querySelectorAll('.lcd-forms-list li').forEach(function (row) {
const id = row.dataset.formId;
row.querySelector('.lcd-form-name').addEventListener('change', function () {
guardForms(async function () {
await _libPersistSheet(rec, _libRenameForm(baseSheet, id, row.querySelector('.lcd-form-name').value));
await rerenderDetail();
});
});
row.querySelector('.lcd-form-show').addEventListener('click', function () {
guardForms(async function () { await _libPersistSheet(rec, Object.assign({}, baseSheet, { active_form_id: id })); await rerenderDetail(); });
});
row.querySelector('.lcd-form-del').addEventListener('click', function () {
guardForms(async function () {
const form = characterForms.find(function (f) { return String(f?.id) === id; });
const ok = await confirmDialog('Delete the form "' + (form?.name || id) + '"? Its own text and images are removed; the character, its voice and the base look stay.',
{ title: 'Delete form?', okLabel: 'Delete', danger: true });
if (!ok) return;
await _libPersistSheet(rec, _libDeleteForm(baseSheet, id));
await rerenderDetail();
});
});
row.querySelector('.lcd-form-gen').addEventListener('click', function () {
guardForms(async function () {
setFormsStatus('Generating images for this form…');
await _libGenerateFormImages(rec, [id], pg.querySelector('.lcd-visual-style')?.value || 'cinematic');
toast('Matching images generated for this form', 'success');
await rerenderDetail();
});
});
});
pg.querySelector('.lcd-forms-gen-all')?.addEventListener('click', function () {
guardForms(async function () {
const ids = characterForms.map(function (f) { return String(f?.id); });
await _libGenerateFormImages(rec, ids, pg.querySelector('.lcd-visual-style')?.value || 'cinematic', function (n, total, name) {
setFormsStatus('Generating images: form ' + n + ' of ' + total + ' (' + name + ')…');
});
toast('Images generated for all ' + ids.length + ' forms', 'success');
await rerenderDetail();
});
});
pg.querySelector('.lib-cpg-back').addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
@ -1744,9 +1952,7 @@ async function _charDetailPage(rec, allChars, opts) {
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Saving…';
try {
if (!rec.sheet) rec.sheet = {};
rec.sheet[sheetKey] = text;
rec.sheet[sheetKey + '_user_edited'] = true;
_libSetSheetPrompt(rec, sheetKey, text, true);
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
// Voice prompts are also available as reusable design presets; the
@ -1840,9 +2046,7 @@ async function _charDetailPage(rec, allChars, opts) {
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
const d = await r.json();
if (!d[key]) throw new Error('Empty response — try again');
if (!rec.sheet) rec.sheet = {};
rec.sheet[key] = d[key];
rec.sheet[key + '_user_edited'] = false;
_libSetSheetPrompt(rec, key, d[key], false);
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
toast('Prompt generated', 'success');
@ -1882,6 +2086,7 @@ async function _charDetailPage(rec, allChars, opts) {
clearTimeout(_saveTimers.get(key));
_saveTimers.set(key, setTimeout(async function () {
if (isRecKey) { rec[key] = value; }
else if (key === 'physical' || key === 'clothing') { _libSetFormField(rec, key, value); } // the active form's own text
else { if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = value; }
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
@ -3343,7 +3548,12 @@ async function _libSaveCharacterVisual(rec, fields) {
return String(item?.id || '') === String(form.id || '') ? Object.assign({}, item, fields) : item;
}) : [];
} else {
Object.assign(target, fields);
// The base appearance: the profile image is the record's own `image`; the concept image,
// prompt version, fingerprint and digests all belong on the sheet, where the UI and the
// pair review read them (they used to land on the record root or stay in memory only).
const { image, ...sheetFields } = fields;
if ('image' in fields) target.image = image;
if (Object.keys(sheetFields).length) target.sheet = Object.assign({}, target.sheet || {}, sheetFields);
}
target.updated = new Date();
if (typeof clPut === 'function') await clPut(target);
@ -3354,6 +3564,42 @@ async function _libSaveCharacterVisual(rec, fields) {
return target;
}
// Prompts that describe one appearance live on the active form; every other prompt
// (voice, SillyTavern) belongs to the character as a whole.
function _libSetSheetPrompt(rec, key, value, edited) {
const form = (key === 'image_prompt' || key === 'concept_art_prompt') ? _libActiveCharacterForm(rec) : null;
rec.sheet = Object.assign({}, rec.sheet || {});
if (form) {
rec.sheet.forms = (rec.sheet.forms || []).map(function (item) {
if (String(item?.id || '') !== String(form.id || '')) return item;
const next = Object.assign({}, item);
next[key] = value;
next[key + '_user_edited'] = edited;
return next;
});
} else {
rec.sheet[key] = value;
rec.sheet[key + '_user_edited'] = edited;
}
}
// The portrait prompt is built server-side from the same structured facts as the concept
// sheet (no LLM call). Returns '' when it cannot be reached so callers can fall back.
async function _libDeterministicPortraitPrompt(rec, bookProfile, sh) {
try {
const r = await fetch('/api/character-generate-prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: rec.name || '', book: rec.book || '', book_profile: bookProfile || {},
sheet: rec.sheet || {}, visual_style: _libStr(sh.visual_style) === 'concept' ? 'concept' : 'cinematic',
fields: ['image_prompt'],
}),
});
if (!r.ok) return '';
return _libStr((await r.json()).image_prompt).trim();
} catch (_) { return ''; }
}
async function _charAutoGenerateImage(rec, provider) {
const form = _libActiveCharacterForm(rec);
const sh = form ? Object.assign({}, rec.sheet || {}, form) : (rec.sheet || {});
@ -3373,7 +3619,12 @@ async function _charAutoGenerateImage(rec, provider) {
}
}
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
// A hand-edited prompt is honoured; otherwise use the deterministic prompt so a lone avatar
// is built from exactly the facts the concept sheet uses (older records may still carry an
// LLM-authored prompt from before extraction stopped writing one).
const editedPrompt = sh.image_prompt_user_edited === true && hasExplicitPrompt;
let prompt = editedPrompt ? _libStr(sh.image_prompt).trim() : await _libDeterministicPortraitPrompt(rec, bookProfile, sh);
if (!prompt) prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
if (!prompt) throw new Error('No image prompt to work from yet');
const body = { prompt: prompt, image_kind: 'portrait' };
if (provider) body.provider = provider;
@ -3383,14 +3634,8 @@ async function _charAutoGenerateImage(rec, provider) {
});
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
const d = await r.json();
if (form) {
await _libSaveCharacterVisual(rec, { image: d.image });
const updatedForm = _libActiveCharacterForm(rec);
if (updatedForm) updatedForm.image = d.image;
} else {
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.image);
rec.image = d.image;
}
// Regenerating only the avatar gives a new face, so an existing matched pair no longer is one.
await _libSaveCharacterVisual(rec, sh.concept_art_image ? { image: d.image, identity_consistent: false } : { image: d.image });
// Bulk runs only re-render the whole grid once, at the very end — with a
// ComfyUI workflow that can take a minute or more per character, that
// left the toolbar's progress counter as the only sign anything was
@ -3557,15 +3802,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
const d = await _cijPollJob(job_id, panel);
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', profile_asset_digest: d.profile_asset_digest || '', reference_asset_digest: d.reference_asset_digest || '', identity_consistent: d.identity_consistent !== false };
if (form) {
await _libSaveCharacterVisual(rec, visualFields);
const updatedForm = _libActiveCharacterForm(rec);
if (updatedForm) Object.assign(updatedForm, visualFields);
} else {
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile);
Object.assign(rec, { image: d.profile });
rec.sheet = Object.assign({}, rec.sheet || {}, { concept_art_image: d.reference, consistent_prompt_version: visualFields.consistent_prompt_version, consistent_identity_fingerprint: visualFields.consistent_identity_fingerprint, identity_consistent: visualFields.identity_consistent });
}
document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) {
av.innerHTML = '<img src="' + d.profile + '" alt="' + escHtml(rec.name) + '">';
});
@ -3595,9 +3832,8 @@ async function _charAutoGenerateConceptArt(rec, provider) {
// scan can silently redirect this onto a different (alias-linked)
// character's record instead of this one, confirmed live as a real bug
// elsewhere this session (a voice update landing on the wrong character).
await _libSaveCharacterVisual(rec, { concept_art_image: d.image });
if (form) form.concept_art_image = d.image;
else rec.sheet = Object.assign({}, rec.sheet || {}, { concept_art_image: d.image });
const hasProfile = form ? !!form.image : !!(rec.image || (rec.sheet || {}).image);
await _libSaveCharacterVisual(rec, hasProfile ? { concept_art_image: d.image, identity_consistent: false } : { concept_art_image: d.image });
return d.image;
}
@ -3703,7 +3939,8 @@ window._openConceptArtLightbox = _openConceptArtLightbox;
window.libraryRenderCharacters = libraryRenderCharacters;
Object.assign(root, {
_libStr, _charAutoGenerateConsistentImages,
_libStr, _charAutoGenerateConsistentImages, _libSetSheetPrompt, _libIdentityFingerprint,
_libAddForm, _libRenameForm, _libDeleteForm, _libFormPairState, _libSetFormField, _libGenerateFormImages,
});
root.TTVC = root.TTVC || {};
root.TTVC.characterLibrary = root.TTVC.characterLibrary || {};

View File

@ -5141,3 +5141,16 @@ input[list="lang-codes"] {
.mag-review-box.is-advertisement { border-color: var(--red); background: rgba(220,38,38,.08); }
.mag-review-box.is-advertisement .mag-review-box-num { background: var(--red); }
/* Appearance forms: manage / rename / delete / per-form image generation */
.lcd-forms-manage { margin: 10px 0 14px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius, 8px); background: var(--surface); }
.lcd-forms-manage > summary { cursor: pointer; font-weight: 600; }
.lcd-forms-list { list-style: none; margin: 8px 0; padding: 0; display: grid; gap: 6px; }
.lcd-forms-list li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; }
.lcd-forms-list li.is-active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.14); }
.lcd-form-name, .lcd-form-new-name { flex: 1 1 180px; min-width: 0; padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); }
.lcd-form-flag { font-size: 12px; color: var(--subtext); white-space: nowrap; }
.lcd-form-flag.is-review { color: var(--warning, #d97706); font-weight: 600; }
.lcd-form-flag.is-ok { color: var(--green, #16a34a); }
.lcd-form-add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
.lcd-forms-manage :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

View File

@ -162,3 +162,250 @@ test('profile completeness counter distinguishes empty, partial, and full sheets
expect(counts.partial.filled).toBe(3);
expect(counts.full.filled).toBe(counts.full.total);
});
// ── Visual persistence (base appearance and forms) ───────────────────────────
// A stateful stand-in for /api/characters/<id> so these tests can assert what was
// actually written back, not just what the page shows.
async function mockRecord(page, initial) {
const state = { rec: JSON.parse(JSON.stringify(initial)), puts: [] };
await page.route(`**/api/characters/${encodeURIComponent(initial.id)}`, route => {
if (route.request().method() === 'PUT') {
state.rec = route.request().postDataJSON();
state.puts.push(state.rec);
return route.fulfill({ json: state.rec });
}
return route.fulfill({ json: state.rec });
});
await page.route('**/api/characters', route => route.fulfill({ json: { characters: [state.rec] } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
return state;
}
async function openLibrary(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window._charAutoGenerateImage === 'function' && typeof window._libSetSheetPrompt === 'function', null, { timeout: 15_000 });
}
const PNG_A = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
test('a matched image pair for the base appearance is fully persisted', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-base', book: 'Smoke Book', name: 'Base', tags: '', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } });
await page.route('**/api/character-generate-consistent-images', route => route.fulfill({ json: { job_id: 'job-ok' } }));
await page.route('**/api/character-generate-consistent-images/status/job-ok', route => route.fulfill({ json: {
stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null,
result: { profile: PNG_A, reference: PNG_A + 'AA', prompt_version: 'v3', identity_fingerprint: 'abc123', profile_asset_digest: 'pd', reference_asset_digest: 'rd', identity_consistent: true },
} }));
await openLibrary(page);
await page.evaluate(() => window._charAutoGenerateConsistentImages({ id: 'c-base', book: 'Smoke Book', name: 'Base', sheet: { physical: 'tall', image_prompt: 'p', concept_art_prompt: 'r' } }, 'cinematic'));
const saved = state.puts[state.puts.length - 1];
expect(saved.image).toBe(PNG_A);
expect(saved.sheet.concept_art_image).toBe(PNG_A + 'AA');
expect(saved.sheet.consistent_identity_fingerprint).toBe('abc123');
expect(saved.sheet.consistent_prompt_version).toBe('v3');
expect(saved.sheet.profile_asset_digest).toBe('pd');
expect(saved.sheet.reference_asset_digest).toBe('rd');
expect(saved.sheet.identity_consistent).toBe(true);
expect(saved.concept_art_image).toBeUndefined();
});
test('regenerating only the avatar uses the deterministic prompt and flags an existing pair', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-av', book: 'Smoke Book', name: 'Avatar', tags: '', image: PNG_A,
sheet: { physical: 'a tall woman with copper hair and green eyes', archetype: 'cartographer', image_prompt: 'LLM-AUTHORED', concept_art_image: PNG_A + 'AA', identity_consistent: true } });
let promptSent = '';
await page.route('**/api/character-generate-prompts', route => route.fulfill({ json: { image_prompt: 'DETERMINISTIC-PROMPT' } }));
await page.route('**/api/character-generate-image', route => { promptSent = route.request().postDataJSON().prompt; return route.fulfill({ json: { image: PNG_A + 'BB' } }); });
await openLibrary(page);
await page.evaluate(() => window._charAutoGenerateImage(JSON.parse(JSON.stringify({ id: 'c-av', book: 'Smoke Book', name: 'Avatar',
sheet: { physical: 'a tall woman with copper hair and green eyes', archetype: 'cartographer', image_prompt: 'LLM-AUTHORED', concept_art_image: 'x' } }))));
expect(promptSent).toBe('DETERMINISTIC-PROMPT');
const saved = state.puts[state.puts.length - 1];
expect(saved.image).toBe(PNG_A + 'BB');
expect(saved.sheet.identity_consistent).toBe(false);
});
test('a hand-edited portrait prompt is still honoured for avatar-only generation', async ({ page }) => {
await mockRecord(page, { id: 'c-ed', book: 'Smoke Book', name: 'Edited', tags: '', sheet: { physical: 'a tall woman with copper hair', image_prompt: 'MY-OWN-PROMPT', image_prompt_user_edited: true } });
let promptSent = '';
let generatedPrompts = 0;
await page.route('**/api/character-generate-prompts', route => { generatedPrompts += 1; return route.fulfill({ json: { image_prompt: 'DETERMINISTIC-PROMPT' } }); });
await page.route('**/api/character-generate-image', route => { promptSent = route.request().postDataJSON().prompt; return route.fulfill({ json: { image: PNG_A } }); });
await openLibrary(page);
await page.evaluate(() => window._charAutoGenerateImage({ id: 'c-ed', book: 'Smoke Book', name: 'Edited', sheet: { physical: 'a tall woman with copper hair', image_prompt: 'MY-OWN-PROMPT', image_prompt_user_edited: true } }));
expect(promptSent).toBe('MY-OWN-PROMPT');
expect(generatedPrompts).toBe(0);
});
test('appearance prompts are written to the active form, character-wide prompts to the base', async ({ page }) => {
await mockRecord(page, { id: 'c-form', book: 'b', name: 'Form', tags: '', sheet: {} });
await openLibrary(page);
const out = await page.evaluate(() => {
const rec = { id: 'c-form', sheet: { image_prompt: 'base-img', voice_design_prompt: 'base-voice', active_form_id: 'wolf',
forms: [{ id: 'wolf', name: 'Wolf', image_prompt: 'old-wolf' }, { id: 'bat', name: 'Bat', image_prompt: 'bat-img' }] } };
window._libSetSheetPrompt(rec, 'image_prompt', 'new-wolf', false);
window._libSetSheetPrompt(rec, 'voice_design_prompt', 'new-voice', true);
return rec.sheet;
});
expect(out.image_prompt).toBe('base-img');
expect(out.forms.find(f => f.id === 'wolf').image_prompt).toBe('new-wolf');
expect(out.forms.find(f => f.id === 'bat').image_prompt).toBe('bat-img');
expect(out.voice_design_prompt).toBe('new-voice');
expect(out.voice_design_prompt_user_edited).toBe(true);
});
// ── Appearance forms: management ─────────────────────────────────────────────
test('form helpers add, rename, delete and judge image pairs', async ({ page }) => {
await mockRecord(page, { id: 'c-h', book: 'b', name: 'H', tags: '', sheet: {} });
await openLibrary(page);
const r = await page.evaluate(() => {
const out = {};
const sheet = { physical: 'human', clothing: 'coat', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }] };
const added = window._libAddForm(sheet, ' Bat form ');
out.added = { name: added.forms[1].name, id: added.forms[1].id, physical: added.forms[1].physical, clothing: added.forms[1].clothing, active: added.active_form_id, base: sheet.forms.length };
out.errors = [];
for (const bad of ['', ' ', 'wolf', 'x'.repeat(161)]) { try { window._libAddForm(sheet, bad); out.errors.push('no error for ' + JSON.stringify(bad.slice(0, 5))); } catch (e) { out.errors.push(e.message); } }
let full = { forms: [] };
for (let i = 0; i < 8; i++) full = window._libAddForm(full, 'form ' + i);
try { window._libAddForm(full, 'one too many'); out.errors.push('no error at 9'); } catch (e) { out.errors.push(e.message); }
out.ids = new Set(full.forms.map(f => f.id)).size;
out.renamed = window._libRenameForm(sheet, 'wolf', 'Werewolf').forms[0].name;
try { window._libRenameForm(added, 'wolf', 'bat form'); out.errors.push('no error on duplicate rename'); } catch (e) { out.errors.push(e.message); }
const deleted = window._libDeleteForm({ ...added, active_form_id: 'wolf' }, 'wolf');
out.deleted = { n: deleted.forms.length, active: deleted.active_form_id };
const keepActive = window._libDeleteForm(added, 'wolf');
out.keepActive = keepActive.active_form_id === added.active_form_id;
// pair state
const base = { physical: 'human' };
const fp = window._libIdentityFingerprint({ ...base, physical: 'fur' });
const okForm = { id: 'f', physical: 'fur', image: 'i', concept_art_image: 'c', consistent_prompt_version: 'v3', consistent_identity_fingerprint: fp, identity_consistent: true };
out.state = {
none: window._libFormPairState(base, { id: 'f', physical: 'fur' }),
ok: window._libFormPairState(base, okForm),
stale: window._libFormPairState(base, { ...okForm, consistent_identity_fingerprint: 'old' }),
broken: window._libFormPairState(base, { ...okForm, identity_consistent: false }),
legacy: window._libFormPairState(base, { ...okForm, consistent_prompt_version: 'v2' }),
};
return out;
});
expect(r.added).toEqual({ name: 'Bat form', id: 'bat-form', physical: 'human', clothing: 'coat', active: 'bat-form', base: 1 });
expect(r.errors).toEqual(['Give the form a name.', 'Give the form a name.', 'Another form already uses that name.', 'The form name is too long (max 160 characters).', 'A character can have at most 8 forms.', 'Another form already uses that name.']);
expect(r.ids).toBe(8);
expect(r.renamed).toBe('Werewolf');
expect(r.deleted).toEqual({ n: 1, active: '' });
expect(r.keepActive).toBe(true);
expect(r.state).toEqual({ none: 'none', ok: 'ok', stale: 'review', broken: 'review', legacy: 'review' });
});
test('editing Physical text while a form is active changes only that form', async ({ page }) => {
await mockRecord(page, { id: 'c-f', book: 'b', name: 'F', tags: '', sheet: {} });
await openLibrary(page);
const r = await page.evaluate(() => {
const rec = { id: 'c-f', sheet: { physical: 'human', active_form_id: 'wolf', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }, { id: 'bat', name: 'Bat', physical: 'wings' }] } };
window._libSetFormField(rec, 'physical', 'grey fur, yellow eyes');
const withForm = JSON.parse(JSON.stringify(rec.sheet));
const base = { id: 'c-f', sheet: { physical: 'human', forms: [{ id: 'wolf', name: 'Wolf' }] } };
window._libSetFormField(base, 'physical', 'tall');
return { withForm, base: base.sheet };
});
expect(r.withForm.physical).toBe('human');
expect(r.withForm.forms.find(f => f.id === 'wolf').physical).toBe('grey fur, yellow eyes');
expect(r.withForm.forms.find(f => f.id === 'bat').physical).toBe('wings');
expect(r.base.physical).toBe('tall');
});
test('generating images for several forms rebuilds each form\'s prompts, saves each on its own form and restores the active appearance', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-g', book: 'Smoke Book', name: 'Gen', tags: '', sheet: {
physical: 'human', active_form_id: '', forms: [{ id: 'wolf', name: 'Wolf', physical: 'fur' }, { id: 'bat', name: 'Bat', physical: 'wings', concept_art_prompt: 'HAND-MADE', concept_art_prompt_user_edited: true }] } });
const consistentBodies = [];
let job = 0;
await page.route('**/api/character-generate-prompts', route => {
const b = route.request().postDataJSON();
return route.fulfill({ json: { image_prompt: 'PORTRAIT[' + b.sheet.active_form_id + ']', concept_art_prompt: 'SHEET[' + b.sheet.active_form_id + ']' } });
});
await page.route('**/api/character-generate-consistent-images', route => {
consistentBodies.push(route.request().postDataJSON());
job += 1;
return route.fulfill({ json: { job_id: 'job-' + job } });
});
await page.route('**/api/character-generate-consistent-images/status/*', route => {
const id = route.request().url().split('/').pop();
return route.fulfill({ json: { stage: 'done', detail: 'Done', elapsed: 1, stale_seconds: 0, done: true, error: null,
result: { profile: PNG_A + id, reference: PNG_A + id + 'R', prompt_version: 'v3', identity_fingerprint: 'fp-' + id, profile_asset_digest: 'p', reference_asset_digest: 'r', identity_consistent: true } } });
});
await openLibrary(page);
const progress = [];
await page.exposeFunction('__progress', (n, t, name) => progress.push(n + '/' + t + ' ' + name));
await page.evaluate(async () => {
const rec = await (await fetch('/api/characters/c-g')).json();
await window._libGenerateFormImages(rec, ['wolf', 'bat'], 'cinematic', (n, t, name) => window.__progress(n, t, name));
});
expect(progress).toEqual(['1/2 Wolf', '2/2 Bat']);
expect(consistentBodies.map(b => b.profile_prompt)).toEqual(['PORTRAIT[wolf]', 'PORTRAIT[bat]']);
// the hand-made reference prompt of the second form is kept, the first is rebuilt
expect(consistentBodies.map(b => b.reference_prompt)).toEqual(['SHEET[wolf]', 'HAND-MADE']);
const forms = state.rec.sheet.forms;
expect(forms.find(f => f.id === 'wolf').image).toBe(PNG_A + 'job-1');
expect(forms.find(f => f.id === 'bat').image).toBe(PNG_A + 'job-2');
expect(forms.find(f => f.id === 'wolf').consistent_identity_fingerprint).toBe('fp-job-1');
expect(state.rec.sheet.active_form_id).toBe('');
expect(state.rec.image).toBeFalsy(); // the base look was not touched
});
test('the forms panel adds, renames and deletes a form and saves each change', async ({ page }) => {
const state = await mockRecord(page, { id: 'c-ui', book: 'Smoke Book', name: 'Zerwas', tags: '', voice: 'de_bob', image: '/human.png', sheet: {
physical: 'human with dark hair', clothing: 'black coat',
forms: [{ id: 'wolf', name: 'Wolf form', physical: 'large black wolf', image: '/wolf.png', concept_art_image: '/wolf-sheet.png' }] } });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window._charDetailPage === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await page.waitForSelector('.lib-char-card[data-char-id="c-ui"]', { timeout: 15_000 });
await page.evaluate(async () => {
const rec = await (await fetch('/api/characters/c-ui')).json();
window._charDetailPage(rec, [], { container: document.querySelector('#lib-chars-list') });
});
const panel = page.locator('.lcd-forms-manage');
await expect(panel).toContainText('Appearance forms (1)');
// A form with images made before fingerprints existed is flagged for review.
await expect(panel.locator('li[data-form-id="wolf"] .lcd-form-flag')).toContainText('review');
// add
await panel.locator('.lcd-form-new-name').fill('Bat form');
await panel.locator('.lcd-form-add-btn').click();
await expect(page.locator('.lcd-forms-manage')).toContainText('Appearance forms (2)');
let saved = state.puts[state.puts.length - 1].sheet;
expect(saved.forms.map(f => f.name)).toEqual(['Wolf form', 'Bat form']);
expect(saved.active_form_id).toBe('bat-form');
expect(saved.forms[1].physical).toBe('human with dark hair'); // starts as a copy of the base look
expect(saved.physical).toBe('human with dark hair'); // base untouched
// adding a duplicate name is refused with a message and saves nothing
const putsBefore = state.puts.length;
await page.locator('.lcd-forms-manage .lcd-form-new-name').fill('wolf FORM');
await page.locator('.lcd-forms-manage .lcd-form-add-btn').click();
await expect(page.locator('#toast')).toContainText('already uses that name');
expect(state.puts.length).toBe(putsBefore);
// rename
const wolfName = page.locator('.lcd-forms-manage li[data-form-id="wolf"] .lcd-form-name');
await wolfName.fill('Werewolf');
await wolfName.blur();
await expect(page.locator('.lcd-forms-manage li[data-form-id="wolf"] .lcd-form-name')).toHaveValue('Werewolf');
expect(state.puts[state.puts.length - 1].sheet.forms[0].name).toBe('Werewolf');
// delete (confirm dialog)
await page.locator('.lcd-forms-manage li[data-form-id="bat-form"] .lcd-form-del').click();
await page.locator('#cd-ok').click();
await expect(page.locator('.lcd-forms-manage')).toContainText('Appearance forms (1)');
saved = state.puts[state.puts.length - 1].sheet;
expect(saved.forms.map(f => f.id)).toEqual(['wolf']);
expect(saved.active_form_id).toBe('');
expect(state.rec.voice).toBe('de_bob'); // the character and its voice are untouched
});

View File

@ -0,0 +1,166 @@
"""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_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))
if __name__ == "__main__":
unittest.main()