Compare commits

...

1 Commits

Author SHA1 Message Date
77f4f6bcb7 fix: keep human character renders period-correct 2026-09-26 19:29:04 +02:00
9 changed files with 89 additions and 15 deletions

View File

@ -7,6 +7,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Fixed
- **Character reference sheets keep ordinary adult humans human-sized and stop inventing modern cameras.** The compact renderer now applies its explicit adult-human anatomy lock to non-dwarf adults, removes photographer wording that the image model could literalize as camera equipment, and limits scene objects to source-required equipment or ordinary period scenery. Existing image pairs are marked for review so they can be regenerated with the corrected prompt version.
---
## [1.53.4] — 2026-09-24

View File

@ -321,3 +321,38 @@ not yet built.
Ingested magazines are stored server-side, so any device on the network sees the
same library and reading progress. Playback needs the server; there is no
offline cache yet.
---
## Character portrait and reference images
### Purpose and prerequisites
Create a matched profile portrait and production reference sheet from a saved character profile. An image
generation engine must be configured and the profile should state the character's species, age/build, clothing,
and any real equipment clearly. The generated scene uses only established equipment and period scenery; a human
adult receives ordinary adult-human proportions, while an explicitly established dwarf keeps dwarf proportions.
### Step by step
1. Open **Library → Characters/Cast** and choose a character.
2. Correct the profile facts first, especially **species**, **physical appearance**, **clothing**, and **inventory**.
3. Choose the visual style, then select **Generate profile + reference together**.
4. Review the automatic image-check result and inspect the full-body views, story scene, and prop tiles. Existing
pairs made with an older prompt version show as needing review; regenerate them to apply the current rules.
**Expected result:** one consistent character identity, ordinary adult-human anatomy unless another species or age
is explicitly established, and a story scene containing only book-grounded period objects. The operation replaces
the character's current generated pair, so keep a copy externally if the old artwork matters.
### Errors, accessibility, mobile, privacy, and offline use
- If the image engine or checker is unavailable, generation reports that failure or marks the pair unverified;
correct the engine settings and retry rather than treating an unchecked image as approved.
- Generation controls are labelled buttons and remain keyboard-operable. Generated images need the surrounding
character name and status text for screen-reader context; the artwork itself is not a substitute for profile text.
- The character detail view is scrollable on phones and tablets; generation can take the same time as desktop.
- Profile text and generated images are sent to the image/checking providers configured in Settings. With local
providers they stay on the local network; hosted providers receive that content under their own privacy terms.
- The browser can remain on the local network, but generation is unavailable offline unless the configured local
image engine and checker are both running.

View File

@ -47,7 +47,7 @@ Two self-hosted Docker instances, both port 7890:
| Voice library / clone / design | `routes/library.py`, `core/voice.py` | `library.js` | mature |
| Read Aloud (books, PDFs) | `routes/reader.py` | `reader.js` | mature |
| Audiobook casting (span mode: deterministic quote spans, LLM names speakers only; verify/recast) | `routes/conversation.py`, `core/attribution_spans.py` | `audiobook*.js`, `audiobook-spans.js`, `audiobook-cast-spans.js` | **new 2026-09-19** — see [architecture/audiobook-attribution.md](architecture/audiobook-attribution.md) |
| Characters (avatar/concept identity, appearance forms) | `routes/characters.py`, `routes/conversation.py` | `library-characters.js`, `character-sheets.js` | **reworked 2026-09-19** — see [architecture/character-visual-identity.md](architecture/character-visual-identity.md) |
| Characters (avatar/concept identity, appearance forms) | `routes/characters.py`, `routes/conversation.py` | `library-characters.js`, `character-sheets.js` | **reworked 2026-09-19** — deterministic age/species proportions and period-only scene objects; see [architecture/character-visual-identity.md](architecture/character-visual-identity.md) |
| Rehearser | `routes/rehearsals_db.py` | `rehearser-*.js` | mature |
| Studio (phase wizard) | — | `studio.js` | mature |
| TTS engines / routing | `routes/tts.py`, `core/routing.py` | `engines.js` | mature |

View File

@ -1 +1 @@
1.53.4
1.53.5

View File

@ -140,7 +140,15 @@ The 4-step Qwen-Image model follows the start of a prompt and loses the tail, so
long prompt did nothing. The prompt now opens with the framing (three full-body views side by side,
head to soles, empty space above and below, plain white studio), then the character (subject capped at
700 chars), then only the rules a sheet needs, one short sentence each: leather-only armour, headwear,
elf ears (only for elves), child/teen proportions, clean skin and clean well-kept clothes.
elf ears (only for elves), age/species-specific proportions, clean skin and clean well-kept clothes. The
proportion rule is explicit for ordinary adult humans too; without it, the model's stocky fantasy-warrior
bias could turn a human into a dwarf-like figure even when `race_species` said `Mensch`.
The story panel uses medium/style terms rather than an occupational "production photographer" phrase.
That wording was interpreted literally on Glombo Brohm and introduced several modern cameras into a
medieval scene. A positive object-authority sentence now limits visible objects to source-required equipment
and ordinary scenery from the established book world, without naming forbidden modern items that the positive
text encoder might otherwise visualize.
What the validation runs found, each fixed and pinned by a test:
* **Passing states drawn as features** — "rote Stirn bei Wut, Tränen … vor Wut" became red marks (Darrag);

View File

@ -77,7 +77,7 @@ _CAST_JOBS: dict[str, dict] = {}
_CAST_JOB_IDEMPOTENCY: dict[str, tuple[str, float]] = {}
_CAST_JOB_MAX = 128
_CAST_JOB_TIMEOUT = 15 * 60
_CHARACTER_VISUAL_PROMPT_VERSION = "v3"
_CHARACTER_VISUAL_PROMPT_VERSION = "v4"
_CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
"gender", "marks", "scars", "nationality_background", "clothing", "era", "profession",
@ -2531,7 +2531,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
r"\s*;\s*(?:signature items|verified portable items)\s*:\s*.*$", "", sheet_subject,
flags=re.IGNORECASE | re.DOTALL,
).strip()
# A saved v3 Subject is the user's editable character direction. Do not
# A saved Subject is the user's editable character direction. Do not
# silently replace its carefully chosen visual role with an extracted
# profession token (for example "Vampir" or "Inquisitor"), because those
# broad labels strongly trigger generic costume stereotypes. The
@ -2701,7 +2701,11 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
sheet_style = portrait_style
else:
portrait_style = "Strict live-action photorealistic historical-fantasy production photography; real actor, honest skin, natural hair, restrained warm-neutral film color."
sheet_style = "Strict live-action photorealistic costume-continuity studio photography by that same production photographer, with a real actor and physically believable period materials."
# Avoid occupational photography language in the story panel. The
# image model took "production photographer" literally and rendered
# Glombo carrying several modern cameras instead of acting as a
# medieval siege expert.
sheet_style = "Strict live-action photorealistic historical-fantasy film still with a real actor, consistent costume continuity, and physically believable period materials."
# Nothing previously told the model what a correctly-proportioned body
# looks like, and the turnaround triptych in particular squeezes each
# full-body figure into a narrow 512-wide third of the frame — confirmed
@ -2738,7 +2742,10 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
"Bare head, the whole hairstyle visible." if bareheaded else "Only the established headwear, the same in every view.",
hair_texture_lock.strip(),
f"{ear_shape.capitalize()}." if ear_shape.startswith("pointed") else "",
proportion_lock.strip() if identical_noun != "adult person" else "",
# The ordinary adult-human lock is just as important as the special
# child/teen/dwarf locks. Omitting it left non-dwarfs vulnerable to
# the model's stocky fantasy-warrior bias.
proportion_lock.strip(),
"Clean healthy skin and clean, well-kept clothes.",
weapon_count_lock.strip(),
female_presentation_lock.strip(),
@ -2777,7 +2784,8 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
f"EXACT CHARACTER: {sheet_subject}" + (f"; exactly as in the portrait: {portrait_look}" if portrait_look else "") + ". "
f"BOOK-SOURCE EVIDENCE: {scene_evidence}. "
f"FOCAL PERSON: {_character_identity_phrase(name, sheet)}, exactly as described above, the single clear subject in the foreground. "
"Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor, with exactly one head and exactly two hands, each holding at most one item, and the same species, skin, and facial features as the turnaround sheet — never rendered as an unmarked ordinary human when a non-human species is established. Preserve the stated face, age, species, build, hair, costume, materials, and period. Every held or worn weapon is one single complete object with exactly one hilt and one blade or head, correctly proportioned and physically attached, never a duplicated or fused shape. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. "
"Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor, with exactly one head and exactly two hands, each holding at most one item, and the same species, skin, and facial features as the turnaround sheet — never rendered as an unmarked ordinary human when a non-human species is established. Preserve the stated face, age, species, build, hair, costume, materials, and period. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. "
"Every visible object belongs to the established book world and is either required by the source evidence or ordinary period scenery. "
f"{period} {sheet_style} Use a believable location from the book, restrained cinematic staging, and complete uncropped head and hands. Keep all surfaces unlettered and free of graphic overlays. Output 768x512."
)
prop_period = (

View File

@ -1296,7 +1296,7 @@ function _libDeleteForm(sheet, id) {
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
const stale = form.consistent_prompt_version !== 'v4' || !form.consistent_identity_fingerprint
|| form.consistent_identity_fingerprint !== fingerprint || form.identity_consistent === false;
return stale ? 'review' : 'ok';
}
@ -1448,7 +1448,7 @@ async function _charDetailPage(rec, allChars, opts) {
// header at thumbnail size (confirmed live: 110px was too small to read
// any actual detail in a multi-pose design sheet).
const visualPairNeedsReview = !!(avatarImage && sh.concept_art_image && (
sh.consistent_prompt_version !== 'v3'
sh.consistent_prompt_version !== 'v4'
|| !sh.consistent_identity_fingerprint
|| sh.consistent_identity_fingerprint !== identityFingerprintShort
|| sh.identity_consistent === false
@ -3822,7 +3822,7 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
panel.setJobId(job_id);
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, image_check: d.image_check || null };
const visualFields = { image: d.profile, concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v4', 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, image_check: d.image_check || null };
await _libSaveCharacterVisual(rec, visualFields);
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) + '">';

View File

@ -103,7 +103,7 @@ test('current-version image pairs marked inconsistent show a review warning', as
id: 'c-mismatch', book: 'Smoke Book', name: 'Mismatch', image: '/profile.png',
sheet: {
image_prompt: 'portrait', concept_art_prompt: 'reference', concept_art_image: '/reference.png',
consistent_prompt_version: 'v3', consistent_identity_fingerprint: 'same-current-version',
consistent_prompt_version: 'v4', consistent_identity_fingerprint: 'same-current-version',
identity_consistent: false,
},
}],
@ -200,7 +200,7 @@ test('a matched image pair for the base appearance is fully persisted', async ({
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 },
result: { profile: PNG_A, reference: PNG_A + 'AA', prompt_version: 'v4', 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'));
@ -208,7 +208,7 @@ test('a matched image pair for the base appearance is fully persisted', async ({
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.consistent_prompt_version).toBe('v4');
expect(saved.sheet.profile_asset_digest).toBe('pd');
expect(saved.sheet.reference_asset_digest).toBe('rd');
expect(saved.sheet.identity_consistent).toBe(true);
@ -283,7 +283,7 @@ test('form helpers add, rename, delete and judge image pairs', async ({ page })
// 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 };
const okForm = { id: 'f', physical: 'fur', image: 'i', concept_art_image: 'c', consistent_prompt_version: 'v4', consistent_identity_fingerprint: fp, identity_consistent: true };
out.state = {
none: window._libFormPairState(base, { id: 'f', physical: 'fur' }),
ok: window._libFormPairState(base, okForm),

View File

@ -15,6 +15,7 @@ from routes.conversation import (
_character_identity_fingerprint,
_build_character_portrait_prompt,
_build_character_sheet_prompt,
_build_compact_character_render_prompts,
_consistent_image_result_metadata,
_CHARACTER_VISUAL_PROMPT_VERSION,
_cij_check_cancelled,
@ -58,6 +59,25 @@ class AttributionParserTests(unittest.TestCase):
self.assertTrue(result["profile_asset_digest"])
self.assertTrue(result["reference_asset_digest"])
def test_glombo_stays_human_and_the_scene_does_not_cue_cameras(self):
"""Glombo rendered as a dwarf holding modern cameras despite being an adult human siege expert."""
sheet = {
"gender": "male",
"race_species": "Mensch",
"profession": "Belagerungsexperte",
"physical": "Groß gewachsener, breitschultriger Mann mit braunem Haar und Vollbart.",
"clothing": "Wolltunika, Lederwams und Köcher für Armbrustbolzen.",
}
portrait, triptych, _side, _back, scene, _props = _build_compact_character_render_prompts(
"Glombo Brohm", "Buch", sheet, {}, "cinematic", "", "",
)
for prompt in (portrait, triptych):
self.assertIn("Natural adult human body proportions", prompt)
self.assertNotIn("Natural adult dwarf body proportions", prompt)
self.assertNotIn("photographer", scene.lower())
self.assertIn("Every visible object belongs to the established book world", scene)
def test_consistent_image_persistence_keeps_live_payload_and_adds_artifact_refs(self):
from core.database import _BACKGROUND_ARTIFACT_DIR
job_id = "live-image-artifact-test"