feat: detect stale character image identity

This commit is contained in:
mARTin-B78 2026-09-04 07:59:17 +02:00
parent 94f09c110d
commit 9517cca00c
4 changed files with 28 additions and 5 deletions

View File

@ -38,6 +38,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
concurrent recognition requests from overcommitting the inference device. concurrent recognition requests from overcommitting the inference device.
- **Visual Identity Versioning.** Consistent profile/reference results now carry the standardized v3 prompt marker, - **Visual Identity Versioning.** Consistent profile/reference results now carry the standardized v3 prompt marker,
and older unmarked image pairs are visibly flagged for regeneration. and older unmarked image pairs are visibly flagged for regeneration.
- **Visual Identity Fingerprints.** Consistent pairs now record a compact fingerprint of canonical appearance fields;
changing those fields causes the existing pair to be flagged stale.
- **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL - **Security Regression Coverage.** Added a Python test gate for character metadata validation, private-download URL
rejection and oversized JSON request rejection. rejection and oversized JSON request rejection.
- **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary. - **Navigation Reliability.** Rehearser phase requests now survive the feature-module initialization boundary.

View File

@ -49,8 +49,8 @@ the first block before investing in architecture or polish.
- [ ] **M — Fix the Schwester Larissa unclosed-guillemet split.** Add a regression test - [ ] **M — Fix the Schwester Larissa unclosed-guillemet split.** Add a regression test
for the epigraph and verify the parser does not split mid-sentence. for the epigraph and verify the parser does not split mid-sentence.
- [ ] **M — Make profile portraits and character sheets use one canonical identity.** - [ ] **M — Make profile portraits and character sheets use one canonical identity.**
Shared structured prompts now version consistent pairs as v3 and flag older pairs; add a field fingerprint so Shared structured prompts now version consistent pairs as v3 and fingerprint canonical appearance fields; add
changes to age, hair, eyes, face, build, skin tone, marks or era also detect stale images. cross-record validation so mismatched profile/reference files are detected even when both are current-version.
- [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate - [ ] **M — Add bounded duplicate-generation protection.** Consistent portrait/reference jobs now deduplicate
repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs. repeated requests with `Idempotency-Key`; extend the same bounded contract to voice and casting jobs.
- [ ] **M — Improve large-table performance.** Virtualize/paginate character and voice - [ ] **M — Improve large-table performance.** Virtualize/paginate character and voice

View File

@ -63,6 +63,21 @@ _consistent_image_jobs_lock = threading.Lock()
_consistent_image_idempotency: dict[str, str] = {} _consistent_image_idempotency: dict[str, str] = {}
_consistent_image_slots = threading.BoundedSemaphore(2) _consistent_image_slots = threading.BoundedSemaphore(2)
_CHARACTER_VISUAL_PROMPT_VERSION = "v3" _CHARACTER_VISUAL_PROMPT_VERSION = "v3"
_CHARACTER_IDENTITY_FIELDS = (
"age", "age_estimate", "hair", "eyes", "physical", "build", "skin_tone", "race_species",
"gender", "marks", "scars", "nationality_background", "clothing", "era", "profession",
)
def _character_identity_fingerprint(sheet: dict) -> str:
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.
value = 0
for char in raw:
value = ((value << 5) - value + ord(char)) & 0xFFFFFFFF
return format(value, "x")
def _cij_new(job_id: str, name: str) -> None: def _cij_new(job_id: str, name: str) -> None:
@ -3126,6 +3141,7 @@ async def character_generate_consistent_images(request: Request):
result = dict(result or {}) result = dict(result or {})
result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION
result["identity_consistent"] = True result["identity_consistent"] = True
result["identity_fingerprint"] = _character_identity_fingerprint(sheet)
_cij_set(job_id, stage="done", detail="Done", done=True, result=result) _cij_set(job_id, stage="done", detail="Done", done=True, result=result)
except HTTPException as exc: except HTTPException as exc:
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail)) _cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail))

View File

@ -1202,6 +1202,11 @@ async function _charDetailPage(rec, allChars, opts) {
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' }, 'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
}; };
const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral']; 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 avatarHtml = '<div class="lcd-avatar-wrap">' const avatarHtml = '<div class="lcd-avatar-wrap">'
+ (rec.image + (rec.image
@ -1230,7 +1235,7 @@ async function _charDetailPage(rec, allChars, opts) {
+ '<span class="mdi mdi-creation"></span> ' + (sh.concept_art_image ? 'Neu generieren' : 'Generieren') + '<span class="mdi mdi-creation"></span> ' + (sh.concept_art_image ? 'Neu generieren' : 'Generieren')
+ '</button>' + '</button>'
+ '</div>' + '</div>'
+ (rec.image && sh.concept_art_image && sh.consistent_prompt_version !== 'v3' + (rec.image && sh.concept_art_image && (sh.consistent_prompt_version !== 'v3' || (sh.consistent_identity_fingerprint && sh.consistent_identity_fingerprint !== identityFingerprintShort))
? '<div class="note" style="color:var(--warning,#d97706);margin-top:6px"><span class="mdi mdi-alert-outline"></span> Dieses Bildpaar stammt aus einer älteren Prompt-Version und sollte für konsistente Identität neu erzeugt werden.</div>' ? '<div class="note" style="color:var(--warning,#d97706);margin-top:6px"><span class="mdi mdi-alert-outline"></span> Dieses Bildpaar stammt aus einer älteren Prompt-Version und sollte für konsistente Identität neu erzeugt werden.</div>'
: '') : '')
+ (sh.concept_art_image + (sh.concept_art_image
@ -3394,11 +3399,11 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null; const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null;
const target = current || rec; const target = current || rec;
target.image = d.profile; target.image = d.profile;
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', identity_consistent: d.identity_consistent !== false }); 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 });
target.updated = new Date(); target.updated = new Date();
if (typeof clPut === 'function') await clPut(target); if (typeof clPut === 'function') await clPut(target);
rec.image = d.profile; rec.image = d.profile;
rec.sheet = Object.assign({}, sh, { concept_art_image: d.reference, consistent_prompt_version: d.prompt_version || 'v3', identity_consistent: d.identity_consistent !== false }); 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 });
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) + '">';
}); });