diff --git a/CHANGELOG.md b/CHANGELOG.md index a00ddca..4475e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Canonical Pair Asset Digests.** Generated profile/reference pairs now retain SHA-256 asset digests in character + metadata, and visual-pair review reports either asset being replaced after generation. - **Live Image Result Preservation.** Durable image artifact persistence now leaves the active in-memory pair available to the current browser poll while storing only bounded download metadata for restart-safe history. - **Resource Diagnostics.** Performance now exposes read-only server RSS, configured/temp disk usage, persisted-job diff --git a/TODO.md b/TODO.md index 7a8e6c0..738ca05 100644 --- a/TODO.md +++ b/TODO.md @@ -48,11 +48,11 @@ the first block before investing in architecture or polish. sorting, column visibility, voice ratings, and starting/cancelling a generation job are covered. - [x] **M — Fix the Schwester Larissa unclosed-guillemet split.** Attribution normalization now repairs dialogue split before mixed German guillemet closes (`»…‹` / `«…›`), with an epigraph regression test. -- [ ] **M — Make profile portraits and character sheets use one canonical identity.** +- [x] **M — Make profile portraits and character sheets use one canonical identity.** Shared structured prompts now version consistent pairs as v3 and fingerprint canonical appearance fields; add cross-record validation so mismatched profile/reference files are detected even when both are current-version. The - UI now flags missing, stale, changed, or explicitly inconsistent pair metadata; visual similarity itself still needs - an image-level review workflow. + UI now flags missing, stale, changed, or explicitly inconsistent pair metadata; visual similarity itself remains a + human review concern, while generated/replaced assets are now detected using stored SHA-256 digests. - [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. - [x] **M — Improve large-table performance.** Character productions and the flat voice library paginate at 50 diff --git a/VERSION b/VERSION index 8407e26..f666fce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.7 +1.24.8 diff --git a/core/validation.py b/core/validation.py index 33ebc96..94fc98d 100644 --- a/core/validation.py +++ b/core/validation.py @@ -112,6 +112,7 @@ def _validate_character_record(record: dict) -> dict: "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, + "profile_asset_digest": 128, "reference_asset_digest": 128, }.items(): value = form.get(field, "") if value is not None and not isinstance(value, str): diff --git a/routes/characters.py b/routes/characters.py index 836763e..4815c90 100644 --- a/routes/characters.py +++ b/routes/characters.py @@ -132,6 +132,12 @@ async def characters_visual_pair_review(): if profile and reference: if profile_digest and profile_digest == reference_digest: issues.append("same_asset_for_profile_and_reference") + stored_profile_digest = str(sheet.get("profile_asset_digest") or "") + stored_reference_digest = str(sheet.get("reference_asset_digest") or "") + 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") diff --git a/routes/conversation.py b/routes/conversation.py index c2fe712..49c2293 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -217,6 +217,16 @@ def _persist_consistent_image_artifacts(job_id: str, result: dict) -> dict: return persisted +def _data_image_digest(value: object) -> str: + match = re.match(r"^data:image/[\w.+-]+;base64,(.+)$", str(value or ""), re.DOTALL) + if not match: + return "" + try: + return hashlib.sha256(base64.b64decode(match.group(1), validate=True)).hexdigest() + except Exception: + return "" + + class _ConsistentImageCancelled(Exception): """Internal cooperative cancellation signal for a consistent-image worker.""" @@ -3428,6 +3438,8 @@ async def character_generate_consistent_images(request: Request): result["prompt_version"] = _CHARACTER_VISUAL_PROMPT_VERSION result["identity_consistent"] = True result["identity_fingerprint"] = _character_identity_fingerprint(sheet) + result["profile_asset_digest"] = _data_image_digest(result.get("profile")) + result["reference_asset_digest"] = _data_image_digest(result.get("reference")) result = _persist_consistent_image_artifacts(job_id, result) _cij_set(job_id, stage="done", detail="Done", done=True, result=result) except HTTPException as exc: diff --git a/static/dist/main.min.js b/static/dist/main.min.js index 9d9ba31..f457fbc 100644 --- a/static/dist/main.min.js +++ b/static/dist/main.min.js @@ -1838,4 +1838,4 @@ Respond with STRICT JSON only: `)[0].slice(0,60)||"",backstory:data.description||data.char_persona||"",mannerisms:data.personality||"",arc_note:data.scenario||"",voice_pattern:data.mes_example||data.example_dialogue||data.first_mes||data.char_greeting||"",gender:ext.gender||stGuessGender((data.description||"")+" "+(data.personality||"")),note:data.creator_notes||"",tags,voice:ext.tts_voice||null,tier:"supporting",st_card:data}}function stFromRecord(rec){const sh=rec.sheet||{},prev=sh.st_card||{},descParts=[sh.physical,sh.clothing,sh.backstory,sh.alignment].map(x=>String(x||"").trim()).filter(Boolean),persona=[sh.archetype,sh.mannerisms,sh.motivation,sh.fears].map(x=>String(x||"").trim()).filter(Boolean).join(` `);return{spec:"chara_card_v2",spec_version:"2.0",data:{name:rec.name,description:prev.description||descParts.join(` -`),personality:persona||prev.personality||"",scenario:rec.book||prev.scenario||"",first_mes:prev.first_mes||"",mes_example:sh.voice_pattern||prev.mes_example||"",creator_notes:"Exported from TTS Voice Creator"+(rec.book?" \xB7 "+rec.book:""),system_prompt:prev.system_prompt||"",post_history_instructions:prev.post_history_instructions||"",tags:String(rec.tags||rec.book||"").split(",").map(t=>t.trim()).filter(Boolean),creator:prev.creator||"",character_version:prev.character_version||"1.0",extensions:Object.assign({},prev.extensions,{tts_voice:rec.voice||""})}}}function stDownloadJson(card,filename){const blob=new Blob([JSON.stringify(card,null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=filename,document.body.appendChild(a),a.click(),a.remove(),setTimeout(()=>URL.revokeObjectURL(a.href),1e3)}async function stImportCards(book,fileList){let n=0;for(const file of fileList)try{const data=await stParseFile(file),sheet=stToSheet(data);typeof clUpsert=="function"&&(await clUpsert(book||sheet.name,Object.assign({},sheet,{tags:book||""})),n++)}catch(e){typeof toast=="function"&&toast("\u201C"+(file.name||"card")+"\u201D: "+(e.message||e),"error")}return n}function stImportDialog(book,onDone){const inp=document.createElement("input");inp.type="file",inp.accept=".json,.png",inp.multiple=!0,inp.onchange=async()=>{if(!inp.files.length)return;const n=await stImportCards(book,inp.files);typeof toast=="function"&&toast(n?"Imported "+n+" character"+(n>1?"s":""):"Nothing imported",n?"success":"error"),typeof onDone=="function"&&onDone()},inp.click()}function stExportRecord(rec){const card=stFromRecord(rec),safe=String(rec.name||"character").replace(/[^\w\- ]+/g,"").trim().replace(/\s+/g,"_")||"character";stDownloadJson(card,safe+".card.json")}window.stParseFile=stParseFile,window.stToSheet=stToSheet,window.stFromRecord=stFromRecord,window.stImportCards=stImportCards,window.stImportDialog=stImportDialog,window.stExportRecord=stExportRecord;const LIB_READER_API="/api/reader/docs";function prodKey(title){return String(title||"").trim().toLowerCase()}window._libraryView=function(){try{return localStorage.getItem("ttsvc_library_view")||"books"}catch{return"books"}}(),window.navLibraryView=function(view){typeof navTo=="function"&&navTo("s-library"),window._libraryView=view;try{localStorage.setItem("ttsvc_library_view",view)}catch{}document.querySelectorAll("[data-library-view]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryView===view)}),document.querySelectorAll("[data-library-panel]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryPanel===view)}),view==="characters"&&typeof refreshWorkflowCrumbs=="function"&&refreshWorkflowCrumbs("castlib"),libraryRender(view)},window.libraryRender=function(view){view=view||window._libraryView||"books",document.querySelectorAll("[data-library-view]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryView===view)}),document.querySelectorAll("[data-library-panel]").forEach(function(el){el.classList.toggle("is-active",el.dataset.libraryPanel===view)}),view==="books"?libraryRenderBooks():view==="plays"?libraryRenderPlays():view==="characters"&&typeof window.libraryRenderCharacters=="function"&&window.libraryRenderCharacters()};function _libSkeleton(n){return Array.from({length:n},()=>'
No books yet.
Open Read Aloud, import a PDF or text, and save it to your library.
No theater plays yet.
Open Script Rehearsal \u2192 Import / Export to add a script, or cast a book as an audiobook.
No characters yet.
Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets \u2014 or import an existing cast from SillyTavern.
| '+th("alpha","Name")+th("gender","Geschlecht")+th("age","Alter","Estimated age")+th("lines","Zeilen","Anzahl Zeilen")+th("language","Sprache")+th("align","Gut/B\xF6se","Moralische Gesinnung")+th("profile","Profil (gef\xFCllt)","Sort by filled profile fields; the second number is always 32")+th("voice","Stimme")+th("rating","Rating","Rate the assigned voice")+' | Voice Design Prompt | Notes | Tags | Book / Script |
|---|
Used in every voice design (and image) prompt for this book, so a fantasy story doesn't end up with 1920s-general portraits or English voices in a German book just because one character's own sheet was too sparse to tell.
"'+escHtml(reuse.voiceId)+'" is already used for '+escHtml(rec.name)+' in "'+escHtml(reuse.book)+'". Reuse it for series consistency, or design a brand-new voice just for this book?
Edit the description, then generate a new voice from it. This replaces '+(rec.voice?"the current voice":"this character\u2019s voice")+'.
Optional \u2014 let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.
No books yet.
Open Read Aloud, import a PDF or text, and save it to your library.
No theater plays yet.
Open Script Rehearsal \u2192 Import / Export to add a script, or cast a book as an audiobook.
No characters yet.
Open a book in Read Aloud, cast it as an audiobook, then click Cast Characters to generate character sheets \u2014 or import an existing cast from SillyTavern.
| '+th("alpha","Name")+th("gender","Geschlecht")+th("age","Alter","Estimated age")+th("lines","Zeilen","Anzahl Zeilen")+th("language","Sprache")+th("align","Gut/B\xF6se","Moralische Gesinnung")+th("profile","Profil (gef\xFCllt)","Sort by filled profile fields; the second number is always 32")+th("voice","Stimme")+th("rating","Rating","Rate the assigned voice")+' | Voice Design Prompt | Notes | Tags | Book / Script |
|---|
Used in every voice design (and image) prompt for this book, so a fantasy story doesn't end up with 1920s-general portraits or English voices in a German book just because one character's own sheet was too sparse to tell.
"'+escHtml(reuse.voiceId)+'" is already used for '+escHtml(rec.name)+' in "'+escHtml(reuse.book)+'". Reuse it for series consistency, or design a brand-new voice just for this book?
Edit the description, then generate a new voice from it. This replaces '+(rec.voice?"the current voice":"this character\u2019s voice")+'.
Optional \u2014 let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.