From 5877aa2edcc6d2a6f7eac625d0cb217c7339788c Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 19 Sep 2026 15:27:21 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 16 ++ TODO.md | 58 ++-- VERSION | 2 +- architecture/character-visual-identity.md | 55 ++++ routes/characters.py | 91 ++++--- routes/conversation.py | 37 ++- static/index.html | 14 +- static/js/character-sheets.js | 13 +- static/js/library-characters.js | 307 +++++++++++++++++++--- static/style.css | 13 + tests/characters.spec.js | 247 +++++++++++++++++ tests/test_character_identity.py | 166 ++++++++++++ 12 files changed, 889 insertions(+), 130 deletions(-) create mode 100644 architecture/character-visual-identity.md create mode 100644 tests/test_character_identity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7200fc5..f274af2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TODO.md b/TODO.md index d239e7f..ae2ce1c 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/VERSION b/VERSION index 8602511..f0fed91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.32.0 \ No newline at end of file +1.33.0 \ No newline at end of file diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md new file mode 100644 index 0000000..da8f74d --- /dev/null +++ b/architecture/character-visual-identity.md @@ -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. diff --git a/routes/characters.py b/routes/characters.py index 4815c90..4b17910 100644 --- a/routes/characters.py +++ b/routes/characters.py @@ -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,43 +144,44 @@ 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") - profile_digest = _visual_asset_digest(profile) - reference_digest = _visual_asset_digest(reference) - for kind, digest in (("profile", profile_digest), ("reference", reference_digest)): - if digest: - digest_owners.setdefault(digest, []).append(f"{record.get('id')}:{kind}") - issues = [] - if bool(profile) != bool(reference): - issues.append("incomplete_pair") - 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 "") - 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: - issues.append("legacy_or_missing_prompt_version") - if not sheet.get("consistent_identity_fingerprint"): - issues.append("missing_identity_fingerprint") - elif sheet.get("consistent_identity_fingerprint") != expected: - issues.append("appearance_changed_since_generation") - if sheet.get("identity_consistent") is False: - issues.append("marked_inconsistent") - if issues: - reports.append({"id": record.get("id"), "name": record.get("name", ""), "issues": issues}) + 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)): + if digest: + digest_owners.setdefault(digest, []).append(f"{record.get('id')}:{kind}") + issues = [] + if bool(profile) != bool(reference): + issues.append("incomplete_pair") + if profile and reference: + if profile_digest and profile_digest == reference_digest: + issues.append("same_asset_for_profile_and_reference") + 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(fp_sheet) + if meta.get("consistent_prompt_version") != _CHARACTER_VISUAL_PROMPT_VERSION: + issues.append("legacy_or_missing_prompt_version") + if not meta.get("consistent_identity_fingerprint"): + issues.append("missing_identity_fingerprint") + elif meta.get("consistent_identity_fingerprint") != expected: + issues.append("appearance_changed_since_generation") + if meta.get("identity_consistent") is False: + issues.append("marked_inconsistent") + if 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) diff --git a/routes/conversation.py b/routes/conversation.py index cc0e9ae..504039a 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -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' ) diff --git a/static/index.html b/static/index.html index 06e5d34..96d868d 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index 3474fa7..eaea5fe 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -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; diff --git a/static/js/library-characters.js b/static/js/library-characters.js index 03c4fe2..a62c85c 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -30,7 +30,7 @@ async function _libReviewVisualPairs(button, container) { } else { panel.innerHTML = ' Review ' + payload.issues.length + ' character pair' + (payload.issues.length === 1 ? '' : 's') + ': ' + payload.issues.map(function (item) { - return '' + escHtml(item.name || item.id) + ' — ' + escHtml(item.issues.join(', ')) + ''; + return '' + escHtml(item.name || item.id) + (item.form_name ? ' / ' + escHtml(item.form_name) : '') + ' — ' + escHtml(item.issues.join(', ')) + ''; }).join(''); if (typeof toast === 'function') toast(payload.issues.length + ' visual pair(s) need review', 'warning'); } @@ -650,6 +650,16 @@ function _charAlignHtml(sh) { + ''; } +// 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('') + '' ) : ''; + const formsManageHtml = '
' + + ' Appearance forms (' + characterForms.length + ')' + + '

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.

' + + (characterForms.length ? '
    ' + characterForms.map(function (form) { + const id = String(form?.id || ''); + const state = _libFormPairState(baseSheet, form); + return '
  • ' + + '' + + (state === 'review' ? ' review' + : state === 'ok' ? ' images ok' + : 'no images') + + '' + + '' + + '' + + '
  • '; + }).join('') + '
' : '') + + '
' + + '' + + '' + + (characterForms.length > 1 ? '' : '') + + '
' + + '
' + + '
'; 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 = '
' @@ -1497,6 +1642,7 @@ async function _charDetailPage(rec, allChars, opts) { + '
' + '
' + bookContextHtml + + formsManageHtml + conceptArtHtml + '
' + '' @@ -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 = ' 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 }); - } + await _libSaveCharacterVisual(rec, visualFields); document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) { av.innerHTML = '' + 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 || {}; diff --git a/static/style.css b/static/style.css index 51c3e04..5f90995 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/characters.spec.js b/tests/characters.spec.js index 843bad3..3df2e30 100644 --- a/tests/characters.spec.js +++ b/tests/characters.spec.js @@ -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/ 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 +}); diff --git a/tests/test_character_identity.py b/tests/test_character_identity.py new file mode 100644 index 0000000..ce20312 --- /dev/null +++ b/tests/test_character_identity.py @@ -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()