feat: support character appearance forms

This commit is contained in:
mARTin-B78 2026-09-04 08:51:33 +02:00
parent 5cd0d0cc3f
commit 3456b5a3b0
11 changed files with 265 additions and 57 deletions

View File

@ -16,6 +16,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
at startup; a generation-history table is available for reversible prompt/result auditing. at startup; a generation-history table is available for reversible prompt/result auditing.
- **Character and Voice Metadata Validation.** Added bounded type and size validation before records and voice sidecars - **Character and Voice Metadata Validation.** Added bounded type and size validation before records and voice sidecars
are persisted. are persisted.
- **Character Appearance Forms.** A character can now keep alternate transformations or disguises in bounded
`sheet.forms[]` entries, with one-character/one-voice attribution, a detail-page form switcher, selected-form
prompt resolution, and per-form portrait/reference assets.
### Changed ### Changed
- **API Boundary Hardening.** External API authentication is enabled by default, request and mutation limits are - **API Boundary Hardening.** External API authentication is enabled by default, request and mutation limits are

36
TODO.md
View File

@ -22,16 +22,16 @@ the first block before investing in architecture or polish.
transient input/error paths. transient input/error paths.
- [x] **S — Validate uploaded filenames and paths.** Upload endpoints reject traversal, path separators and control - [x] **S — Validate uploaded filenames and paths.** Upload endpoints reject traversal, path separators and control
characters before deriving extensions; ZIP imports reject absolute, traversal and malformed archive members. characters before deriving extensions; ZIP imports reject absolute, traversal and malformed archive members.
- [ ] **M — Add upload, request-size, temporary-file and concurrent-job limits.** Apply - [x] **M — Add upload, request-size, temporary-file and concurrent-job limits.** Endpoint-specific upload/request
endpoint-specific caps and clean up intermediates on failure. caps, bounded temporary work, cache eviction, and bounded TTS/STT/image job pools are implemented.
- [x] **M — Add automatic backups before bulk deletes, voice moves and recasts.** Make - [x] **M — Add automatic backups before bulk deletes, voice moves and recasts.** Make
recovery explicit and verify that backups can be restored. recovery explicit and verify that backups can be restored.
- [ ] **M — Strictly validate custom URLs and prevent SSRF.** Allow only intended - [x] **M — Strictly validate custom URLs and prevent SSRF.** HTTP downloads and provider/container probes share
schemes, hosts and network destinations for provider/container probes. scheme, hostname and private-destination validation with explicit local-network opt-in.
- [ ] **M — Add authentication and rate limits for destructive/API endpoints.** Protect - [x] **M — Add authentication and rate limits for destructive/API endpoints.** External API authentication is
settings, file operations, Docker controls and bulk mutations. enabled by default and destructive/general mutation limits return bounded 429 responses.
- [ ] **M — Validate character sheets and voice metadata with schemas.** Reject or repair - [x] **M — Validate character sheets and voice metadata with schemas.** Character/form and voice metadata are
malformed records at API boundaries. bounded and type-checked at API boundaries.
- [x] **L — Add database migrations and prompt/generation history.** Preserve old record shapes and expose protected, - [x] **L — Add database migrations and prompt/generation history.** Preserve old record shapes and expose protected,
metadata-only generation history for auditing and follow-up recovery workflows. metadata-only generation history for auditing and follow-up recovery workflows.
- [x] **L — Harden container and Docker access.** The app runs non-root with dropped capabilities, - [x] **L — Harden container and Docker access.** The app runs non-root with dropped capabilities,
@ -55,10 +55,10 @@ the first block before investing in architecture or polish.
an image-level review workflow. an image-level review workflow.
- [x] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference, voice-design, and casting - [x] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference, voice-design, and casting
jobs now deduplicate repeated requests with bounded, expiring `Idempotency-Key` contracts. jobs now deduplicate repeated requests with bounded, expiring `Idempotency-Key` contracts.
- [ ] **M — Improve large-table performance.** Character productions and the flat voice library now paginate at 50 - [x] **M — Improve large-table performance.** Character productions and the flat voice library paginate at 50
visible rows; updating only affected rows and disabling preloading on low-memory devices remain. visible rows; low-memory mode also reduces rendered rows and image work.
- [ ] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses and persisted synthesized - [x] **M — Add explicit cache invalidation and eviction.** Fish.audio search responses, persisted synthesized
line-audio clips now have bounded eviction; image entries and browser object URLs still need equivalent cleanup. audio, image-generation job mappings, and browser object URLs have bounded cleanup.
- [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling, - [ ] **M — Add constrained-device validation.** Test with Chrome CPU/memory throttling,
a 4 GB VM/container, and measure browser, Docker and temporary-disk usage. a 4 GB VM/container, and measure browser, Docker and temporary-disk usage.
- [ ] **L — Add background jobs and bounded queues.** Give voice design, transcription, - [ ] **L — Add background jobs and bounded queues.** Give voice design, transcription,
@ -66,8 +66,9 @@ the first block before investing in architecture or polish.
idempotency support. idempotency support.
- [ ] **L — Run the Book 2 generalization regression.** Confirm speaker-attribution and - [ ] **L — Run the Book 2 generalization regression.** Confirm speaker-attribution and
confidence rules are not overfit to *Der Sturm*. confidence rules are not overfit to *Der Sturm*.
- [ ] **L — Add character forms inside one character record.** Support multiple - [x] **L — Add character forms inside one character record.** `sheet.forms[]` stores bounded alternate appearances,
appearances while preserving one voice and one attribution target. prompts, and per-form visual assets; the detail-page switcher preserves one character ID and voice, and selected
forms feed prompt generation and identity fingerprints.
- [ ] **L — Modularize the frontend.** Split Audiobook, Rehearser and styles, replace - [ ] **L — Modularize the frontend.** Split Audiobook, Rehearser and styles, replace
broad globals with namespaces, and consolidate the loader/module manifest. broad globals with namespaces, and consolidate the loader/module manifest.
@ -266,7 +267,7 @@ grammatical evidence (`Darrag der Schmied`), and pre-reveal aliases like
- Character Sheets only saves after its passage loop ends; poll `/api/characters` - Character Sheets only saves after its passage loop ends; poll `/api/characters`
until records appear before closing the browser, or a 4.4 h run is discarded. until records appear before closing the browser, or a 4.4 h run is discarded.
## P1 — Character forms (decided 2026-09-02, not yet built) ## P1 — Character forms (implemented 2026-09-04)
Shape-shifting characters (Zerwas: human / vampire / wolf-demon, aliases "Fürst der Shape-shifting characters (Zerwas: human / vampire / wolf-demon, aliases "Fürst der
Nacht", "der Wolfsmensch") need more than one appearance per character. Nacht", "der Wolfsmensch") need more than one appearance per character.
@ -278,7 +279,10 @@ and portrait. **Detection is automatic**: the sheet prompt asks the LLM to ident
whether a character has distinct forms and describe each, so it generalises to any whether a character has distinct forms and describe each, so it generalises to any
book (werewolves, disguises, transformations) without per-book rules. book (werewolves, disguises, transformations) without per-book rules.
Not started. Data model, sheet UI and prompt changes all still to do. Implemented as bounded `sheet.forms[]` data inside the existing character record. The extraction prompt asks for
distinct forms, the detail page switches the active appearance, selected forms feed prompt/fingerprint generation,
and generated/uploaded portrait/reference assets remain attached to that form. One character ID and one voice are
preserved for attribution. A future polish pass can add richer inline form editing and per-form bulk-generation UX.
## P1 — Portrait / concept-sheet identity mismatch ## P1 — Portrait / concept-sheet identity mismatch

View File

@ -98,6 +98,37 @@ def _validate_character_record(record: dict) -> dict:
raise HTTPException(413, f"Character field '{field}' is too large") raise HTTPException(413, f"Character field '{field}' is too large")
if not isinstance(record.get("sheet", {}), dict): if not isinstance(record.get("sheet", {}), dict):
raise HTTPException(400, "Character sheet must be an object") raise HTTPException(400, "Character sheet must be an object")
sheet = record.get("sheet") or {}
forms = sheet.get("forms", [])
if forms is not None and not isinstance(forms, list):
raise HTTPException(400, "Character sheet forms must be an array")
if isinstance(forms, list) and len(forms) > 8:
raise HTTPException(413, "A character may have at most 8 forms")
for form in forms or []:
if not isinstance(form, dict):
raise HTTPException(400, "Each character form must be an object")
for field, max_length in {
"id": 120, "name": 160, "physical": 12000, "clothing": 12000,
"image_prompt": 8000, "concept_art_prompt": 8000,
"image": 24 * 1024 * 1024, "concept_art_image": 24 * 1024 * 1024,
"consistent_prompt_version": 120, "consistent_identity_fingerprint": 120,
}.items():
value = form.get(field, "")
if value is not None and not isinstance(value, str):
raise HTTPException(400, f"Character form field '{field}' must be text")
if isinstance(value, str) and len(value) > max_length:
raise HTTPException(413, f"Character form field '{field}' is too large")
for field in ("inventory", "visual_props"):
value = form.get(field, [])
if value is not None and not isinstance(value, list):
raise HTTPException(400, f"Character form field '{field}' must be an array")
if isinstance(value, list) and len(value) > 12:
raise HTTPException(413, f"Character form field '{field}' has too many items")
if isinstance(value, list) and any(not isinstance(item, str) or len(item) > 300 for item in value):
raise HTTPException(400, f"Character form field '{field}' contains an invalid item")
active_form_id = sheet.get("active_form_id", "")
if active_form_id is not None and not isinstance(active_form_id, str):
raise HTTPException(400, "Character active form id must be text")
if "analysis" in record and record["analysis"] is not None and not isinstance(record["analysis"], (dict, list)): if "analysis" in record and record["analysis"] is not None and not isinstance(record["analysis"], (dict, list)):
raise HTTPException(400, "Character analysis must be an object, array, or null") raise HTTPException(400, "Character analysis must be an object, array, or null")
return record return record

View File

@ -73,6 +73,7 @@ _CHARACTER_IDENTITY_FIELDS = (
def _character_identity_fingerprint(sheet: dict) -> str: def _character_identity_fingerprint(sheet: dict) -> str:
sheet = _character_form_sheet(sheet)
values = {key: sheet.get(key, "") for key in _CHARACTER_IDENTITY_FIELDS} 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) 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 # Keep this compact hash algorithm identical to the browser-side stale
@ -83,6 +84,20 @@ def _character_identity_fingerprint(sheet: dict) -> str:
return format(value, "x") return format(value, "x")
def _character_form_sheet(sheet: dict) -> dict:
"""Overlay the selected appearance form without changing the character ID or voice."""
if not isinstance(sheet, dict):
return {}
forms = sheet.get("forms") if isinstance(sheet.get("forms"), list) else []
active_id = str(sheet.get("active_form_id") or "").strip()
if not active_id:
return dict(sheet)
selected = next((form for form in forms if isinstance(form, dict) and str(form.get("id") or "") == active_id), None)
if not selected:
return dict(sheet)
return {**sheet, **{key: value for key, value in selected.items() if key not in {"id", "name"}}}
def _cij_new(job_id: str, name: str) -> None: def _cij_new(job_id: str, name: str) -> None:
with _consistent_image_jobs_lock: with _consistent_image_jobs_lock:
now = time.time() now = time.time()
@ -1476,6 +1491,7 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str,
def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str: def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str:
"""Build the replaceable Subject block used by every character image.""" """Build the replaceable Subject block used by every character image."""
sheet = _character_form_sheet(sheet)
def clipped(key: str, limit: int) -> str: def clipped(key: str, limit: int) -> str:
value = _visual_prompt_value(sheet.get(key)) value = _visual_prompt_value(sheet.get(key))
if len(value) <= limit: if len(value) <= limit:
@ -2230,7 +2246,7 @@ async def character_generate_prompts(request: Request):
data = await request.json() data = await request.json()
name: str = (data.get("name") or "").strip() name: str = (data.get("name") or "").strip()
book: str = (data.get("book") or "").strip() book: str = (data.get("book") or "").strip()
sheet: dict = data.get("sheet") or {} sheet: dict = _character_form_sheet(data.get("sheet") or {})
book_profile: dict = data.get("book_profile") or {} book_profile: dict = data.get("book_profile") or {}
voice_feedback: str = (data.get("voice_feedback") or "").strip() voice_feedback: str = (data.get("voice_feedback") or "").strip()
visual_style: str = (data.get("visual_style") or "cinematic").strip().lower() visual_style: str = (data.get("visual_style") or "cinematic").strip().lower()
@ -3148,7 +3164,7 @@ async def character_generate_consistent_images(request: Request):
return {"job_id": previous_job_id, "deduplicated": True} return {"job_id": previous_job_id, "deduplicated": True}
name = str(data.get("name") or "").strip() name = str(data.get("name") or "").strip()
book = str(data.get("book") or "").strip() book = str(data.get("book") or "").strip()
sheet: dict = data.get("sheet") or {} sheet: dict = _character_form_sheet(data.get("sheet") or {})
saved_profile_prompt = str(data.get("profile_prompt") or data.get("image_prompt") or "").strip() saved_profile_prompt = str(data.get("profile_prompt") or data.get("image_prompt") or "").strip()
saved_reference_prompt = str(data.get("reference_prompt") or data.get("concept_art_prompt") or "").strip() saved_reference_prompt = str(data.get("reference_prompt") or data.get("concept_art_prompt") or "").strip()
book_profile: dict = data.get("book_profile") or {} book_profile: dict = data.get("book_profile") or {}

File diff suppressed because one or more lines are too long

View File

@ -45,6 +45,7 @@ For each character output these fields:
- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting) - 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. - 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. - 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.
- inventory: 1-3 defining items/props/clothing (array of short strings) - inventory: 1-3 defining items/props/clothing (array of short strings)
- secret: dark secret or fatal flaw - secret: dark secret or fatal flaw
- conflict_style: fight, flight, or manipulate — how they act when cornered - conflict_style: fight, flight, or manipulate — how they act when cornered
@ -58,7 +59,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. 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. 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: 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_pattern":"","voice_design_prompt":"","image_prompt":"","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":"","image_prompt":"","forms":[],"inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
/no-think`; /no-think`;
// Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the // Resolve the LLM endpoint. Prefer an explicitly-typed rehearser URL, then the
@ -275,7 +276,7 @@ function csBlankSheet(name) {
attribute_high: '', attribute_low: '', skills: '', capabilities: '', attribute_high: '', attribute_low: '', skills: '', capabilities: '',
backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', communication_style: '', voice_pattern: '', backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', communication_style: '', voice_pattern: '',
secret: '', conflict_style: '', win_condition: '', reputation: '', religious_beliefs: '', notes: '', voice_design_prompt: '', image_prompt: '', secret: '', conflict_style: '', win_condition: '', reputation: '', religious_beliefs: '', notes: '', voice_design_prompt: '', image_prompt: '',
inventory: [], sources: [], moral_alignment_score: 50, arc_direction: 'neutral', tier: 'supporting', forms: [], active_form_id: '', inventory: [], sources: [], moral_alignment_score: 50, arc_direction: 'neutral', tier: 'supporting',
}; };
} }
@ -304,6 +305,18 @@ function csSeedSheet(raw) {
line_hint: _csStr(item.line_hint).slice(0, 60), line_hint: _csStr(item.line_hint).slice(0, 60),
} : null).filter(Boolean); } : null).filter(Boolean);
} }
if (Array.isArray(src?.forms)) {
seed.forms = src.forms.slice(0, 8).filter(form => form && typeof form === 'object').map((form, index) => ({
id: String(form.id || `form-${index + 1}`).slice(0, 120),
name: String(form.name || `Form ${index + 1}`).slice(0, 160),
physical: _csStr(form.physical).slice(0, 12000),
clothing: _csStr(form.clothing).slice(0, 12000),
image_prompt: _csStr(form.image_prompt).slice(0, 8000),
concept_art_prompt: _csStr(form.concept_art_prompt).slice(0, 8000),
inventory: Array.isArray(form.inventory) ? form.inventory.filter(x => typeof x === 'string').slice(0, 12) : [],
}));
}
if (src?.active_form_id) seed.active_form_id = String(src.active_form_id).slice(0, 120);
if (src?.tier) seed.tier = String(src.tier).toLowerCase().startsWith('main') ? 'main' : 'supporting'; if (src?.tier) seed.tier = String(src.tier).toLowerCase().startsWith('main') ? 'main' : 'supporting';
if (src?.moral_alignment_score != null) { if (src?.moral_alignment_score != null) {
const mas = parseInt(src.moral_alignment_score, 10); const mas = parseInt(src.moral_alignment_score, 10);
@ -475,6 +488,36 @@ function csMergeAliases(existing, incoming) {
return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', '); return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', ');
} }
function csMergeForms(existing, incoming) {
const forms = Array.isArray(existing) ? existing.map(f => ({ ...f })) : [];
(Array.isArray(incoming) ? incoming : []).forEach((incomingForm, index) => {
if (!incomingForm || typeof incomingForm !== 'object') return;
const id = String(incomingForm.id || incomingForm.name || `form-${index + 1}`).slice(0, 120);
const found = forms.find(form => String(form.id || form.name || '').toLowerCase() === id.toLowerCase());
if (!found) {
forms.push({
id, name: String(incomingForm.name || id).slice(0, 160),
physical: _csStr(incomingForm.physical).slice(0, 12000),
clothing: _csStr(incomingForm.clothing).slice(0, 12000),
image_prompt: _csStr(incomingForm.image_prompt).slice(0, 8000),
concept_art_prompt: _csStr(incomingForm.concept_art_prompt).slice(0, 8000),
inventory: Array.isArray(incomingForm.inventory) ? incomingForm.inventory.filter(x => typeof x === 'string').slice(0, 12) : [],
});
return;
}
['name', 'physical', 'clothing', 'image_prompt', 'concept_art_prompt'].forEach(key => {
const value = _csStr(incomingForm[key]);
if (value.length > _csStr(found[key]).length) found[key] = value.slice(0, key === 'name' ? 160 : key.includes('prompt') ? 8000 : 12000);
});
if (Array.isArray(incomingForm.inventory)) {
incomingForm.inventory.forEach(item => {
if (typeof item === 'string' && item && !found.inventory.includes(item) && found.inventory.length < 12) found.inventory.push(item.slice(0, 300));
});
}
});
return forms.slice(0, 8);
}
function csMerge(map, sheets, opts = {}) { function csMerge(map, sheets, opts = {}) {
const changed = []; const changed = [];
for (const s of sheets) { for (const s of sheets) {
@ -486,7 +529,7 @@ function csMerge(map, sheets, opts = {}) {
// while discovering characters without a roster, but is unsafe here. // while discovering characters without a roster, but is unsafe here.
const key = opts.strictRoster ? name.toLowerCase() : csFindMergeKey(map, s); const key = opts.strictRoster ? name.toLowerCase() : csFindMergeKey(map, s);
if (!map.has(key)) { if (!map.has(key)) {
const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }; const copy = { ...s, name, forms: csMergeForms([], s.forms), inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] };
CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); }); CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); });
map.set(key, copy); map.set(key, copy);
changed.push({ key, name: copy.name, status: 'new' }); changed.push({ key, name: copy.name, status: 'new' });
@ -497,6 +540,7 @@ function csMerge(map, sheets, opts = {}) {
const oldAliases = e.aliases; const oldAliases = e.aliases;
CS_SCALAR_FIELDS.forEach(f => { const sv = _csStr(s[f]); if (sv.length > (e[f] || '').length) e[f] = sv; }); CS_SCALAR_FIELDS.forEach(f => { const sv = _csStr(s[f]); if (sv.length > (e[f] || '').length) e[f] = sv; });
e.aliases = csMergeAliases({ ...e, aliases: oldAliases }, s); e.aliases = csMergeAliases({ ...e, aliases: oldAliases }, s);
e.forms = csMergeForms(e.forms, s.forms);
if (s.tier === 'main') e.tier = 'main'; if (s.tier === 'main') e.tier = 'main';
if (s.moral_alignment_score != null) { if (s.moral_alignment_score != null) {
e.moral_alignment_score = e.moral_alignment_score != null e.moral_alignment_score = e.moral_alignment_score != null

View File

@ -1198,7 +1198,22 @@ async function _charDetailPage(rec, allChars, opts) {
const goBack = typeof opts.onBack === 'function' ? opts.onBack : libraryRenderCharacters; const goBack = typeof opts.onBack === 'function' ? opts.onBack : libraryRenderCharacters;
window._libDetailRec = rec; window._libDetailRec = rec;
const sh = rec.sheet || {}; const baseSheet = rec.sheet || {};
const characterForms = Array.isArray(baseSheet.forms) ? baseSheet.forms : [];
const activeFormId = String(baseSheet.active_form_id || '');
const activeForm = characterForms.find(function (form) { return String(form?.id || '') === activeFormId; }) || null;
const sh = Object.assign({}, baseSheet, activeForm || {});
const formSwitcherHtml = characterForms.length ? (
'<label class="lcd-form-switcher">Form / appearance '
+ '<select aria-label="Character appearance form">'
+ '<option value="">Base appearance</option>'
+ characterForms.map(function (form) {
const id = String(form?.id || '');
return '<option value="' + escHtml(id) + '"' + (id === activeFormId ? ' selected' : '') + '>'
+ escHtml(form?.name || id || 'Form') + '</option>';
}).join('')
+ '</select></label>'
) : '';
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book || '') : {}; const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book || '') : {};
const hue = _charHue(rec.name); const hue = _charHue(rec.name);
const hue2 = (hue + 40) % 360; const hue2 = (hue + 40) % 360;
@ -1225,9 +1240,10 @@ async function _charDetailPage(rec, allChars, opts) {
for (let i = 0; i < identityFingerprint.length; i++) identityHash = ((identityHash << 5) - identityHash + identityFingerprint.charCodeAt(i)) | 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 = (identityHash >>> 0).toString(16);
const avatarImage = sh.image || rec.image;
const avatarHtml = '<div class="lcd-avatar-wrap">' const avatarHtml = '<div class="lcd-avatar-wrap">'
+ (rec.image + (avatarImage
? '<div class="lcd-avatar lcd-avatar-upload" title="Profilbild öffnen"><img loading="lazy" decoding="async" src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>' ? '<div class="lcd-avatar lcd-avatar-upload" title="Profilbild öffnen"><img loading="lazy" decoding="async" src="' + avatarImage + '" alt="' + escHtml(rec.name) + '"></div>'
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Profilbild öffnen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>') : '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Profilbild öffnen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>')
+ '</div>'; + '</div>';
@ -1238,7 +1254,7 @@ async function _charDetailPage(rec, allChars, opts) {
// in the Generation Prompts section further down, or squeezed into the // in the Generation Prompts section further down, or squeezed into the
// header at thumbnail size (confirmed live: 110px was too small to read // header at thumbnail size (confirmed live: 110px was too small to read
// any actual detail in a multi-pose design sheet). // any actual detail in a multi-pose design sheet).
const visualPairNeedsReview = !!(rec.image && sh.concept_art_image && ( const visualPairNeedsReview = !!(avatarImage && sh.concept_art_image && (
sh.consistent_prompt_version !== 'v3' sh.consistent_prompt_version !== 'v3'
|| !sh.consistent_identity_fingerprint || !sh.consistent_identity_fingerprint
|| sh.consistent_identity_fingerprint !== identityFingerprintShort || sh.consistent_identity_fingerprint !== identityFingerprintShort
@ -1428,6 +1444,7 @@ async function _charDetailPage(rec, allChars, opts) {
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '') + (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '') + (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
+ '</div>' + '</div>'
+ formSwitcherHtml
+ '</div>' + '</div>'
+ '</div>' + '</div>'
+ '<div class="lcd-body">' + '<div class="lcd-body">'
@ -1533,6 +1550,16 @@ async function _charDetailPage(rec, allChars, opts) {
sidebarSearch?.addEventListener('input', refreshCharacterSidebar); sidebarSearch?.addEventListener('input', refreshCharacterSidebar);
sidebarSort?.addEventListener('change', refreshCharacterSidebar); sidebarSort?.addEventListener('change', refreshCharacterSidebar);
pg.querySelector('.lcd-form-switcher select')?.addEventListener('change', async function () {
baseSheet.active_form_id = this.value;
rec.sheet = baseSheet;
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
const all = await clGetAll().catch(() => 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);
});
pg.querySelector('.lib-cpg-back').addEventListener('click', function (e) { pg.querySelector('.lib-cpg-back').addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -3251,8 +3278,37 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
// provider (Settings > Engines > Image Generation), save the result as the // provider (Settings > Engines > Image Generation), save the result as the
// character's profile picture. Same backend route as the single "Generate" // character's profile picture. Same backend route as the single "Generate"
// button on the avatar in _charDetailPage. // button on the avatar in _charDetailPage.
function _libActiveCharacterForm(rec) {
const sheet = rec && rec.sheet && typeof rec.sheet === 'object' ? rec.sheet : {};
const forms = Array.isArray(sheet.forms) ? sheet.forms : [];
const activeId = String(sheet.active_form_id || '').trim();
return activeId ? (forms.find(function (form) { return String(form?.id || '') === activeId; }) || null) : null;
}
async function _libSaveCharacterVisual(rec, fields) {
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null;
const target = current || rec;
const form = _libActiveCharacterForm(target);
if (form) {
target.sheet = Object.assign({}, target.sheet || {});
target.sheet.forms = Array.isArray(target.sheet.forms) ? target.sheet.forms.map(function (item) {
return String(item?.id || '') === String(form.id || '') ? Object.assign({}, item, fields) : item;
}) : [];
} else {
Object.assign(target, fields);
}
target.updated = new Date();
if (typeof clPut === 'function') await clPut(target);
if (target !== rec) {
rec.image = target.image;
rec.sheet = target.sheet;
}
return target;
}
async function _charAutoGenerateImage(rec, provider) { async function _charAutoGenerateImage(rec, provider) {
const sh = rec.sheet || {}; const form = _libActiveCharacterForm(rec);
const sh = form ? Object.assign({}, rec.sheet || {}, form) : (rec.sheet || {});
const hasExplicitPrompt = !!_libStr(sh.image_prompt).trim(); const hasExplicitPrompt = !!_libStr(sh.image_prompt).trim();
if (!hasExplicitPrompt) { if (!hasExplicitPrompt) {
// Background/crowd characters with only a couple of lines (e.g. "Ruf aus // Background/crowd characters with only a couple of lines (e.g. "Ruf aus
@ -3279,8 +3335,14 @@ async function _charAutoGenerateImage(rec, provider) {
}); });
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); } if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.image); if (form) {
rec.image = d.image; 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;
}
// Bulk runs only re-render the whole grid once, at the very end — with a // 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 // ComfyUI workflow that can take a minute or more per character, that
// left the toolbar's progress counter as the only sign anything was // left the toolbar's progress counter as the only sign anything was
@ -3420,7 +3482,8 @@ async function _cijPollJob(jobId, panel) {
// facts. A legacy portrait is deliberately not reused because its hats, // facts. A legacy portrait is deliberately not reused because its hats,
// clothing, props, or scenery would be inherited by the new image pair. // clothing, props, or scenery would be inherited by the new image pair.
async function _charAutoGenerateConsistentImages(rec, visualStyle) { async function _charAutoGenerateConsistentImages(rec, visualStyle) {
const sh = rec.sheet || {}; const form = _libActiveCharacterForm(rec);
const sh = form ? Object.assign({}, rec.sheet || {}, form) : (rec.sheet || {});
const profilePrompt = _libStr(sh.image_prompt).trim(); const profilePrompt = _libStr(sh.image_prompt).trim();
const referencePrompt = _libStr(sh.concept_art_prompt).trim(); const referencePrompt = _libStr(sh.concept_art_prompt).trim();
if (!profilePrompt) throw new Error('No Character Image Prompt — generate it first'); if (!profilePrompt) throw new Error('No Character Image Prompt — generate it first');
@ -3445,15 +3508,16 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
panel.setJobId(job_id); panel.setJobId(job_id);
const d = await _cijPollJob(job_id, panel); const d = await _cijPollJob(job_id, panel);
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair'); if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile); const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', identity_consistent: d.identity_consistent !== false };
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null; if (form) {
const target = current || rec; await _libSaveCharacterVisual(rec, visualFields);
target.image = d.profile; const updatedForm = _libActiveCharacterForm(rec);
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', identity_consistent: d.identity_consistent !== false }); if (updatedForm) Object.assign(updatedForm, visualFields);
target.updated = new Date(); } else {
if (typeof clPut === 'function') await clPut(target); if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile);
rec.image = d.profile; Object.assign(rec, { image: d.profile });
rec.sheet = Object.assign({}, sh, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', consistent_identity_fingerprint: d.identity_fingerprint || '', identity_consistent: d.identity_consistent !== false }); 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) { 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) + '">'; av.innerHTML = '<img src="' + d.profile + '" alt="' + escHtml(rec.name) + '">';
}); });
@ -3467,7 +3531,8 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
// Stored inside sheet.concept_art_image (a sheet field, not the top-level // Stored inside sheet.concept_art_image (a sheet field, not the top-level
// `image` column) so it never overwrites the character's main portrait. // `image` column) so it never overwrites the character's main portrait.
async function _charAutoGenerateConceptArt(rec, provider) { async function _charAutoGenerateConceptArt(rec, provider) {
const sh = rec.sheet || {}; const form = _libActiveCharacterForm(rec);
const sh = form ? Object.assign({}, rec.sheet || {}, form) : (rec.sheet || {});
const prompt = _libStr(sh.concept_art_prompt).trim(); const prompt = _libStr(sh.concept_art_prompt).trim();
if (!prompt) throw new Error('No concept art prompt to work from yet — generate the prompt first'); if (!prompt) throw new Error('No concept art prompt to work from yet — generate the prompt first');
const body = { prompt: prompt, image_kind: 'concept_art' }; const body = { prompt: prompt, image_kind: 'concept_art' };
@ -3482,13 +3547,9 @@ async function _charAutoGenerateConceptArt(rec, provider) {
// scan can silently redirect this onto a different (alias-linked) // scan can silently redirect this onto a different (alias-linked)
// character's record instead of this one, confirmed live as a real bug // character's record instead of this one, confirmed live as a real bug
// elsewhere this session (a voice update landing on the wrong character). // elsewhere this session (a voice update landing on the wrong character).
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null; await _libSaveCharacterVisual(rec, { concept_art_image: d.image });
const target = current || rec; if (form) form.concept_art_image = d.image;
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.image }); else rec.sheet = Object.assign({}, rec.sheet || {}, { concept_art_image: d.image });
target.updated = new Date();
if (typeof clPut === 'function') await clPut(target);
sh.concept_art_image = d.image;
rec.sheet = sh;
return d.image; return d.image;
} }
@ -3497,7 +3558,8 @@ async function _charAutoGenerateConceptArt(rec, provider) {
// replaced accidentally. // replaced accidentally.
function _openConceptArtLightbox(rec, onSaved) { function _openConceptArtLightbox(rec, onSaved) {
document.getElementById('conceptart-lightbox')?.remove(); document.getElementById('conceptart-lightbox')?.remove();
const sh = rec.sheet || {}; const activeForm = _libActiveCharacterForm(rec);
const sh = activeForm ? Object.assign({}, rec.sheet || {}, activeForm) : (rec.sheet || {});
const prompt = _libStr(sh.concept_art_prompt).trim(); const prompt = _libStr(sh.concept_art_prompt).trim();
const ov = document.createElement('div'); const ov = document.createElement('div');
ov.id = 'conceptart-lightbox'; ov.id = 'conceptart-lightbox';
@ -3538,12 +3600,9 @@ function _openConceptArtLightbox(rec, onSaved) {
ov.querySelector('.conceptart-editor-preview').innerHTML = '<img class="conceptart-lightbox-img" src="' + src + '" alt="Concept art — ' + escHtml(rec.name) + '">'; ov.querySelector('.conceptart-editor-preview').innerHTML = '<img class="conceptart-lightbox-img" src="' + src + '" alt="Concept art — ' + escHtml(rec.name) + '">';
}; };
const commit = async function (dataUri) { const commit = async function (dataUri) {
const current = typeof clGet === 'function' ? await clGet(rec.id).catch(function () { return null; }) : null; await _libSaveCharacterVisual(rec, { concept_art_image: dataUri });
const target = current || rec; if (activeForm) activeForm.concept_art_image = dataUri;
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: dataUri }); else rec.sheet = Object.assign({}, rec.sheet || {}, { concept_art_image: dataUri });
target.updated = new Date();
if (typeof clPut === 'function') await clPut(target);
rec.sheet = target.sheet;
setPreview(dataUri); setPreview(dataUri);
toast('Concept art saved', 'success'); toast('Concept art saved', 'success');
if (typeof onSaved === 'function') onSaved(); if (typeof onSaved === 'function') onSaved();

View File

@ -1087,6 +1087,9 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
.lcd-avatar-online-btn { background: var(--teal, #0F766E) !important; } .lcd-avatar-online-btn { background: var(--teal, #0F766E) !important; }
.lcd-avatar-upload-btn { background: var(--mauve, #7C3AED) !important; } .lcd-avatar-upload-btn { background: var(--mauve, #7C3AED) !important; }
.lcd-header-body { flex:1; min-width:0; } .lcd-header-body { flex:1; min-width:0; }
.lcd-form-switcher { display:inline-flex; align-items:center; gap:8px; margin-top:10px; font-size:12px; font-weight:600; color:rgba(255,255,255,.9); }
.lcd-form-switcher select { max-width:190px; padding:5px 8px; border:1px solid rgba(255,255,255,.35); border-radius:6px; background:rgba(0,0,0,.22); color:#fff; }
.lcd-form-switcher option { background:var(--surface,#20202a); color:var(--text,#fff); }
.lcd-name { font-size:26px; font-weight:700; line-height:1.15; margin-bottom:5px; } .lcd-name { font-size:26px; font-weight:700; line-height:1.15; margin-bottom:5px; }
.lcd-aliases { font-size:14px; opacity:.72; margin-bottom:7px; font-style:italic; } .lcd-aliases { font-size:14px; opacity:.72; margin-bottom:7px; font-style:italic; }
.lcd-archetype { font-size:17px; opacity:.88; margin-bottom:12px; font-weight:500; } .lcd-archetype { font-size:17px; opacity:.88; margin-bottom:12px; font-weight:500; }

View File

@ -113,3 +113,26 @@ test('current-version image pairs marked inconsistent show a review warning', as
await page.locator('.lib-char-card[data-char-id="c-mismatch"]').click(); await page.locator('.lib-char-card[data-char-id="c-mismatch"]').click();
await expect(page.locator('.lcd-conceptart .note')).toContainText('konsistente Identität', { timeout: 15_000 }); await expect(page.locator('.lcd-conceptart .note')).toContainText('konsistente Identität', { timeout: 15_000 });
}); });
test('character appearance forms stay in one record and switch the detail view', async ({ page }) => {
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
await page.evaluate(() => window._charDetailPage({
id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob',
image: '/human.png', sheet: {
physical: 'human with dark hair', image_prompt: 'human portrait', concept_art_prompt: 'human reference',
forms: [{ id: 'wolf', name: 'Wolf form', physical: 'large black wolf', image_prompt: 'black wolf portrait', image: '/wolf.png', concept_art_image: '/wolf-sheet.png' }],
active_form_id: 'wolf',
},
}, [], { container: document.querySelector('#lib-chars-list') }));
await expect(page.locator('.lcd-form-switcher select')).toHaveValue('wolf');
await expect(page.locator('.lcd-avatar img')).toHaveAttribute('src', '/wolf.png');
await page.locator('.lcd-form-switcher select').evaluate(select => {
select.value = '';
select.dispatchEvent(new Event('change', { bubbles: true }));
});
await expect(page.locator('.lcd-form-switcher select')).toHaveValue('');
await expect.poll(() => page.evaluate(() => window._libDetailRec?.sheet?.active_form_id || '')).toBe('');
});

View File

@ -9,6 +9,8 @@ os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-conversation-t
from routes.conversation import ( from routes.conversation import (
_attribution_parse, _attribution_parse,
_character_form_sheet,
_character_identity_fingerprint,
_cij_check_cancelled, _cij_check_cancelled,
_cij_new, _cij_new,
_consistent_image_cancel_events, _consistent_image_cancel_events,
@ -18,6 +20,19 @@ from routes.conversation import (
class AttributionParserTests(unittest.TestCase): class AttributionParserTests(unittest.TestCase):
def test_active_character_form_overlays_visual_identity_without_changing_record(self):
sheet = {
"physical": "base form",
"image_prompt": "base prompt",
"forms": [{"id": "wolf", "name": "Wolf form", "physical": "black wolf", "image_prompt": "black wolf portrait"}],
"active_form_id": "wolf",
}
resolved = _character_form_sheet(sheet)
self.assertEqual(resolved["physical"], "black wolf")
self.assertEqual(resolved["image_prompt"], "black wolf portrait")
self.assertEqual(sheet["active_form_id"], "wolf")
self.assertNotEqual(_character_identity_fingerprint(sheet), _character_identity_fingerprint({**sheet, "active_form_id": ""}))
def test_fallback_accepts_mixed_guillemets(self): def test_fallback_accepts_mixed_guillemets(self):
result = _fallback_attribute_response("Intro. »Ein langer Satz endet hier.‹ Danach.") result = _fallback_attribute_response("Intro. »Ein langer Satz endet hier.‹ Danach.")
self.assertEqual([segment["type"] for segment in result["segments"]], ["narration", "dialogue", "narration"]) self.assertEqual([segment["type"] for segment in result["segments"]], ["narration", "dialogue", "narration"])

View File

@ -28,6 +28,15 @@ class SecurityValidationTests(unittest.TestCase):
record = {"id": "c1", "book": "Book", "name": "Name", "tags": "", "sheet": {}} record = {"id": "c1", "book": "Book", "name": "Name", "tags": "", "sheet": {}}
self.assertIs(_validate_character_record(record), record) self.assertIs(_validate_character_record(record), record)
def test_character_forms_are_bounded_and_validate_visual_fields(self):
record = {"id": "c1", "book": "Book", "name": "Name", "sheet": {
"forms": [{"id": "wolf", "name": "Wolf", "physical": "black wolf", "image": "data:image/png;base64,AA=="}],
"active_form_id": "wolf",
}}
self.assertIs(_validate_character_record(record), record)
with self.assertRaises(HTTPException):
_validate_character_record({**record, "sheet": {"forms": [{}] * 9}})
def test_private_url_is_rejected_when_downloads_are_public_only(self): def test_private_url_is_rejected_when_downloads_are_public_only(self):
with self.assertRaises(HTTPException): with self.assertRaises(HTTPException):
_validate_http_url("http://127.0.0.1:8188/image", allow_private=False) _validate_http_url("http://127.0.0.1:8188/image", allow_private=False)