diff --git a/CHANGELOG.md b/CHANGELOG.md
index 637258a..4f461ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Changed
+- **Section-scoped feature loading.** The shared module manifest now builds a 17-module foundation bundle and loads
+ Rehearser, Reader/Audiobook, Library/Characters, and Studio code only when their sections are opened. Repeated
+ navigation reuses loaded modules, while deep links load their group before activation.
- **P1 Roadmap status.** Documented the completed namespace/manifest foundations and durable background artifacts,
while keeping true lazy feature loading, responsibility-level frontend extraction, provider-health consolidation,
full workload measurements, and browser image-cache eviction explicitly open.
diff --git a/TODO.md b/TODO.md
index 9d424b2..7d7415a 100644
--- a/TODO.md
+++ b/TODO.md
@@ -20,9 +20,9 @@ the first block before investing in architecture or polish.
All P0 safety, security, data-integrity, and release-protection work is complete.
P1 implementation is active: pagination, shared voice caching, bounded decoded-audio
caching, background jobs, canonical character identity, forms, and the frontend
-namespace/manifest foundations are working and regression-tested. Remaining P1 work
-is intentionally open where it needs a real architecture change or workload
-measurement: true feature-module lazy loading, the larger Audiobook/Rehearser/style
+namespace/manifest foundations and section-scoped lazy loading are working and
+regression-tested. Remaining P1 work is intentionally open where it needs a real
+architecture change or workload measurement: the larger Audiobook/Rehearser/style
split, centralized provider-health handling, full constrained-device workloads, and
browser image/profile-cache eviction.
@@ -89,9 +89,9 @@ browser image/profile-cache eviction.
- [x] **L — Add character forms inside one character record.** `sheet.forms[]` stores bounded alternate appearances,
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.** The shared manifest and compatibility namespace
- bridge are complete foundations; responsibility-level extraction, true feature-module
- lazy loading, and the Audiobook/Rehearser/style split remain.
+- [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
+ bridge, and section-scoped lazy loading are complete foundations; responsibility-level
+ extraction and the Audiobook/Rehearser/style split remain.
### P2 — after behavior and safety stabilize
@@ -132,7 +132,9 @@ browser image/profile-cache eviction.
## P1 — Frontend performance and memory safety
-- [ ] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened.
+- [x] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened. The shared manifest
+ keeps the foundation in the core bundle and loads Reader/Audiobook, Rehearser, Library/Characters, and Studio
+ groups once on first navigation; deep links load their group before activation.
- [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device
regression covering a 250-character production.
- [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and
diff --git a/VERSION b/VERSION
index afd2e72..0f089f4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.24.15
+1.24.16
diff --git a/scripts/check-frontend.mjs b/scripts/check-frontend.mjs
index 8a39997..377d85e 100644
--- a/scripts/check-frontend.mjs
+++ b/scripts/check-frontend.mjs
@@ -7,7 +7,8 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
-const modules = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
+const manifest = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
+const modules = [...new Set([...(manifest.core || []), ...Object.values(manifest.lazy || {}).flat()])];
const seenModules = new Set();
const declarations = new Map();
const declarationPattern = /^(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
diff --git a/scripts/minify.mjs b/scripts/minify.mjs
index 239f801..da7a9af 100644
--- a/scripts/minify.mjs
+++ b/scripts/minify.mjs
@@ -18,8 +18,11 @@ const jsDir = join(root, 'static', 'js');
const outDir = join(root, 'static', 'dist');
mkdirSync(outDir, { recursive: true });
-// Must match loader.js batch C order exactly.
-const MODULES = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
+// Bundle only the foundation. Section-specific modules are loaded on demand by
+// loader.js; keeping the same manifest as the runtime prevents drift.
+const MANIFEST = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
+const MODULES = MANIFEST.core;
+if (!Array.isArray(MODULES) || !MODULES.length) throw new Error('module manifest has no core modules');
const MAIN = MODULES.map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
diff --git a/static/dist/main.min.js b/static/dist/main.min.js
index 1643905..963be76 100644
--- a/static/dist/main.min.js
+++ b/static/dist/main.min.js
@@ -1,4 +1,4 @@
-var __defProp=Object.defineProperty;var __defNormalProp=(obj,key,value)=>key in obj?__defProp(obj,key,{enumerable:!0,configurable:!0,writable:!0,value}):obj[key]=value;var __publicField=(obj,key,value)=>__defNormalProp(obj,typeof key!="symbol"?key+"":key,value);var _a,_b,_c,_d,_e,_f,_g,_h,_i,_j,_k,_l,_m,_n,_o,_p,_q,_r,_s,_t,_u,_v,_w,_x,_y,_z,_A,_B,_C,_D,_E,_F,_G,_H,_I,_J,_K,_L,_M,_N,_O,_P,_Q,_R,_S,_T,_U,_V,_W,_X,_Y,_Z,__,_$,_aa,_ba,_ca,_da,_ea,_fa,_ga,_ha,_ia,_ja,_ka,_la,_ma,_na,_oa,_pa,_qa,_ra,_sa,_ta,_ua,_va,_wa,_xa,_ya,_za,_Aa,_Ba,_Ca,_Da,_Ea,_Fa,_Ga,_Ha,_Ia,_Ja,_Ka,_La,_Ma,_Na,_Oa,_Pa,_Qa,_Ra,_Sa,_Ta,_Ua,_Va,_Wa,_Xa,_Ya,_Za,__a,_$a,_ab,_bb,_cb,_db,_eb,_fb,_gb,_hb,_ib,_jb,_kb,_lb,_mb,_nb,_ob,_pb,_qb,_rb,_sb,_tb,_ub,_vb,_wb,_xb,_yb,_zb,_Ab,_Bb,_Cb,_Db,_Eb,_Fb,_Gb,_Hb,_Ib,_Jb,_Kb,_Lb,_Mb,_Nb,_Ob,_Pb,_Qb,_Rb,_Sb,_Tb,_Ub,_Vb,_Wb,_Xb,_Yb,_Zb,__b,_$b,_ac,_bc,_cc,_dc,_ec,_fc,_gc,_hc,_ic,_jc,_kc,_lc,_mc,_nc,_oc,_pc,_qc,_rc,_sc,_tc,_uc,_vc,_wc,_xc,_yc,_zc,_Ac;(function(){"use strict";const _pickers={};function _voiceData(id){return(window._voices||[]).find(v=>v.id===id)||null}function _voiceLang(v,id){const raw=String((v==null?void 0:v.lang)||(v==null?void 0:v.language)||"").trim(),fromMeta=raw&&raw.length<=5?raw:"",fromId=String(id||"").split("_")[0]||"";return(fromMeta||fromId).toUpperCase()}function _voiceGender(v,id){const raw=String((v==null?void 0:v.gender)||(v==null?void 0:v.sex)||"").trim(),first=raw?raw.charAt(0).toUpperCase():"";if(["F","M","N"].includes(first))return first;const fromId=(String(id||"").split("_")[1]||"").charAt(0).toUpperCase();return["F","M","N"].includes(fromId)?fromId:""}function _voiceMetaLabel(id){const v=_voiceData(id);return[_voiceGender(v,id),_voiceLang(v,id)].filter(Boolean).join(" ")}function _voiceOptionLabel(id){const meta=_voiceMetaLabel(id);return meta?`${id} ${meta}`:id}const VOICE_AVATAR_ICONS={male:"mdi-face-man",female:"mdi-face-woman",neutral:"mdi-account",robot:"mdi-robot-outline",animal:"mdi-paw"},VOICE_AVATAR_COLORS={male:"#3b82f6",female:"#ec4899",neutral:"#6b7280",robot:"#0ea5e9",animal:"#f59e0b"};window.VOICE_AVATAR_ICONS=VOICE_AVATAR_ICONS,window.voiceAvatarIcon=function(avatarKey,size){const icon=VOICE_AVATAR_ICONS[avatarKey];if(!icon)return null;const s=size+"px",r=Math.round(size/2)+"px",bg=VOICE_AVATAR_COLORS[avatarKey]||"#6b7280";return``};function _avatarHtml(id,size){const v=_voiceData(id),s=size+"px",r=Math.round(size/2)+"px";if(v!=null&&v.has_picture)return``;const icon=window.voiceAvatarIcon?window.voiceAvatarIcon(v==null?void 0:v.avatar,size):null;if(icon)return icon;const lang=(v==null?void 0:v.lang)||"",color=_langColor(lang,id),init=(id||"?")[0].toUpperCase();return`${init}`}function _langColor(lang,id){const str=(lang||id||"").toLowerCase();if(str.startsWith("de"))return"#3b82f6";if(str.startsWith("en"))return"#10b981";if(str.startsWith("fr"))return"#8b5cf6";if(str.startsWith("es"))return"#f59e0b";if(str.startsWith("it"))return"#ef4444";if(str.startsWith("zh"))return"#ec4899";if(str.startsWith("ja"))return"#f97316";const palette=["#3b82f6","#10b981","#8b5cf6","#f59e0b","#ef4444","#ec4899","#06b6d4","#84cc16"];let h=0;for(let i=0;i>>0;return palette[h%palette.length]}function _flagSpan(v){return v&&v.flag?`${v.flag}`:""}function _buildItem(id,label){const v=_voiceData(id),meta=_voiceMetaLabel(id),name=id||label||"";return`
${_avatarHtml(id,26)}
${_esc(name)}
${meta?`${_esc(meta)}`:_flagSpan(v)}
@@ -875,967 +875,4 @@ This warms each voice so the engine caches its .pt and first playback is instant
`}function _checkToneStyleSupport(){const warn=$("reh-tone-warn");if(!warn)return;const txtEl=$("reh-tone-warn-txt"),b=typeof backendById=="function"?backendById(rehState.backend):null;if(!b){warn.hidden=!0;return}const all=typeof availableTtsBackends=="function"?availableTtsBackends():[],hasTone=rehState.lines.some(l=>l.type==="dialog"&&l.emotion);if(_rehBackendIsFish())txtEl&&(txtEl.innerHTML=`${escHtml(b.label)} keeps each character\u2019s voice consistent and applies tone. Per-line tones are sent as inline [tags] (e.g. [whisper], [excited], [laughing]). You can also type a custom tone like [professional broadcast tone] \u2014 S2 supports free-form descriptions. Fish-Speech S2 \u2197`),warn.hidden=!1;else if(!b.style_aware&&hasTone){const styleAware=all.find(x=>x.style_aware&&x.uses_wav)||all.find(x=>x.style_aware);txtEl&&(txtEl.innerHTML=styleAware?`
`:`${escHtml(b.label)} keeps each character\u2019s voice consistent but has weak tone control \u2014 tone picks may have little effect.`),warn.hidden=!1}else if(b.style_aware&&!b.uses_wav){const wavBackend=all.find(x=>x.uses_wav&&x.style_aware)||all.find(x=>x.uses_wav),qwenHint=/qwen|voice design|custom/i.test((b.id||"")+" "+(b.label||""))?'
Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.
`,card.addEventListener("click",()=>importImsdb(it)),frag.appendChild(card),obs.observe(card)}),grid.appendChild(frag),countEl&&(countEl.textContent=`${items.length} script${items.length!==1?"s":""}${items.length>400?" (showing 400)":""}`),_applyView()}async function importAnyWebUrl(url,label="script"){if(url=String(url||"").trim(),!url){toast("Paste a script URL first","error");return}if(!/^https?:\/\//i.test(url)){toast("URL must start with http:// or https://","error");return}urlBtn&&(urlBtn.disabled=!0),grid.innerHTML=`
`,_catalogue&&renderGrid(_searchFilter(_catalogue)),toast("Script fetch failed: "+e.message,"error")}finally{urlBtn&&(urlBtn.disabled=!1)}}async function importImsdb(it){await importAnyWebUrl(it.fetch_url,it.title||"IMSDb script")}async function _loadCatalogue(){try{const raw=localStorage.getItem(CACHE_KEY);if(raw){const cached=JSON.parse(raw);if(Date.now()-cached.tsr.json())).items||[];try{localStorage.setItem(CACHE_KEY,JSON.stringify({ts:Date.now(),items:_catalogue}))}catch{}}async function openModal(){if(modal.hidden=!1,urlInp&&(urlInp.value=""),_applyView(),!_catalogue){grid.innerHTML='
Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context. Everyone else's sheet stays as-is.
`,document.body.appendChild(ov),render();const sortSel=ov.querySelector("#ab-recast-sort");sortSel.value=sort,sortSel.addEventListener("change",()=>{sort=sortSel.value;try{localStorage.setItem("ttsvc_ab_recast_sort",sort)}catch{}render()});let _searchT=null;ov.querySelector("#ab-recast-search").addEventListener("input",e=>{clearTimeout(_searchT);const v=e.target.value;_searchT=setTimeout(()=>{filter=v,render()},120)}),ov.querySelector("#ab-recast-cancel").addEventListener("click",()=>ov.remove()),ov.querySelector("#ab-recast-select-all").addEventListener("click",e=>{const allSelected=existingChars.length>0&&existingChars.every(c=>selected.has(c.name));allSelected?selected.clear():existingChars.forEach(c=>selected.add(c.name)),e.currentTarget.textContent=allSelected?"Select all":"Select none",render()}),ov.querySelector("#ab-recast-confirm").addEventListener("click",async()=>{const names=[...ov.querySelectorAll(".ab-recast-cb:checked")].map(cb=>cb.value).filter(Boolean);if(!names.length){toast("Select at least one character","error");return}ov.remove(),typeof window.audiobookRecastSelectedCharacters=="function"?await window.audiobookRecastSelectedCharacters(names):typeof window.csForReaderSelective=="function"&&await window.csForReaderSelective(names)})}async function audiobookRecastSelectedCharacters(selectedNames){return typeof window.csForReaderSelective=="function"?window.csForReaderSelective(selectedNames):(toast("Character-sheet recast is unavailable right now","error"),null)}window.audiobookRecastSelectedCharacters=audiobookRecastSelectedCharacters;function _abScrollIntoView(el,opts){el&&(el.scrollIntoView(Object.assign({behavior:"auto"},opts)),requestAnimationFrame(()=>requestAnimationFrame(()=>{el.scrollIntoView(Object.assign({behavior:"smooth"},opts))})))}function audiobookCastView(total,llmUrl,defaultModel,isIdle=!1){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2;const panel=document.getElementById("reader-audiobook-panel");if(!panel)return;if(typeof window.navReaderView=="function")window.navReaderView("cast");else if(typeof window.showReaderView=="function")window.showReaderView("cast");else{const mainView=document.getElementById("reader-main-view");mainView&&(mainView.hidden=!0),panel.hidden=!1}panel.className="ab-castpanel-inline card",panel.style.display="",panel.style.flexDirection="",panel.style.minHeight="",panel.innerHTML=`
-
`;const AB_DEFAULT_PROMPT=`Du bist ein erfahrener Drehbuchautor und H\xF6rbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente f\xFCr Erz\xE4hler und Dialoge (w\xF6rtliche Rede) zu unterteilen.
-
-WICHTIGE DEFINITION VON DIALOG:
-Text ist NUR dann 'dialogue', wenn er explizit in Anf\xFChrungszeichen steht (z.B. \xBB...\xAB, \u201E...\u201C, "...", \xAB...\xBB, <<...>>) oder mit einem Gedankenstrich (\u2014) beginnt. ALLES ANDERE, einschlie\xDFlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erz\xE4hler) deklariert werden.
-Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erw\xE4hnt wird! (z.B. "Karyla rannte zur T\xFCr." ist Narration, KEIN Dialog).
-
-ABSATZ- UND KAPITELSTRUKTUR:
-Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitel\xFCberschrift \u2014 immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenst\xE4ndige narration-Segmente oder als Teil des umgebenden Erz\xE4hler-Segments bei; erfinde daraus keinen Dialog und l\xF6sche sie nicht aus dem rekonstruierten Text.
-
-GRAMMATIK-REGELN F\xDCR NARRATION:
-- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, fl\xFCsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").
-- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. \u2014 auch wenn direkt davor/danach Dialog steht.
-- Grammatische Probe: Wenn der Text eine Erz\xE4hler-Aussage \xDCBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tats\xE4chlich ge\xE4u\xDFerten W\xF6rter innerhalb der Anf\xFChrungszeichen sind dialogue.
-- Satzfragmente mit f\xFChrendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenst\xE4ndige Dialoge; sie geh\xF6ren als narration zum Erz\xE4hlertext.
-
-ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv \u2014 arbeite wie ein Lektor):
-1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". L\xF6se Pronomen (er/sie) IMMER zum tats\xE4chlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts.
-2. Doppelpunkt-Regel: Endet ein Erz\xE4hlersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" \u2192 der zuvor genannte Charakter spricht das n\xE4chste Zitat; "Ein anderer fragte verschlafen:" \u2192 dieser andere spricht).
-3. Nachgestellte Zuordnung: Der Erz\xE4hlersatz NACH einem Zitat verr\xE4t oft den Sprecher \u2014 auch bei unpers\xF6nlicher Formel: "\xBBWas machst du denn da?\xAB ert\xF6nte es \xFCber ihm. Karyla hatte ihren Hammer weggelegt und war her\xFCbergekommen." \u2192 Karyla sprach das Zitat.
-4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist \u2014 ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (sp\xFCrte, h\xF6rte, f\xFChlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUH\xD6RER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB"; "Ver\xE4chtlich sah sie ihn an. \xBBDas wirst du schon noch merken.\xAB" \u2192 sie spricht, und "sie" ist die zuletzt genannte Frau).
-5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" \u2192 X spricht das n\xE4chste Zitat, und Y ist der wahrscheinlichste Antwortende.
-6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab \u2014 auch \xFCber viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette l\xFCckenlos zur\xFCck zur letzten eindeutigen Nennung und f\xFChre sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.
-7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters?
-8. Namenlose Sprecher: Hat eine sprechende Person im bisherigen Text KEINEN Eigennamen, ist eine Rollenbezeichnung ein g\xFCltiger Sprecher (z.B. 'Ork', 'Nachbar', 'W\xE4chter'). Ist die Person dagegen namentlich bekannt, benutze IMMER den Eigennamen und NIE die Rollenbezeichnung \u2014 eine Rollenbezeichnung ist kein Ersatz f\xFCr einen Namen und auch kein Mittel, 'Unknown' zu vermeiden.
-9. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgef\xFChrt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
-10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln unm\xF6glich ist. Ist der EINZIGE Anhaltspunkt f\xFCr einen Namen, dass die Figur in der N\xE4he steht oder in der Szene wichtig ist, gib 'Unknown' aus statt zu raten. Ist die Person im Text namentlich bekannt, benutze den Eigennamen statt einer Rollenbezeichnung.
-11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende \u2014 die Rede geht danach unver\xE4ndert weiter, bis das tats\xE4chliche schlie\xDFende Anf\xFChrungszeichen erscheint.
-12. Stimm-Ank\xFCndigung: Erw\xE4hnt ein Erz\xE4hlersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), geh\xF6rt diese Zeile dieser Person \u2014 nicht 'Unknown'.
-13. ZUH\xD6RER-AUSSCHLUSS (negative Evidenz \u2014 sehr wichtig): Ein Name im Erz\xE4hlsatz neben einem Zitat ist H\xC4UFIG der Zuh\xF6rer, nicht der Sprecher. Schlie\xDFe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian sp\xFCrte ihre Tr\xE4nen auf seiner Brust; Marcian h\xF6rte dar\xFCber hinweg \u2192 Marcian H\xD6RT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: M\xF6gen die G\xF6tter dich sch\xFCtzen, Marcian. \u2192 Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr \xFCbrig bleibt, suche WEITER ZUR\xDCCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) \u2014 der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt f\xFCr sich allein NIEMALS 'Unknown'.
-16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER \u2014 das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." \u2192 das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13).
-17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") \u2014 niemals 'Unknown'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt.
-18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe \u2192 "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals 'Unknown', damit die Figur durchgehend dieselbe Stimme behaelt.
-19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. \xBBIch werde jetzt ...\xAB"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker 'Narrator' und type 'narration', das Zitat ein eigenes Segment mit type 'dialogue' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf \u2014 siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes 'Unknown' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergn\xFCgen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als 'Unknown' zu lassen.
-14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er \u2192 letzter Mann, sie \u2192 letzte Frau). Solche Zeilen d\xFCrfen niemals 'Unknown' bleiben.
-15. MEHRERE KANDIDATEN in der N\xE4he: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuh\xF6rer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegen\xFCber jemandem in Objekt- oder Pr\xE4positionalstellung; (c) pr\xFCfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist 'Unknown' zul\xE4ssig.
-
-F\xDCR JEDES SEGMENT GIBST DU FOLGENDES AUS:
-- speaker: 'Narrator' f\xFCr Narration/Erz\xE4hlertext, oder den EXAKTEN Namen des Charakters f\xFCr gesprochene Dialoge.
-- type: 'narration' oder 'dialogue'
-- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' BEH\xC4LTST du die umschlie\xDFenden Anf\xFChrungszeichen als Teil des Texts (z.B. \xBBHallo!\xAB bleibt \xBBHallo!\xAB) \u2014 entferne sie NICHT und lasse niemals nur eines der beiden \xFCbrig.
-- emotion: Bei Dialogen 1-2 deutsche W\xF6rter, die den Tonfall beschreiben (z.B. w\xFCtend, fl\xFCsternd, \xE4ngstlich). Bei Narration leer lassen ('').
-
-STRIKTE FORMAT- UND TEXTREGELN:
-- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (\xBBNein\xAB, sagte sie, \xBBhalt.\xAB), erstelle 3 Segmente: dialogue ("\xBBNein\xAB"), narration (", sagte sie, "), dialogue ("\xBBhalt.\xAB").
-- \xBBText?\xAB und \xBBText!\xAB sind vollst\xE4ndige Dialoge \u2014 das ?\xAB bzw. !\xAB schlie\xDFt das Zitat ab, auch wenn es ungewohnt aussieht.
-- PDF-/OCR-SCHUTZ: Wenn ein \xBB oder \xAB offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schlie\xDFe ein offenes \xBB-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("fl\xFCsterte er", "sagte sie", "rief Uriens") oder normale Erz\xE4hlerhandlung weitergeht.
-- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und h\xE4nge das schlie\xDFende \xAB an dessen Ende an, statt es als eigenes Segment stehen zu lassen \u2014 ein Anf\xFChrungszeichen darf NIE ein eigenes Segment f\xFCr sich bilden.
-- Nur wenn ein offenes \xBB wirklich am Ende des Auszugs steht und danach KEINE Erz\xE4hlerhandlung/Inquit-Formel mehr folgt, behandle den Text ab \xBB bis Textende als 'dialogue'.
-- Anf\xFChrungszeichen geh\xF6ren IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schlie\xDFende \xAB am Ende einer Figurenrede geh\xF6rt ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das \xF6ffnende \xBB am Anfang einer Figurenrede geh\xF6rt an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen \xBB, \u201E, \u201A oder \u203A beginnen oder enden, und NIE mit einem einzelnen \xAB, ", ' oder \u2039 enden oder beginnen \u2014 verschiebe das Zeichen ins richtige Nachbar-Segment.
-- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!`,normalizeCastingPrompt=prompt=>{let p=prompt||AB_DEFAULT_PROMPT;if(p=p.replace("- Wenn ein Auszug mit einem offenen \xBB-Zitat endet (kein schlie\xDFendes \xAB), behandle den Text ab \xBB bis Textende als 'dialogue'.",`- PDF-/OCR-SCHUTZ: Wenn ein \xBB oder \xAB offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schlie\xDFe ein offenes \xBB-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("fl\xFCsterte er", "sagte sie", "rief Uriens") oder normale Erz\xE4hlerhandlung weitergeht.
-- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und entferne das einzelne \xAB aus dem ausgegebenen Text.
-- Nur wenn ein offenes \xBB wirklich am Ende des Auszugs steht und danach KEINE Erz\xE4hlerhandlung/Inquit-Formel mehr folgt, behandle den Text ab \xBB bis Textende als 'dialogue'.`),p=p.replace("- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen.","- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen vollst\xE4ndig (nie nur ein einzelnes \xBB oder \xAB stehen lassen)."),p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren!","- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren \u2014 abgesehen von entfernten \xE4u\xDFeren Dialog-Anf\xFChrungszeichen!"),p=p.replace("- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschlie\xDFenden Anf\xFChrungszeichen vollst\xE4ndig (nie nur ein einzelnes \xBB oder \xAB stehen lassen).","- text: Der EXAKTE, wortw\xF6rtliche Text aus dem Auszug. Bei 'dialogue' BEH\xC4LTST du die umschlie\xDFenden Anf\xFChrungszeichen als Teil des Texts (z.B. \xBBHallo!\xAB bleibt \xBBHallo!\xAB) \u2014 entferne sie NICHT und lasse niemals nur eines der beiden \xFCbrig."),p=p.replace('- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und entferne das einzelne \xAB aus dem ausgegebenen Text.','- Wenn nur ein schlie\xDFendes \xAB nach einem kurzen Satz steht (z.B. "Der Tod tr\xE4gt rot. \xAB"), behandle den Satz davor als Dialog und h\xE4nge das schlie\xDFende \xAB an dessen Ende an, statt es als eigenes Segment stehen zu lassen \u2014 ein Anf\xFChrungszeichen darf NIE ein eigenes Segment f\xFCr sich bilden.'),p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren \u2014 abgesehen von entfernten \xE4u\xDFeren Dialog-Anf\xFChrungszeichen!","- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!"),/Doppelpunkt-Regel/.test(p)||(p=p.replace(`ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv):
-1. Direkte Zuordnung: Achte auf W\xF6rter wie "sagte [Name]", "fragte er", "rief sie". L\xF6se Pronomen (er/sie) zum tats\xE4chlichen Namen auf.
-2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausf\xFChrt und direkt davor/danach w\xF6rtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB").
-3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette l\xFCckenlos zur\xFCck zur letzten eindeutigen Nennung.
-4. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt die Aussage zum Wissen oder Tonfall eines bestimmten Charakters?
-5. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgef\xFChrt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
-6. Unbekannte Sprecher: Nur wenn eine Zuordnung durch Kontext absolut nicht m\xF6glich ist, verwende 'Unknown'. Rate nicht blind, aber bevorzuge immer einen namentlich genannten Charakter gegen\xFCber 'Unknown'.`,`ANALYSE-REGELN F\xDCR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv \u2014 arbeite wie ein Lektor):
-1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". L\xF6se Pronomen (er/sie) IMMER zum tats\xE4chlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts.
-2. Doppelpunkt-Regel: Endet ein Erz\xE4hlersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" \u2192 der zuvor genannte Charakter spricht das n\xE4chste Zitat; "Ein anderer fragte verschlafen:" \u2192 dieser andere spricht).
-3. Nachgestellte Zuordnung: Der Erz\xE4hlersatz NACH einem Zitat verr\xE4t oft den Sprecher \u2014 auch bei unpers\xF6nlicher Formel: "\xBBWas machst du denn da?\xAB ert\xF6nte es \xFCber ihm. Karyla hatte ihren Hammer weggelegt und war her\xFCbergekommen." \u2192 Karyla sprach das Zitat.
-4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist \u2014 ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (sp\xFCrte, h\xF6rte, f\xFChlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUH\xD6RER, NICHT den Sprecher (z.B. "Thomas trat ans Fenster. \xBBEs regnet.\xAB"; "Ver\xE4chtlich sah sie ihn an. \xBBDas wirst du schon noch merken.\xAB" \u2192 sie spricht, und "sie" ist die zuletzt genannte Frau).
-5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" \u2192 X spricht das n\xE4chste Zitat, und Y ist der wahrscheinlichste Antwortende.
-6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab \u2014 auch \xFCber viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette l\xFCckenlos zur\xFCck zur letzten eindeutigen Nennung und f\xFChre sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.
-7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters?
-8. Namenlose Sprecher: Hat eine sprechende Person im bisherigen Text KEINEN Eigennamen, ist eine Rollenbezeichnung ein g\xFCltiger Sprecher (z.B. 'Ork', 'Nachbar', 'W\xE4chter'). Ist die Person dagegen namentlich bekannt, benutze IMMER den Eigennamen und NIE die Rollenbezeichnung \u2014 eine Rollenbezeichnung ist kein Ersatz f\xFCr einen Namen und auch kein Mittel, 'Unknown' zu vermeiden.
-9. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgef\xFChrt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
-10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln unm\xF6glich ist. Ist der EINZIGE Anhaltspunkt f\xFCr einen Namen, dass die Figur in der N\xE4he steht oder in der Szene wichtig ist, gib 'Unknown' aus statt zu raten. Ist die Person im Text namentlich bekannt, benutze den Eigennamen statt einer Rollenbezeichnung.`)),/GRAMMATIK-REGELN FÜR NARRATION/.test(p)||(p=p.replace('Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erw\xE4hnt wird! (z.B. "Karyla rannte zur T\xFCr." ist Narration, KEIN Dialog).',`Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erw\xE4hnt wird! (z.B. "Karyla rannte zur T\xFCr." ist Narration, KEIN Dialog).
-
-GRAMMATIK-REGELN F\xDCR NARRATION:
-- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, fl\xFCsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").
-- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. \u2014 auch wenn direkt davor/danach Dialog steht.
-- Grammatische Probe: Wenn der Text eine Erz\xE4hler-Aussage \xDCBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tats\xE4chlich ge\xE4u\xDFerten W\xF6rter innerhalb der Anf\xFChrungszeichen sind dialogue.
-- Satzfragmente mit f\xFChrendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenst\xE4ndige Dialoge; sie geh\xF6ren als narration zum Erz\xE4hlertext.`)),/ABSATZ- UND KAPITELSTRUKTUR/.test(p)||(p=p.replace('Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erw\xE4hnt wird! (z.B. "Karyla rannte zur T\xFCr." ist Narration, KEIN Dialog).',`Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erw\xE4hnt wird! (z.B. "Karyla rannte zur T\xFCr." ist Narration, KEIN Dialog).
-
-ABSATZ- UND KAPITELSTRUKTUR:
-Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitel\xFCberschrift \u2014 immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenst\xE4ndige narration-Segmente oder als Teil des umgebenden Erz\xE4hler-Segments bei; erfinde daraus keinen Dialog und l\xF6sche sie nicht aus dem rekonstruierten Text.`)),!/Gedankenstrich-Pause-Regel/.test(p)||!/ZUHÖRER-AUSSCHLUSS/.test(p)||!/FORTSETZUNGS-REGEL/.test(p)||!/EPITHETA/.test(p)){const newRules=`11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende \u2014 die Rede geht danach unver\xE4ndert weiter, bis das tats\xE4chliche schlie\xDFende Anf\xFChrungszeichen erscheint.
-12. Stimm-Ank\xFCndigung: Erw\xE4hnt ein Erz\xE4hlersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), geh\xF6rt diese Zeile dieser Person \u2014 nicht 'Unknown'.
-13. ZUH\xD6RER-AUSSCHLUSS (negative Evidenz \u2014 sehr wichtig): Ein Name im Erz\xE4hlsatz neben einem Zitat ist H\xC4UFIG der Zuh\xF6rer, nicht der Sprecher. Schlie\xDFe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian sp\xFCrte ihre Tr\xE4nen auf seiner Brust; Marcian h\xF6rte dar\xFCber hinweg \u2192 Marcian H\xD6RT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: M\xF6gen die G\xF6tter dich sch\xFCtzen, Marcian. \u2192 Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr \xFCbrig bleibt, suche WEITER ZUR\xDCCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) \u2014 der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt f\xFCr sich allein NIEMALS 'Unknown'.
-16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER \u2014 das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." \u2192 das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13).
-17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") \u2014 niemals 'Unknown'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt.
-18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe \u2192 "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals 'Unknown', damit die Figur durchgehend dieselbe Stimme behaelt.
-19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. \xBBIch werde jetzt ...\xAB"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker 'Narrator' und type 'narration', das Zitat ein eigenes Segment mit type 'dialogue' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf \u2014 siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes 'Unknown' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergn\xFCgen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als 'Unknown' zu lassen.
-14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er \u2192 letzter Mann, sie \u2192 letzte Frau). Solche Zeilen d\xFCrfen niemals 'Unknown' bleiben.
-15. MEHRERE KANDIDATEN in der N\xE4he: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuh\xF6rer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegen\xFCber jemandem in Objekt- oder Pr\xE4positionalstellung; (c) pr\xFCfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist 'Unknown' zul\xE4ssig.`,unknownRuleRe=/(\n\d+\. 'Unknown' NUR,[^\n]*'Unknown'\.)/;unknownRuleRe.test(p)?p=p.replace(unknownRuleRe,`$1
-${newRules}`):p+=`
-${newRules}`}return/gehören IMMER zum Dialog-Segment/.test(p)||(p=p.replace("- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!",`- Anf\xFChrungszeichen geh\xF6ren IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schlie\xDFende \xAB am Ende einer Figurenrede geh\xF6rt ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das \xF6ffnende \xBB am Anfang einer Figurenrede geh\xF6rt an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen \xBB, \u201E, \u201A oder \u203A beginnen oder enden, und NIE mit einem einzelnen \xAB, ", ' oder \u2039 enden oder beginnen \u2014 verschiebe das Zeichen ins richtige Nachbar-Segment.
-- Lasse NIEMALS W\xF6rter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und l\xFCckenlos Wort f\xFCr Wort rekonstruieren, EINSCHLIESSLICH der Anf\xFChrungszeichen!`)),p},rawPrompt=typeof _appSettings!="undefined"&&_appSettings.audiobook_prompt?_appSettings.audiobook_prompt:AB_DEFAULT_PROMPT,globalPrompt=normalizeCastingPrompt(rawPrompt);globalPrompt!==rawPrompt&&typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=globalPrompt,fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:globalPrompt})}).catch(()=>{})),panel.querySelector("#ab-cv-prompt-text").value=globalPrompt;const closePanel=()=>{if(panel.hidden=!0,panel.innerHTML="",typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1),typeof window.navReaderView=="function")window.navReaderView("main");else if(typeof window.showReaderView=="function")window.showReaderView("main");else{const mv=document.getElementById("reader-main-view");mv&&(mv.hidden=!1)}},setStoppingState=()=>{const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.disabled=!0,cancelBtn.innerHTML=' Stopping...',cancelBtn.title="Stopping the current casting request");const status2=panel.querySelector("#ab-cv-status-msg");status2&&(status2.style.display="inline-block",status2.textContent="Stopping... completed passages will stay saved.")};panel.querySelector("#ab-cv-cancel").addEventListener("click",()=>{if(_audiobook.running){_audiobook.cancel=!0,setStoppingState(),typeof _audiobook.abort=="function"&&_audiobook.abort();return}closePanel()}),panel.querySelector("#ab-cv-prompt-btn").addEventListener("click",()=>{const p=panel.querySelector("#ab-cv-prompt-panel");p.hidden=!p.hidden;const chevron=panel.querySelector("#ab-cv-prompt-chevron");chevron&&(chevron.className=p.hidden?"mdi mdi-chevron-down":"mdi mdi-chevron-up")}),function(){const tBtn=panel.querySelector("#ab-cv-settings-toggle"),sBox=panel.querySelector("#ab-cv-settings"),chev=panel.querySelector("#ab-cv-settings-chevron");function apply(collapsed2){if(sBox&&(sBox.style.display=collapsed2?"none":"flex"),collapsed2){const p=panel.querySelector("#ab-cv-prompt-panel");p&&(p.hidden=!0)}chev&&(chev.className="mdi "+(collapsed2?"mdi-chevron-down":"mdi-chevron-up"))}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_settings_collapsed")==="1"}catch{}apply(collapsed),tBtn&&tBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_settings_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}(),function(){const side=panel.querySelector("#ab-cv-side"),body=panel.querySelector(".ab-cv-body"),cBtn=panel.querySelector("#ab-cv-side-collapse");function apply(collapsed2){side&&side.classList.toggle("is-collapsed",collapsed2),body&&body.classList.toggle("side-collapsed",collapsed2),cBtn&&(cBtn.querySelector(".mdi").className="mdi "+(collapsed2?"mdi-chevron-left":"mdi-chevron-right")),cBtn&&(cBtn.title=collapsed2?"Expand character list":"Collapse to avatars")}let collapsed=!1;try{collapsed=localStorage.getItem("ttsvc_ab_side_collapsed")==="1"}catch{}apply(collapsed),cBtn&&cBtn.addEventListener("click",()=>{collapsed=!collapsed;try{localStorage.setItem("ttsvc_ab_side_collapsed",collapsed?"1":"0")}catch{}apply(collapsed)})}();let savedPrompts=[];try{savedPrompts=JSON.parse(localStorage.getItem("ttsvc_ab_prompts")||"[]")}catch{savedPrompts=[]}const libSelect=panel.querySelector("#ab-cv-prompt-lib"),delBtn=panel.querySelector("#ab-cv-prompt-del"),promptText=panel.querySelector("#ab-cv-prompt-text"),renderPromptLib=(selectedIdx=-1)=>{libSelect.innerHTML=''+savedPrompts.map((p,i)=>``).join(""),selectedIdx>=0?(libSelect.value=selectedIdx,delBtn.style.display="block"):(libSelect.value="",delBtn.style.display="none")};renderPromptLib(),libSelect.addEventListener("change",()=>{const idx=parseInt(libSelect.value),nameInput=panel.querySelector("#ab-cv-prompt-name");!isNaN(idx)&&savedPrompts[idx]?(promptText.value=savedPrompts[idx].prompt,nameInput&&(nameInput.value=savedPrompts[idx].name),delBtn.style.display="block"):(nameInput&&(nameInput.value=""),delBtn.style.display="none")}),delBtn.addEventListener("click",()=>{const idx=parseInt(libSelect.value);isNaN(idx)||confirm("Delete this saved prompt preset?")&&(savedPrompts.splice(idx,1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(),toast("Prompt deleted","success"))}),panel.querySelector("#ab-cv-prompt-save").addEventListener("click",async()=>{const val=promptText.value.trim();if(!val){toast("Prompt is empty","error");return}const name=panel.querySelector("#ab-cv-prompt-name").value.trim()||"Custom Prompt "+(savedPrompts.length+1);let targetIdx=parseInt(libSelect.value);!isNaN(targetIdx)&&savedPrompts[targetIdx]&&savedPrompts[targetIdx].name===name?savedPrompts[targetIdx].prompt=val:(savedPrompts.push({name,prompt:val}),targetIdx=savedPrompts.length-1),localStorage.setItem("ttsvc_ab_prompts",JSON.stringify(savedPrompts)),renderPromptLib(targetIdx),toast("Prompt preset saved","success"),typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=val);try{await fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:val})})}catch{}}),(()=>{const localSel=panel.querySelector("#ab-cv-llm-url"),globalSel=document.getElementById("llm-active-url");if(globalSel&&localSel){const currentVal=localSel.value;localSel.innerHTML=globalSel.innerHTML,localSel.value=currentVal||globalSel.value||""}})();const loadModels=()=>{const urlInput2=panel.querySelector("#ab-cv-llm-url"),sel=panel.querySelector("#ab-cv-llm-select");if(!sel||!urlInput2)return;const currentUrl=urlInput2.value.trim(),oldVal=sel.value;sel.innerHTML='',fetch("/api/conversation/llm-models"+(currentUrl?"?url="+encodeURIComponent(currentUrl):"")).then(r=>r.json()).then(d=>{if(d.models&&d.models.length){const preferred=audiobookSafeLlmModel(oldVal||defaultModel);sel.innerHTML=d.models.map(m=>``).join(""),preferred&&d.models.includes(preferred)?sel.value=preferred:oldVal&&d.models.includes(oldVal)&&!audiobookIsRouterModel(oldVal)?sel.value=oldVal:d.models.includes(defaultModel)&&(sel.value=defaultModel)}else sel.innerHTML=``}).catch(()=>{sel.innerHTML=``})};loadModels(),panel.querySelector("#ab-cv-llm-refresh").addEventListener("click",loadModels);const urlInput=panel.querySelector("#ab-cv-llm-url");urlInput&&urlInput.addEventListener("change",loadModels);const applyPromptAndRun=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{callback&&callback(savedChoice.url,savedChoice.model)})};if(isIdle){const hasSegs=_audiobook.segments&&_audiobook.segments.length>0;if(panel.querySelector("#ab-cv-status-msg").style.display="inline-block",hasSegs){const recastUnkBtn=panel.querySelector("#ab-cv-start-recast-unk"),recastBtn=panel.querySelector("#ab-cv-start-recast");recastUnkBtn.style.display="inline-block",recastBtn.style.display="inline-block",recastUnkBtn.addEventListener("click",()=>applyPromptAndRun(audiobookRecastUnknown)),recastBtn.addEventListener("click",async()=>{await confirmDialog("Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.",{title:"Discard current cast?",okLabel:"Start from scratch",danger:!0})&&applyPromptAndRun(audiobookCast)}),panel.querySelector("#ab-cv-status-msg").textContent="Ready to identify characters."}else{const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>applyPromptAndRun(audiobookCast))}}else panel.querySelector("#ab-cv-cancel").innerHTML=' Stop Casting';const fill=panel.querySelector("#ab-cv-fill"),count=panel.querySelector("#ab-cv-count"),feed=panel.querySelector("#ab-cv-feed"),chars=panel.querySelector("#ab-cv-chars"),roster=new Map,characterRecords=new Map,identityNames=recOrSheet=>{const s=(recOrSheet==null?void 0:recOrSheet.sheet)||recOrSheet||{},out=new Set,add=(v,opts={})=>clSplitIdentityTokens(v,opts).forEach(x=>out.add(x));return add((recOrSheet==null?void 0:recOrSheet.name)||s.name),["aliases","first_name","last_name","full_name","title"].forEach(k=>add(s[k],{aliases:k==="aliases"})),[...out]};let _hlVer=0,_hlCache={ver:-1,rosterSize:-1,regex:null,byLower:new Map};const registerCharacterRecord=rec=>{if(rec!=null&&rec.name){_hlVer++;for(const n of identityNames(rec))characterRecords.set(n.toLowerCase(),rec)}},recordForName=name=>characterRecords.get(String(name||"").toLowerCase())||null,colorFor=(name,rec)=>{const key=String(name||"").toLowerCase(),stored=rec||characterRecords.get(key),color=stored?_abRecordColor(stored,name):_abDefaultCharacterColor(name);return roster.has(name)?roster.get(name).color=color:roster.set(name,{count:0,color}),roster.get(name).color},_abAvatarHtml=(name,color)=>{var _a3;const img=(_a3=recordForName(name))==null?void 0:_a3.image,c=color||colorFor(name);return img?``:`${escHtml((name||"?")[0].toUpperCase())}`},setCharacterColor=(name,color)=>{const safe=_abNormalizeColor(color,name),info=roster.get(name);info&&(info.color=safe);const key=String(name||"").toLowerCase(),rec=characterRecords.get(key);return rec&&(rec.color=safe,rec.sheet&&(rec.sheet.color=safe)),safe},highlightText2=text=>{if(!text)return"";let html=escHtml(text);if(_hlCache.ver!==_hlVer||_hlCache.rosterSize!==roster.size){const extraNames=[];new Set([...characterRecords.values()]).forEach(rec=>extraNames.push(...identityNames(rec)));const seen=new Set,names=[];for(const n of[...roster.keys(),...extraNames]){const k=n.toLowerCase();n.length<2||k==="narrator"||/^unknown|unbekannt/i.test(k)||seen.has(k)||(seen.add(k),names.push(n))}names.sort((a,b)=>b.length-a.length);const pattern=names.map(n=>n.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("|");_hlCache={ver:_hlVer,rosterSize:roster.size,regex:pattern?new RegExp(`\\b(${pattern})\\b`,"gi"):null,byLower:new Map(names.map(n=>[n.toLowerCase(),n]))}}return _hlCache.regex&&(html=html.replace(_hlCache.regex,match=>{const canon=_hlCache.byLower.get(match.toLowerCase())||match;return`${match}`})),html},feedWrap=panel.querySelector(".ab-cv-feed-wrap"),jumpBtn=(_a2=panel.querySelector("#ab-cv-quotes"))==null?void 0:_a2.addEventListener("click",()=>audiobookToggleQuotes());{const _qb2=panel.querySelector("#ab-cv-quotes");_qb2&&(_qb2.classList.toggle("is-on",_abShowQuotes),_qb2.title=_abShowQuotes?"Hide quotation marks":"Show quotation marks")}panel.querySelector("#ab-cv-jump-btn");const _abTopbar=document.createElement("div");_abTopbar.className="ab-cv-topbar",feedWrap.insertBefore(_abTopbar,feedWrap.firstChild);const _abBar=document.createElement("div");_abBar.className="ab-char-bar",_abBar.hidden=!0,_abTopbar.appendChild(_abBar);const _abEditToolbar=document.createElement("div");_abEditToolbar.className="ab-edit-toolbar",_abEditToolbar.innerHTML=`
-
- `,_abTopbar.appendChild(_abEditToolbar);const _abPageNav=document.createElement("div");_abPageNav.className="ab-page-nav",_abPageNav.hidden=!0,_abPageNav.innerHTML=`
-
-
-
- `,_abTopbar.appendChild(_abPageNav);const _abFontCtl=document.createElement("div");_abFontCtl.className="ab-font-ctl",_abFontCtl.innerHTML=`
-
-
- `,_abTopbar.appendChild(_abFontCtl);const _abReviewCtl=document.createElement("div");_abReviewCtl.className="ab-review-ctl",_abReviewCtl.id="ab-cv-review",_abReviewCtl.hidden=!0,_abReviewCtl.innerHTML=`
-
- review
-
- `,_abTopbar.insertBefore(_abReviewCtl,_abTopbar.firstChild);{const revBox=panel.querySelector("#ab-cv-review"),revCount=panel.querySelector("#ab-cv-review-count"),revLevel=panel.querySelector("#ab-cv-review-level"),uncertainRows=()=>{const limit=parseInt((revLevel==null?void 0:revLevel.value)||"96",10)||96;return[...panel.querySelectorAll(".ab-cv-row")].filter(r=>{var _a3;if((revLevel==null?void 0:revLevel.value)==="wrong")return r.dataset.mark==="wrong";if(r.dataset.mark)return!1;const c=parseInt((_a3=r.dataset.conf)!=null?_a3:"",10);return!isNaN(c)&&c{if(!revBox)return;const rows=uncertainRows(),total2=panel.querySelectorAll(".ab-cv-row[data-conf]").length;revBox.hidden=total2===0;const ok=panel.querySelectorAll('.ab-cv-row[data-mark="ok"]').length,bad=panel.querySelectorAll('.ab-cv-row[data-mark="wrong"]').length,tail=ok||bad?` \xB7 ${ok}\u2713 ${bad}\u2717`:` of ${total2}`;revCount&&(revCount.textContent=rows.length?`${rows.length} to check${tail}`:ok||bad?`all clear${tail}`:`all ${total2} look confident`)};let cursor=-1;const goTo=dir=>{var _a3;const rows=uncertainRows();if(!rows.length){toast("No lines below the review threshold","success");return}const mid=(_a3=panel.querySelector(".ab-cv-feed"))==null?void 0:_a3.getBoundingClientRect();if(mid){const firstBelow=rows.findIndex(r=>r.getBoundingClientRect().top>mid.top+4);dir>0?cursor=firstBelow===-1?rows.length-1:firstBelow-1:cursor=firstBelow===-1?rows.length:firstBelow}cursor=dir>0?Math.min(cursor+1,rows.length-1):Math.max(cursor-1,0);const row=rows[cursor];row&&(_abScrollIntoView(row,{block:"center"}),panel.querySelectorAll(".ab-cv-row.ab-review-focus").forEach(r=>r.classList.remove("ab-review-focus")),row.classList.add("ab-review-focus"),revCount&&(revCount.textContent=`${cursor+1} / ${rows.length} to check`))};(_b2=panel.querySelector("#ab-cv-review-next"))==null||_b2.addEventListener("click",()=>goTo(1)),(_c2=panel.querySelector("#ab-cv-review-prev"))==null||_c2.addEventListener("click",()=>goTo(-1)),revLevel==null||revLevel.addEventListener("change",()=>{cursor=-1,refreshReview()});const _abTopSpeakers=()=>{const c=new Map;for(const sg of _audiobook.segments||[]){if((sg==null?void 0:sg.type)!=="dialogue")continue;const n=String(sg.speaker||"").trim();!n||/^(unknown|unbekannt|narrator)$/i.test(n)||c.set(n,(c.get(n)||0)+1)}return[...c.entries()].sort((a,b)=>b[1]-a[1]).map(x=>x[0])},markChecked=(row,mark="ok")=>{const sg=row&&row.__seg;if(!sg)return;sg.confMark=mark||null,sg.confChecked=mark==="ok",mark?row.dataset.mark=mark:delete row.dataset.mark;const b=row.querySelector(".ab-cv-conf");b&&(b.outerHTML=_abConfBadgeHtml(sg));const cb=row.querySelector(".ab-cv-check");cb&&(cb.outerHTML=_abCheckBtnHtml(sg))},assignSpeaker=(row,name)=>{const sg=row&&row.__seg;if(!sg)return;sg.speaker=name,sg.type=/^narrator$/i.test(name)?"narration":"dialogue",sg.type==="narration"&&(sg.emotion="");const idx=(_audiobook.segments||[]).indexOf(sg);if(idx>=0){const{score,why}=_abSegmentConfidence(_audiobook.segments,idx),band=_abCalibrateConfidence(score);sg.conf=band.pct,sg.confLabel=band.label,sg.confRaw=score,sg.confWhy=why.join("; "),row.dataset.conf=String(sg.conf)}const spkEl=row.querySelector(".ab-cv-spk");if(spkEl){const{speakerName}=_abRowSpeaker(sg);spkEl.textContent=speakerName,spkEl.style.color=colorFor(speakerName)}row.classList.toggle("is-narr",sg.type==="narration")},persist=()=>{_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0),refreshReview()};document.addEventListener("keydown",e=>{if(!panel.isConnected||panel.offsetParent===null)return;const t=e.target;if(t&&(t.tagName==="INPUT"||t.tagName==="TEXTAREA"||t.tagName==="SELECT"||t.isContentEditable)||e.ctrlKey||e.metaKey||e.altKey)return;const focused=panel.querySelector(".ab-cv-row.ab-review-focus");if(!(e.key.startsWith("Arrow")&&!focused&&e.key!=="ArrowRight")){if(e.key==="ArrowRight"||e.key==="F2"){e.preventDefault(),goTo(e.shiftKey?-1:1);return}if(e.key==="ArrowLeft"){e.preventDefault(),goTo(-1);return}if(focused){if(e.key==="ArrowUp"||e.key==="ArrowDown"){e.preventDefault(),markChecked(focused,e.key==="ArrowUp"?"ok":"wrong"),persist(),goTo(1);return}if(/^[0-9n]$/i.test(e.key)){e.preventDefault();const name=/n/i.test(e.key)||e.key==="0"?"Narrator":_abTopSpeakers()[parseInt(e.key,10)-1];if(!name){toast(`No character #${e.key} in the cast`,"error");return}assignSpeaker(focused,name),markChecked(focused,"ok"),persist(),toast(`\u2192 ${name}`,"success"),goTo(1);return}/^u$/i.test(e.key)&&(e.preventDefault(),markChecked(focused,null),persist())}}}),panel._abRefreshReview=refreshReview,setTimeout(refreshReview,0)}{const _qb2=_abFontCtl.querySelector("#ab-cv-quotes");_qb2&&(_qb2.addEventListener("click",()=>audiobookToggleQuotes()),_qb2.classList.toggle("is-on",_abShowQuotes),_qb2.title=_abShowQuotes?"Hide quotation marks":"Show quotation marks")}const _abApplyFont=()=>{let v=1;try{v=parseFloat(localStorage.getItem("ttsvc_reh_stage_scale"))||1}catch{}feed.style.setProperty("--reh-stage-scale",String(v))},_abFontStep=d=>{let v=1;try{v=parseFloat(localStorage.getItem("ttsvc_reh_stage_scale"))||1}catch{}v=Math.round(Math.min(2,Math.max(.7,v+d))*100)/100;try{localStorage.setItem("ttsvc_reh_stage_scale",String(v))}catch{}_abApplyFont(),typeof rehApplyStageFont=="function"&&rehApplyStageFont()};_abFontCtl.querySelector(".ab-font-dec").addEventListener("click",()=>_abFontStep(-.1)),_abFontCtl.querySelector(".ab-font-inc").addEventListener("click",()=>_abFontStep(.1)),_abApplyFont();let _abCharRows=[],_abNavIdx=0,_abDetailEl=null,_abCurrentPageNum=1;const _abClearHL=()=>{feed.querySelectorAll(".ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-hl","ab-char-focus"))},_abCloseProfile=()=>{_abDetailEl&&(_abDetailEl.remove(),_abDetailEl=null),feed.style.display="",jumpBtn&&typeof _userScrolled!="undefined"&&(jumpBtn.hidden=!_userScrolled),_abCharRows.forEach(r=>r.classList.add("ab-char-hl"));const btn=_abBar.querySelector(".ab-char-bar-profile");btn&&(btn.innerHTML=' Profil')},_abCloseBar=()=>{_abBar.hidden=!0,_abBar.innerHTML="",_abCloseProfile(),_abClearHL(),_abCharRows=[],chars.querySelectorAll(".ab-char-item").forEach(el=>el.classList.remove("is-active")),typeof _abSyncTopbar=="function"&&_abSyncTopbar()},_abNav=(dir,pool)=>{const rows=pool||_abCharRows;if(!rows.length)return;_abNavIdx=(_abNavIdx+dir+rows.length)%rows.length,feed.querySelectorAll(".ab-cv-row.ab-char-focus").forEach(r=>r.classList.remove("ab-char-focus")),rows[_abNavIdx].classList.add("ab-char-focus"),_abScrollIntoView(rows[_abNavIdx],{block:"center"});const pos=_abBar.querySelector(".ab-char-bar-pos");pos&&(pos.textContent=_abNavIdx+1+" / "+rows.length)},_abRefreshCharacterColors=name=>{const names=name?[name]:[...roster.keys()];for(const n of names)colorFor(n);feed.querySelectorAll(".ab-cv-row").forEach(row=>{const seg=row.__seg;if(!seg)return;const speakerName=seg.type!=="dialogue"||!seg.speaker||seg.speaker.toLowerCase()==="narrator"?"Narrator":seg.speaker,c=colorFor(speakerName),spk=row.querySelector(".ab-cv-spk"),txt=row.querySelector(".ab-cv-txt");spk&&(!name||speakerName.toLowerCase()===name.toLowerCase())&&(spk.style.color=c),txt&&(txt.innerHTML=_abMarkQuotes(highlightText2(_abDisplayText(seg))))}),renderRoster();const selected=_abBar.hidden?null:_abBar.dataset.charName;if(selected){const dot=_abBar.querySelector(".ab-char-bar-dot");dot&&(dot.style.background=colorFor(selected))}},_abShowProfile=async name=>{var _a3,_b3,_c3,_d3,_e3,_f3,_g3;_abCloseProfile(),feed.style.display="none",jumpBtn&&(jumpBtn.hidden=!0);const profileBtn=_abBar.querySelector(".ab-char-bar-profile");profileBtn&&(profileBtn.innerHTML=' Skript');const title=((_a3=window.readerState)==null?void 0:_a3.title)||"";let rec=null;try{const all=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(title):typeof clGetAll=="function"?await clGetAll():[];for(const r of all||[])registerCharacterRecord(r);rec=recordForName(name)}catch{}const detail=document.createElement("div");if(detail.className="ab-char-detail-panel",_abDetailEl=detail,!rec){detail.innerHTML='
Kein Charakterblatt \u2013 zuerst \u201ECast all character roles\u201C ausf\xFChren.
',feedWrap.appendChild(detail),detail.querySelector(".ab-char-detail-back").addEventListener("click",_abCloseProfile);return}registerCharacterRecord(rec);const sh=rec.sheet||{},charColor=setCharacterColor(rec.name,rec.color||sh.color||colorFor(rec.name,rec)),accent2=clHslToHex((clNameHue(rec.name)+40)%360,58,30),tier=String(sh.tier||"").toLowerCase(),tierLabel=tier==="main"?"Hauptcharakter":tier==="supporting"?"Nebencharakter":"Nebenfigur",voiceId=rec.voice?typeof rec.voice=="object"?rec.voice.id||"":String(rec.voice):"",lineCount=_abStr(sh.line_count),pct=((_b3=rec.sheet)==null?void 0:_b3.moral_alignment_score)!=null?Math.max(0,Math.min(100,rec.sheet.moral_alignment_score)):null,arcMap={"good-to-bad":"\u2198 Entwicklung zum B\xF6sen","bad-to-good":"\u2197 Wandel zum Guten",complex:"\u2195 Komplex","stable-good":"\u2192 Stabil gut","stable-bad":"\u2192 Stabil b\xF6se",neutral:"\u2192 Neutral"},avatarHtml=rec.image?`
${field("Vorname",sh.first_name,"first_name")}${field("Nachname",sh.last_name,"last_name")}${field("Geschlecht",sh.gender,"gender")}${field("Titel",sh.title,"title")}${field("Beruf / Rolle",sh.profession,"profession")}${field("Auch bekannt als",sh.aliases,"aliases")}
`,document.body.appendChild(el);const rect=anchorEl.getBoundingClientRect();el.style.left=Math.min(rect.left,window.innerWidth-280)+"px",el.style.top=Math.min(rect.bottom+4,window.innerHeight-340)+"px";const inp=el.querySelector(".ab-alias-popup-inp");setTimeout(()=>inp.focus(),30);const otherByLower=new Map(otherNames.map(([n])=>[n.toLowerCase(),n])),doSave=async mergeName=>{var _a3,_b3;const alias=mergeName||inp.value.trim();if(!alias){_abCloseAliasPopup();return}const mergeTarget=otherByLower.get(alias.toLowerCase());if(_abCloseAliasPopup(),mergeTarget){const busy=_abShowBusyOverlay(`Merging "${mergeTarget}" into ${name}\u2026`,!0);await new Promise(r=>requestAnimationFrame(r));try{const book=((_a3=window.readerState)==null?void 0:_a3.title)||"",rec=await clUpsert(book,{name,aliases:alias});rec&&(registerCharacterRecord(rec),_hlCache&&(_hlCache.ver=-1));const{changed:n,segs}=_abMergeCharacters(mergeTarget,name);n&&(await _abPatchScatteredRows(segs,(done,total2)=>busy.setProgress(done,total2))||await _abRedrawSegmentsChunked(_abActiveSegments().arr,(done,total2)=>busy.setProgress(done,total2)),_abPersistManualEdit()),toast(n?`Merged "${mergeTarget}" into ${name} (${n} line${n!==1?"s":""})`:`"${alias}" added as an alias for ${name}`,"success")}catch(err){toast("Could not merge: "+(err.message||err),"error")}finally{busy.remove()}}else try{const book=((_b3=window.readerState)==null?void 0:_b3.title)||"",rec=await clUpsert(book,{name,aliases:alias});rec&&(registerCharacterRecord(rec),_hlCache&&(_hlCache.ver=-1)),renderRoster(),toast(`"${alias}" added as an alias for ${name}`,"success")}catch(err){toast("Could not save alias: "+(err.message||err),"error")}};el.querySelector(".ab-alias-save").addEventListener("click",()=>doSave()),el.querySelector(".ab-alias-cancel").addEventListener("click",()=>_abCloseAliasPopup()),el.querySelectorAll(".ab-alias-merge-opt").forEach(btn=>{btn.addEventListener("click",()=>doSave(btn.dataset.name))}),inp.addEventListener("keydown",e=>{e.key==="Enter"?(e.preventDefault(),doSave()):e.key==="Escape"&&(e.preventDefault(),_abCloseAliasPopup())}),setTimeout(()=>{document.addEventListener("click",function onDoc(e){!el.contains(e.target)&&e.target!==anchorEl&&(_abCloseAliasPopup(),document.removeEventListener("click",onDoc))})},0),_abAliasPopup=el}function _abMergeCharacters(fromName,intoName){const active=_abActiveSegments();if(!active.arr.length)return{changed:0,arr:active.arr,segs:[]};_abPushEditState(active.key,active.arr);let changed=0;const segs=[];for(const s of active.arr)s.type==="dialogue"&&s.speaker&&s.speaker.toLowerCase()===fromName.toLowerCase()&&(s.speaker=intoName,changed++,segs.push(s));return{changed,arr:active.arr,segs}}(async()=>{var _a3;const title=((_a3=window.readerState)==null?void 0:_a3.title)||"";try{const records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(title):typeof clGetAll=="function"?await clGetAll():[];for(const rec of records||[])if(rec!=null&&rec.name){registerCharacterRecord(rec);for(const alias of identityNames(rec)){const rosterName=[...roster.keys()].find(n=>n.toLowerCase()===alias.toLowerCase());rosterName&&setCharacterColor(rosterName,_abRecordColor(rec,rec.name))}}_abRefreshCharacterColors()}catch{}})();const MAXROWS=Number.POSITIVE_INFINITY;let _userScrolled=!1;feed.addEventListener("scroll",()=>{var _a3;const atBottom=feed.scrollTop+feed.clientHeight>=feed.scrollHeight-80;_userScrolled=!atBottom,jumpBtn&&(jumpBtn.hidden=atBottom);const labels=Array.from(feed.querySelectorAll(".ab-cv-page-label[data-page]"));let current=_abCurrentPageNum;for(const label of labels){const r=(_a3=label.closest(".ab-cv-page"))==null?void 0:_a3.getBoundingClientRect(),fr=feed.getBoundingClientRect();if(r&&r.top<=fr.top+80)current=Number(label.dataset.page)||current;else break}current&&_abSetCurrentPage(current)},{passive:!0});const _scrollToBottom=()=>{feed.scrollTop=feed.scrollHeight};jumpBtn&&jumpBtn.addEventListener("click",()=>{_userScrolled=!1,jumpBtn.hidden=!0,_scrollToBottom()});const trim=()=>{if(!_userScrolled){if(Number.isFinite(MAXROWS)){let rows=feed.querySelectorAll(".ab-cv-row, .ab-cv-note, .ab-cv-divider");for(;rows.length>MAXROWS;){const first=rows[0],page=first.closest(".ab-cv-page");first.remove(),page&&!page.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider")&&page.remove(),rows=feed.querySelectorAll(".ab-cv-row, .ab-cv-note, .ab-cv-divider")}}_scrollToBottom()}};let _abCurPage=null;const _abJumpToReaderPage=async pageNum=>{const pg=parseInt(pageNum,10);if(pg){if(typeof window.readerJumpToPage=="function"){await window.readerJumpToPage(pg);return}typeof navTo=="function"&&navTo("s-reader"),typeof window.navReaderView=="function"?window.navReaderView("main"):typeof window.showReaderView=="function"&&window.showReaderView("main"),setTimeout(async()=>{var _a3,_b3,_c3;const pageDiv=(_c3=(_b3=(_a3=window.readerState)==null?void 0:_a3.pages)==null?void 0:_b3[pg-1])==null?void 0:_c3.pageDiv;pageDiv?(typeof window.readerRenderPage=="function"&&await window.readerRenderPage(pg-1),_abScrollIntoView(pageDiv,{block:"start",inline:"nearest"})):typeof toast=="function"&&toast("Source page is not loaded in Reader yet","info")},250)}},_abNewPage=(label,pageNum)=>{_abCurPage&&!_abCurPage.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider")&&_abCurPage.remove();const p=document.createElement("div");if(p.className="ab-cv-page",label){const pageAttr=pageNum?` data-page="${escHtml(String(pageNum))}"`:"";p.innerHTML=``}return feed.appendChild(p),_abCurPage=p,pageNum&&(_abSetCurrentPage(pageNum),_abUpdatePageNav()),p},_abPage=()=>_abCurPage||_abNewPage(),_abPageNumbers=()=>{var _a3,_b3,_c3;const set=new Set,readerPages=((_a3=window.readerState)==null?void 0:_a3.mode)==="pdf"&&((_c3=(_b3=window.readerState)==null?void 0:_b3.pages)==null?void 0:_c3.length)||0;if(readerPages>0)for(let i=1;i<=readerPages;i++)set.add(i);else(_audiobook.pageMarks||[]).forEach(m=>set.add((m.page||0)+1));return feed.querySelectorAll(".ab-cv-page-label[data-page]").forEach(el=>{const n=parseInt(el.dataset.page,10);n&&set.add(n)}),[...set].sort((a,b)=>a-b)},_abSetCurrentPage=pageNum=>{const pg=parseInt(pageNum,10);if(!pg)return;_abCurrentPageNum=pg;const sel=_abPageNav.querySelector(".ab-page-select");sel&&[...sel.options].some(o=>Number(o.value)===pg)&&(sel.value=String(pg))},_abUpdatePageNav=()=>{const pages=_abPageNumbers(),sel=_abPageNav.querySelector(".ab-page-select");if(!sel||!pages.length){_abPageNav.hidden=!0,typeof _abSyncTopbar=="function"&&_abSyncTopbar();return}const old=Number(sel.value)||_abCurrentPageNum||pages[0];sel.innerHTML=pages.map(n=>``).join(""),_abPageNav.hidden=pages.length<=1,_abSetCurrentPage(pages.includes(old)?old:pages[0]),typeof _abSyncTopbar=="function"&&_abSyncTopbar()},_abScrollToCastPage=pageNum=>{const pg=parseInt(pageNum,10);if(!pg)return;let label=feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`);if(!label){const active=_abActiveSegments();active.arr.length&&_abRedrawSegments(active.arr),label=feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`)}if(pg===1){_userScrolled=!0,jumpBtn&&(jumpBtn.hidden=!1);const firstPage=(label==null?void 0:label.closest(".ab-cv-page"))||feed.firstElementChild;firstPage?_abScrollIntoView(firstPage,{block:"start"}):feed.scrollTo({top:0,behavior:"smooth"}),_abSetCurrentPage(pg);return}if(!label){toast("This cast page is not available yet. Use the book button to jump to the source page.","info"),_abSetCurrentPage(pg);return}_userScrolled=!0,jumpBtn&&(jumpBtn.hidden=!1),_abScrollIntoView(label.closest(".ab-cv-page"),{block:"start"}),_abSetCurrentPage(pg)},_abStepPage=dir=>{var _a3;const pages=_abPageNumbers();if(!pages.length)return;const current=Number((_a3=_abPageNav.querySelector(".ab-page-select"))==null?void 0:_a3.value)||_abCurrentPageNum||pages[0],idx=Math.max(0,pages.indexOf(current)),next=pages[Math.max(0,Math.min(pages.length-1,idx+dir))];_abScrollToCastPage(next)};(_d2=_abPageNav.querySelector(".ab-page-select"))==null||_d2.addEventListener("change",e=>_abScrollToCastPage(e.target.value)),(_e2=_abPageNav.querySelector(".ab-page-prev"))==null||_e2.addEventListener("click",()=>_abStepPage(-1)),(_f2=_abPageNav.querySelector(".ab-page-next"))==null||_f2.addEventListener("click",()=>_abStepPage(1)),(_g2=_abPageNav.querySelector(".ab-page-source"))==null||_g2.addEventListener("click",()=>{var _a3;const pg=Number((_a3=_abPageNav.querySelector(".ab-page-select"))==null?void 0:_a3.value)||_abCurrentPageNum;_abJumpToReaderPage(pg)});const _abUndoStack=[],_abRedoStack=[],_abHistoryLimit=40,_abSyncTopbar=()=>{const hasSelection=!_abBar.hidden,hasHistory=_abUndoStack.length>0||_abRedoStack.length>0;_abEditToolbar.hidden=!hasHistory,_abTopbar.hidden=!1},_abActiveSegments=()=>_audiobook.running&&Array.isArray(_audiobook.liveSegments)?{key:"liveSegments",arr:_audiobook.liveSegments}:Array.isArray(_audiobook.segments)?{key:"segments",arr:_audiobook.segments}:Array.isArray(_audiobook.liveSegments)?{key:"liveSegments",arr:_audiobook.liveSegments}:{key:"segments",arr:[]},_abCloneSegments=segs=>(segs||[]).map(s=>({...s})),_abSyncRosterList=segs=>{const names=[];for(const s of segs||[]){const sp=(s==null?void 0:s.type)==="dialogue"&&s.speaker?s.speaker:"";!sp||/^Narrator$/i.test(sp)||/^Unknown|Unbekannt/i.test(sp)||names.some(n=>n.toLowerCase()===sp.toLowerCase())||names.push(sp)}_audiobook.roster=names},_abPersistManualEdit=()=>{const active=_abActiveSegments();if(_audiobook.lastText){const done=_audiobook.completedChunks||active.arr.length,total2=_audiobook.completedTotal||done;setTimeout(()=>_abSaveDraft(active.arr||[],_audiobook.roster||[],_audiobook.lastText,done,total2),50)}_audiobookDebouncedSave()},_abUpdateEditButtons=()=>{const undo=_abEditToolbar.querySelector(".ab-edit-undo"),redo=_abEditToolbar.querySelector(".ab-edit-redo");undo&&(undo.disabled=!_abUndoStack.length),redo&&(redo.disabled=!_abRedoStack.length),_abSyncTopbar()},_abPushEditState=(keyOverride,arrOverride)=>{const active=arrOverride?{key:keyOverride||"segments",arr:arrOverride}:_abActiveSegments();if(!active.arr.length)return!1;for(_abUndoStack.push({key:active.key,segments:_abCloneSegments(active.arr)});_abUndoStack.length>_abHistoryLimit;)_abUndoStack.shift();return _abRedoStack.length=0,_abUpdateEditButtons(),!0},_abRowSpeaker=s=>{const isNarrator=s.type!=="dialogue"||!s.speaker||String(s.speaker).toLowerCase()==="narrator";return{isNarrator,speakerName:isNarrator?"Narrator":s.speaker}},_abIsHeadingSeg=s=>{if((s==null?void 0:s.type)==="dialogue")return!1;const t=String((s==null?void 0:s.text)||"").trim();return!t||t.length>60?!1:/^(prolog(ue)?|epilog(ue)?|kapitel|chapter|teil|buch|part|book|akt|szene|scene)\b/i.test(t)||/^\d+[\.\)]?\s*(kapitel|chapter)?$/i.test(t)?!0:t.split(/\s+/).length<=7&&!/[.!?;:,«»"]/.test(t)},_abRowFromSegment=(s,extraClass="")=>{const{isNarrator,speakerName}=_abRowSpeaker(s),c=colorFor(speakerName),row=document.createElement("div");return row.className=`ab-cv-row${extraClass?" "+extraClass:""}${isNarrator?" is-narr":""}${_abIsHeadingSeg(s)?" is-heading":""}`,row.__seg=s,row.innerHTML=`
-
-
-
-
- ${escHtml(speakerName)}${s.emotion?' ('+escHtml(s.emotion)+")":""}
- ${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)}
- ${_abMarkQuotes(highlightText2(_abDisplayText(s)))}`,typeof s.conf=="number"&&(row.dataset.conf=String(s.conf)),s.confChecked&&(row.dataset.checked="1"),row},_abPatchScatteredRows=(segs,onProgress)=>new Promise(resolve=>{if(!segs||!segs.length){resolve(!0);return}const bySeg=new Set(segs),targets=[...feed.querySelectorAll(".ab-cv-row")].filter(r=>r.__seg&&bySeg.has(r.__seg));if(!targets.length){resolve(!1);return}let i=0;const step=()=>{const end=Math.min(i+150,targets.length);for(;i{const rows=(oldRows||[]).filter(Boolean);if(!rows.length)return!1;const parent=rows[0].parentNode;if(!parent||rows.some(r=>r.parentNode!==parent))return!1;const beforeCounts=_abRosterCountsFromSegs(rows.map(r=>r.__seg).filter(Boolean)),afterCounts=_abRosterCountsFromSegs(newSegs||[]),frag=document.createDocumentFragment();for(const seg of newSegs||[])frag.appendChild(_abRowFromSegment(seg));parent.insertBefore(frag,rows[0]);for(const row of rows)row.remove();return _abApplyRosterDelta(beforeCounts,afterCounts),_abQueueRosterRender(),_abClearHL(),_abUpdatePageNav(),!0},_abAdjacentRow=(row,dir)=>{let cur=dir<0?row==null?void 0:row.previousElementSibling:row==null?void 0:row.nextElementSibling;for(;cur&&!(cur.classList&&cur.classList.contains("ab-cv-row"));)cur=dir<0?cur.previousElementSibling:cur.nextElementSibling;return cur||null},_abStartRowEdit=row=>{if(!row||!row.__seg||row.querySelector(".ab-cv-edit-ta"))return;const s=row.__seg,txtEl=row.querySelector(".ab-cv-txt");if(!txtEl)return;const ta=document.createElement("textarea");ta.className="ab-cv-edit-ta",ta.value=s.text||"",txtEl.replaceWith(ta),ta.focus(),ta.setSelectionRange(ta.value.length,ta.value.length);const commit=save=>{if(save){const val=ta.value;val!==s.text&&(_abPushEditState(),s.text=val,_abPersistManualEdit())}ta.replaceWith(txtEl),txtEl.innerHTML=_abMarkQuotes(highlightText2(_abDisplayText(s)))};ta.addEventListener("keydown",e=>{e.key==="Enter"&&(e.ctrlKey||e.metaKey)?(e.preventDefault(),commit(!0)):e.key==="Escape"&&(e.preventDefault(),commit(!1))}),ta.addEventListener("blur",()=>commit(!0))},_abRosterCountsFromSegs=segs=>{const counts=new Map;for(const s of segs||[]){const{speakerName}=_abRowSpeaker(s||{});counts.set(speakerName,(counts.get(speakerName)||0)+1)}return counts},_abApplyRosterCounts=counts=>{for(const info of roster.values())info.count=0;for(const[name,count2]of counts||[]){const info=roster.get(name)||{count:0,color:colorFor(name)};info.count=count2,roster.set(name,info)}_audiobook.roster=[...roster.entries()].filter(([n,info])=>info.count>0&&!/^Narrator$/i.test(n)&&!/^Unknown|Unbekannt/i.test(n)).map(([n])=>n)},_abApplyRosterDelta=(beforeCounts,afterCounts)=>{const names=new Set([...(beforeCounts||new Map).keys(),...(afterCounts||new Map).keys()]);for(const name of names){const before=(beforeCounts==null?void 0:beforeCounts.get(name))||0,after=(afterCounts==null?void 0:afterCounts.get(name))||0;if(before===after)continue;const info=roster.get(name)||{count:0,color:colorFor(name)};info.count=Math.max(0,(info.count||0)+(after-before)),roster.set(name,info)}_audiobook.roster=[...roster.entries()].filter(([n,info])=>info.count>0&&!/^Narrator$/i.test(n)&&!/^Unknown|Unbekannt/i.test(n)).map(([n])=>n)};let _abRosterRenderRaf=0;const _abQueueRosterRender=()=>{_abRosterRenderRaf||(_abRosterRenderRaf=requestAnimationFrame(()=>{_abRosterRenderRaf=0,renderRoster()}))},_abRecountRoster=segs=>{_abApplyRosterCounts(_abRosterCountsFromSegs(segs||[])),_abQueueRosterRender()},_abRedrawSegments=segs=>{const selectedChar=!_abBar.hidden&&_abBar.dataset.charName?_abBar.dataset.charName:"";feed.innerHTML="",_abCurPage=null;let lastPage=null;for(const s of segs||[])s.page!=null&&s.page!==lastPage&&(_abNewPage("Page "+s.page,s.page),lastPage=s.page),_abPage().appendChild(_abRowFromSegment(s));_abRecountRoster(segs||[]),_abClearHL(),_abUpdatePageNav(),selectedChar&&(_abBar.hidden=!0,_abSelectChar(selectedChar))};let _abRedrawGen=0;const _abRedrawSegmentsChunked=(segs,onProgress,batchSize=150)=>new Promise(resolve=>{const selectedChar=!_abBar.hidden&&_abBar.dataset.charName?_abBar.dataset.charName:"",myGen=++_abRedrawGen;feed.innerHTML="",_abCurPage=null;const list=segs||[];let i=0,lastPage=null;const step=()=>{if(myGen!==_abRedrawGen){resolve();return}const end=Math.min(i+batchSize,list.length);for(;i{if(!snap)return;const restored=_abCloneSegments(snap.segments);snap.key==="liveSegments"?_audiobook.liveSegments=restored:_audiobook.segments=restored,_abRedrawSegments(restored),_abPersistManualEdit()},_abUndoEdit=()=>{const active=_abActiveSegments();!_abUndoStack.length||!active.arr.length||(_abRedoStack.push({key:active.key,segments:_abCloneSegments(active.arr)}),_abRestoreEditState(_abUndoStack.pop()),_abUpdateEditButtons())},_abRedoEdit=()=>{const active=_abActiveSegments();!_abRedoStack.length||!active.arr.length||(_abUndoStack.push({key:active.key,segments:_abCloneSegments(active.arr)}),_abRestoreEditState(_abRedoStack.pop()),_abUpdateEditButtons())};(_h2=_abEditToolbar.querySelector(".ab-edit-undo"))==null||_h2.addEventListener("click",_abUndoEdit),(_i2=_abEditToolbar.querySelector(".ab-edit-redo"))==null||_i2.addEventListener("click",_abRedoEdit),_abSyncTopbar();let assignModeSeg=null,assignModeRow=null,assignPopup=document.getElementById("ab-cv-assign-popup");if(!assignPopup){assignPopup=document.createElement("div"),assignPopup.id="ab-cv-assign-popup",assignPopup.style.cssText="position:fixed; z-index:2001; display:none; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.4); padding:10px; width:220px; max-height:320px; flex-direction:column; gap:8px;";const header=document.createElement("div");header.style.cssText="display:flex; justify-content:space-between; align-items:center; margin-bottom:-4px; margin-top:-4px;",header.innerHTML='Assign to',assignPopup.appendChild(header);const inp=document.createElement("input");inp.type="text",inp.placeholder="Search or add character...",inp.style.cssText="width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);",assignPopup.appendChild(inp);const list=document.createElement("div");list.className="ab-cv-popup-list",list.style.cssText="display:flex; flex-direction:column; gap:2px; overflow-y:auto; max-height:220px; margin-right:-4px; padding-right:4px;",assignPopup.appendChild(list),document.body.appendChild(assignPopup),header.querySelector(".mdi-close").addEventListener("click",()=>{var _a3;return(_a3=assignPopup._close)==null?void 0:_a3.call(assignPopup)}),document.addEventListener("mousedown",e=>{var _a3;assignPopup.style.display!=="none"&&!assignPopup.contains(e.target)&&!e.target.closest(".ab-cv-spk")&&!e.target.closest(".ab-cv-txt")&&((_a3=assignPopup._close)==null||_a3.call(assignPopup))}),document.addEventListener("keydown",e=>{var _a3;e.key==="Escape"&&assignPopup.style.display!=="none"&&((_a3=assignPopup._close)==null||_a3.call(assignPopup))}),inp.addEventListener("keydown",e=>{var _a3,_b3;if(e.key==="Enter"){const addBtn=assignPopup.querySelector(".ab-cv-popup-add-btn"),visibleBtns=Array.from(assignPopup.querySelectorAll(".ab-cv-popup-list > div[data-name]")).filter(b=>b.style.display!=="none");if(addBtn&&addBtn.style.display!=="none")addBtn.click();else if(visibleBtns.length===1)visibleBtns[0].click();else{const val=inp.value.trim();val&&((_a3=assignPopup._assignName)==null||_a3.call(assignPopup,val))}}else e.key==="Escape"&&((_b3=assignPopup._close)==null||_b3.call(assignPopup))}),inp.addEventListener("input",()=>{const val=inp.value.trim().toLowerCase(),listItems=assignPopup.querySelectorAll(".ab-cv-popup-list > div[data-name]");let exactMatch=!1;listItems.forEach(item=>{const name=item.dataset.name.toLowerCase();name===val&&(exactMatch=!0),name.includes(val)?item.style.display="flex":item.style.display="none"});let addBtn=assignPopup.querySelector(".ab-cv-popup-add-btn");val&&!exactMatch&&val!=="narrator"?(addBtn||(addBtn=document.createElement("div"),addBtn.className="ab-cv-popup-add-btn",addBtn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--primary); border-top:1px solid var(--border); margin-top:4px; padding-top:8px;",addBtn.onmouseover=()=>addBtn.style.background="var(--panel)",addBtn.onmouseout=()=>addBtn.style.background="transparent"),addBtn.innerHTML=`+ Add "${escHtml(inp.value.trim())}"`,addBtn.onclick=()=>{var _a3;return(_a3=assignPopup._assignName)==null?void 0:_a3.call(assignPopup,inp.value.trim())},addBtn.style.display="flex",assignPopup.querySelector(".ab-cv-popup-list").appendChild(addBtn)):addBtn&&(addBtn.style.display="none")})}function closeAssignPopup(){assignPopup.style.display="none",assignModeRow&&assignModeRow.classList.remove("is-assigning"),assignModeSeg=null,assignModeRow=null}function assignName(name){if(!assignModeSeg)return;const active=_abActiveSegments();_abPushEditState(active.key,active.arr);const isNarrator=name.toLowerCase()==="narrator",_oldSpk=assignModeSeg.speaker;if(_oldSpk&&roster.has(_oldSpk)){const _old=roster.get(_oldSpk);_old.count>0&&_old.count--}assignModeSeg.speaker=isNarrator?"Narrator":name,assignModeSeg.type=isNarrator?"narration":"dialogue",isNarrator&&(assignModeSeg.emotion=""),assignModeRow.classList.remove("is-assigning");const speakerName=isNarrator?"Narrator":name,c=colorFor(speakerName);roster.has(speakerName)||roster.set(speakerName,{count:0,color:c}),roster.get(speakerName).count++,assignModeRow.className="ab-cv-row"+(isNarrator?" is-narr":""),assignModeRow.querySelector(".ab-cv-spk").innerHTML=`${escHtml(speakerName)}${assignModeSeg.emotion?' ('+escHtml(assignModeSeg.emotion)+")":""}`,assignModeRow.querySelector(".ab-cv-spk").style.color=c,assignModeRow.querySelector(".ab-cv-spk").title="Click to assign character",_abRecountRoster(_abActiveSegments().arr),toast(`Assigned to ${isNarrator?"Narrator":name}`,"success"),closeAssignPopup(),_abPersistManualEdit()}function _abOpenAssignPopup(row,anchorEl,prefill){var _a3;if(!row||!row.__seg||row.classList.contains("is-processing"))return;assignModeRow&&assignModeRow.classList.remove("is-assigning"),assignModeSeg=row.__seg,assignModeRow=row,row.classList.add("is-assigning"),assignPopup._assignName=assignName,assignPopup._close=closeAssignPopup,window.getSelection().removeAllRanges(),assignPopup.style.display="flex";const rect=anchorEl.getBoundingClientRect(),top=Math.min(rect.bottom+4,window.innerHeight-300);assignPopup.style.top=top+"px",assignPopup.style.left=rect.left+"px";const list=assignPopup.querySelector(".ab-cv-popup-list");list.innerHTML="";const narrBtn=document.createElement("div");narrBtn.dataset.name="Narrator",narrBtn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;",narrBtn.innerHTML='\u{1F4D6} Narrator',narrBtn.onmouseover=()=>narrBtn.style.background="var(--panel)",narrBtn.onmouseout=()=>narrBtn.style.background="transparent",narrBtn.onclick=()=>assignName("Narrator"),list.appendChild(narrBtn);const items=[...roster.entries()].sort((a,b)=>b[1].count-a[1].count);for(const[n,info]of items){const btn=document.createElement("div");btn.dataset.name=n,btn.style.cssText="padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);";const img=(_a3=recordForName(n))==null?void 0:_a3.image;btn.innerHTML=(img?``:``)+` ${escHtml(n)}`,btn.onmouseover=()=>btn.style.background="var(--panel)",btn.onmouseout=()=>btn.style.background="transparent",btn.onclick=()=>assignName(n),list.appendChild(btn)}const inp=assignPopup.querySelector("input");inp.value=prefill||"",setTimeout(()=>{inp.focus(),prefill&&inp.dispatchEvent(new Event("input"))},50)}const _abIsUnknownSeg=s=>!(s!=null&&s.speaker)||/^Unknown|Unbekannt/i.test(s.speaker),_abIsNarrSeg=s=>(s==null?void 0:s.type)!=="dialogue"||!s.speaker||/^Narrator$/i.test(s.speaker),_abMergedSegment=(a,b)=>{var _a3;let base=a;(_abIsUnknownSeg(a)&&!_abIsUnknownSeg(b)||_abIsNarrSeg(a)&&!_abIsNarrSeg(b))&&(base=b);const type=_abIsNarrSeg(base)?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":base.speaker||"Unknown",type,emotion:type==="dialogue"&&(base.emotion||a.emotion||b.emotion)||"",text:audiobookJoinSegmentText(a.text||"",b.text||""),page:(_a3=a.page)!=null?_a3:b.page}},_abMergeByRow=(row,dir)=>{const active=_abActiveSegments(),idx=active.arr.indexOf(row==null?void 0:row.__seg);if(idx<0)return;const leftIdx=dir<0?idx-1:idx,rightIdx=dir<0?idx:idx+1;if(leftIdx<0||rightIdx>=active.arr.length){toast("No adjacent segment to merge","info");return}const leftRow=dir<0?_abAdjacentRow(row,-1):row,rightRow=dir<0?row:_abAdjacentRow(row,1);_abPushEditState(active.key,active.arr);const merged=_abMergedSegment(active.arr[leftIdx],active.arr[rightIdx]);active.arr.splice(leftIdx,2,merged),active.key==="segments"?_audiobook.segments=active.arr:_audiobook.liveSegments=active.arr,!leftRow||!rightRow||leftRow.parentNode!==rightRow.parentNode?_abRedrawSegmentsChunked(active.arr):_abPatchRowRange([leftRow,rightRow],[merged],active.arr)||_abRedrawSegmentsChunked(active.arr),_abPersistManualEdit(),toast("Segments merged","success")};feed.addEventListener("click",e=>{var _a3;const pageLabel=e.target.closest(".ab-cv-page-label[data-page]");if(pageLabel){e.preventDefault(),e.stopPropagation(),_abJumpToReaderPage(pageLabel.dataset.page);return}const editBtn=e.target.closest(".ab-cv-edit-text");if(editBtn){e.preventDefault(),e.stopPropagation(),_abStartRowEdit(editBtn.closest(".ab-cv-row"));return}const chkBtn=e.target.closest(".ab-cv-check");if(chkBtn){e.preventDefault(),e.stopPropagation();const row=chkBtn.closest(".ab-cv-row"),seg=row&&row.__seg;if(seg){seg.confChecked=!seg.confChecked,seg.confChecked?row.dataset.checked="1":delete row.dataset.checked;const badge=row.querySelector(".ab-cv-conf");badge&&(badge.outerHTML=_abConfBadgeHtml(seg)),chkBtn.outerHTML=_abCheckBtnHtml(seg);const root=document.querySelector(".ab-castpanel-inline");root&&typeof root._abRefreshReview=="function"&&root._abRefreshReview(),_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0)}return}const mergeBtn=e.target.closest(".ab-cv-row-tool");if(mergeBtn){e.preventDefault(),e.stopPropagation(),_abMergeByRow(mergeBtn.closest(".ab-cv-row"),mergeBtn.classList.contains("ab-cv-merge-prev")?-1:1);return}const spk=e.target.closest(".ab-cv-spk");if(spk){_abOpenAssignPopup(spk.closest(".ab-cv-row"),spk,"");return}if(_abJustHandledSelection){_abJustHandledSelection=!1;return}const txtEl=e.target.closest(".ab-cv-txt");if(txtEl&&window.getSelection().isCollapsed){if(!assignModeRow)return;const row=txtEl.closest(".ab-cv-row"),nameHit=e.target.closest(".ab-name-hit"),clickedWord=nameHit?nameHit.dataset.name||nameHit.textContent.trim():(_a3=_abWordRangeAtPoint(e.clientX,e.clientY))==null?void 0:_a3.word;if(assignModeRow!==row){if(clickedWord){const inp=assignPopup.querySelector("input");inp.value=clickedWord,inp.dispatchEvent(new Event("input")),inp.focus()}return}if(nameHit)_abOpenAssignPopup(row,nameHit,clickedWord);else{const hit=_abWordRangeAtPoint(e.clientX,e.clientY);if(hit){const rect=hit.range.getBoundingClientRect();_abOpenAssignPopup(row,{getBoundingClientRect:()=>rect},hit.word)}}}});let _abJustHandledSelection=!1,_abHoverHL=document.getElementById("ab-word-hover");_abHoverHL||(_abHoverHL=document.createElement("div"),_abHoverHL.id="ab-word-hover",_abHoverHL.className="ab-word-hover",_abHoverHL.hidden=!0,document.body.appendChild(_abHoverHL));let _abHoverRaf=0,_abHoverPt=null;const _abHideHoverHL=()=>{_abHoverHL.hidden=!0};feed.addEventListener("mousemove",e=>{_abHoverPt={x:e.clientX,y:e.clientY,target:e.target},!_abHoverRaf&&(_abHoverRaf=requestAnimationFrame(()=>{var _a3;_abHoverRaf=0;const pt=_abHoverPt;if(!pt||!((_a3=pt.target)!=null&&_a3.closest))return;if(!pt.target.closest(".ab-cv-txt")||pt.target.closest(".ab-name-hit")||feed.querySelector(".ab-cv-edit-ta")){_abHideHoverHL();return}const hit=_abWordRangeAtPoint(pt.x,pt.y);if(!hit){_abHideHoverHL();return}const r=hit.range.getBoundingClientRect();if(!r.width){_abHideHoverHL();return}_abHoverHL.style.left=r.left-2+"px",_abHoverHL.style.top=r.top-1+"px",_abHoverHL.style.width=r.width+4+"px",_abHoverHL.style.height=r.height+2+"px",_abHoverHL.hidden=!1}))}),feed.addEventListener("mouseleave",_abHideHoverHL),feed.addEventListener("scroll",_abHideHoverHL,{passive:!0}),feed.addEventListener("dblclick",e=>{const hit=e.target.closest(".ab-name-hit");if(!hit)return;const row=hit.closest(".ab-cv-row"),name=hit.dataset.name;row&&name&&(_abOpenAssignPopup(row,hit,""),assignName(name))});let splitBtn=document.getElementById("ab-cv-split-btn");splitBtn||(splitBtn=document.createElement("button"),splitBtn.id="ab-cv-split-btn",splitBtn.className="btn-secondary btn-sm",splitBtn.innerHTML=' Split text to Unknown Speaker',splitBtn.style.cssText="position:fixed; z-index:2000; display:none; background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px rgba(0,0,0,0.3);",document.body.appendChild(splitBtn));let currentSplitState=null;const _abOffsetInEl=(el,node,offset)=>{const r=document.createRange();return r.selectNodeContents(el),r.setEnd(node,offset),r.toString().length};return document.addEventListener("selectionchange",()=>{if(!feed)return;const sel=window.getSelection();if(sel.isCollapsed||!feed.contains(sel.anchorNode)){splitBtn&&(splitBtn.style.display="none"),currentSplitState=null;return}const txtSpan=sel.anchorNode.nodeType===3?sel.anchorNode.parentNode.closest(".ab-cv-txt"):sel.anchorNode.closest(".ab-cv-txt");if(!txtSpan){splitBtn.style.display="none";return}const row=txtSpan.closest(".ab-cv-row");if(!row||!row.__seg)return;const rawText=sel.toString(),text=rawText.trim();if(text.length>0){if(assignModeSeg&&text.length<40&&!text.includes(`
-`)){splitBtn.style.display="none";return}const range=sel.getRangeAt(0),rect=range.getBoundingClientRect();splitBtn.style.display="block",splitBtn.style.top=rect.bottom+8+"px",splitBtn.style.left=Math.max(10,rect.left+rect.width/2-100)+"px";const startOffset=_abOffsetInEl(txtSpan,range.startContainer,range.startOffset),endOffset=_abOffsetInEl(txtSpan,range.endContainer,range.endOffset);currentSplitState={row,text,rawText,seg:row.__seg,startOffset,endOffset}}else splitBtn.style.display="none",currentSplitState=null}),splitBtn.addEventListener("click",()=>{if(!currentSplitState)return;const{row,seg,startOffset,endOffset}=currentSplitState,fullText=seg.text||"",before=fullText.slice(0,startOffset),selectedText=fullText.slice(startOffset,endOffset),after=fullText.slice(endOffset);if(!selectedText)return;let arr=_audiobook.segments||[],globalIdx=arr.indexOf(seg);globalIdx===-1&&Array.isArray(_audiobook.liveSegments)&&(arr=_audiobook.liveSegments,globalIdx=arr.indexOf(seg)),globalIdx!==-1&&_abPushEditState(arr===_audiobook.liveSegments?"liveSegments":"segments",arr);const newSegs=[];if(before.length&&newSegs.push({speaker:seg.speaker,type:seg.type,emotion:seg.emotion,text:before,page:seg.page}),newSegs.push({speaker:"Unknown",type:"dialogue",emotion:"",text:selectedText,page:seg.page}),after.length&&newSegs.push({speaker:seg.speaker,type:seg.type,emotion:seg.emotion,text:after,page:seg.page}),globalIdx!==-1)arr.splice(globalIdx,1,...newSegs),_abPatchRowRange([row],newSegs,arr)||_abRedrawSegments(arr),_abPersistManualEdit();else{console.warn("[audiobook] split during casting: DOM-only, segment not yet in array");const frag=document.createDocumentFragment();for(const s of newSegs)frag.appendChild(_abRowFromSegment(s));row.parentNode.insertBefore(frag,row),row.remove()}splitBtn.style.display="none",window.getSelection().removeAllRanges(),toast("Segment split! You can now assign a character to the Unknown block.","success")}),feed.addEventListener("mouseup",()=>{const sel=window.getSelection();if(sel.isCollapsed||!feed.contains(sel.anchorNode))return;const text=sel.toString().trim();!text||text.length>=40||text.includes(`
-`)||/^[»„“"'‘]/.test(text)||assignModeSeg&&(_abJustHandledSelection=!0,assignName(text),sel.removeAllRanges())}),chars.addEventListener("click",e=>{const chip=e.target.closest(".ab-chip");if(chip){let name="";for(const n of chip.childNodes)n.nodeType===3&&(name+=n.nodeValue);if(name=name.trim(),assignModeSeg)name&&assignName(name);else if(name){const rows=Array.from(feed.querySelectorAll(".ab-cv-row")).filter(r=>r.__seg&&r.__seg.speaker===name);if(!rows.length)return;const currentY=feed.scrollTop;let target=rows.find(r=>r.offsetTop-feed.offsetTop>currentY+10);target||(target=rows[0]),_abScrollIntoView(target,{block:"center"}),target.style.transition="background 0.3s",target.style.background="var(--accent-hover, rgba(100, 150, 255, 0.2))",setTimeout(()=>{target.parentNode&&(target.style.background="")},1e3)}}}),{recountRoster(allSegs){_abRecountRoster(allSegs||[])},rebuild(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),_abRedrawSegmentsChunked(segs||[])},update(done){const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!1);const pct=Math.round(done/total*100);fill&&(fill.style.width=pct+"%"),count&&(count.hidden=!1,count.textContent=`passage ${done} / ${total}`);const fillText=panel.querySelector("#ab-cv-fill-text");fillText&&(fillText.textContent=`${pct}% (Passage ${done} of ${total})`)},processing(text){this._procRow&&this._procRow.remove(),this._thinkRaw="",this._thinkDone=!1,this._procRow=document.createElement("div"),this._procRow.className="ab-cv-row is-processing ab-cv-llm-row";const isLong=text.length>160,preview=escHtml(text.slice(0,160))+(isLong?"\u2026":"");this._procRow.innerHTML=`
-
- LLM Reading\u2026
-
-
-
${preview}
-
-
-
Passage
-
${escHtml(text)}
-
-
-
LLM Thinking\u2026
-
-
-
- `,this._procRow.querySelector(".ab-cv-expand-btn").addEventListener("click",function(){const split=this.closest(".ab-cv-llm-row").querySelector(".ab-cv-llm-split"),open=this.getAttribute("aria-expanded")==="true";this.setAttribute("aria-expanded",String(!open)),split.hidden=open;const icon=this.querySelector("span");icon&&(icon.className=open?"mdi mdi-chevron-right":"mdi mdi-chevron-down")}),_abPage().appendChild(this._procRow),trim()},thinking(delta){if(!this._procRow||!delta||this._thinkDone)return;this._thinkRaw=(this._thinkRaw||"")+delta;const think=this._procRow.querySelector(".ab-cv-think"),label=this._procRow.querySelector(".ab-cv-think-label");if(!think)return;const openAt=this._thinkRaw.indexOf("");let shown,isReal=!1,closeAt=-1;if(openAt>=0){const afterOpen=this._thinkRaw.slice(openAt+7);closeAt=afterOpen.indexOf(""),shown=closeAt>=0?afterOpen.slice(0,closeAt):afterOpen,isReal=!0}else shown=this._thinkRaw;label&&(label.innerHTML=isReal?' LLM Thinking\u2026':' Live Output (raw \u2014 no reasoning exposed)'),think.textContent=shown.trim().slice(-2e4),think.scrollTop=think.scrollHeight,closeAt>=0&&(this._thinkDone=!0)},clearProcessing(){this._procRow&&(this._procRow.remove(),this._procRow=null)},addSegments(segs){var _a3,_b3;this.clearProcessing(),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const makeRow=s=>{const{speakerName}=_abRowSpeaker(s);return colorFor(speakerName),roster.get(speakerName).count++,_abRowFromSegment(s)},CHUNK=80,first=segs.slice(0,CHUNK),frag0=document.createDocumentFragment();if(first.forEach(s=>frag0.appendChild(makeRow(s))),_abPage().appendChild(frag0),renderRoster(),segs.length>CHUNK){let i=CHUNK;const next=()=>{if(i>=segs.length){trim(),renderRoster();return}const batch=document.createDocumentFragment(),end=Math.min(i+CHUNK,segs.length);for(;i=0?prevIdx+1:0),segsAll=_audiobook.segments||[],gapTo=Math.min(segsAll.length-1,nextIdx>=0?nextIdx-1:segsAll.length-1),gapCount=Math.max(0,gapTo-gapFrom+1);r.innerHTML=`\u22EF ${gapCount>0?gapCount+" line"+(gapCount!==1?"s":"")+" hidden \u2014 ":""}click to expand`,r.title="Click to reveal the text between these passages",r.addEventListener("click",()=>{const gap=segsAll.slice(gapFrom,gapTo+1);if(!gap.length){r.remove();return}const PEEK=20,show=gap.slice(0,PEEK),rest=gap.slice(PEEK),frag=document.createDocumentFragment();for(const s of show){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${_abMarkQuotes(highlightText2(_abDisplayText(s)))}`,frag.appendChild(row)}if(rest.length){const next=document.createElement("div");next.className="ab-cv-divider ab-cv-divider-expand",next.innerHTML=`\u22EF ${rest.length} line${rest.length!==1?"s":""} hidden \u2014 click to expand`,next.title="Click to reveal more";const restSegs=rest;next.addEventListener("click",function(){const f2=document.createDocumentFragment();for(const s of restSegs){const isNarr=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator",spk=isNarr?"Narrator":s.speaker,c=colorFor(spk),row=document.createElement("div");row.className="ab-cv-row ab-cv-ctx-row"+(isNarr?" is-narr":""),row.__seg=s,row.innerHTML=`${escHtml(spk)}${s.emotion?' ('+escHtml(s.emotion)+")":""}${_abMarkQuotes(highlightText2(_abDisplayText(s)))}`,f2.appendChild(row)}next.replaceWith(f2)}),frag.appendChild(next)}r.replaceWith(frag)}),_abPage().appendChild(r),trim()},pagemark(pageNum){this.clearProcessing(),_abNewPage("Page "+pageNum,pageNum),trim()},complete(summary,onOpen,onRecast,onRecastUnknown){var _a3,_b3;_abHideCastingControl(),count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0);const completedSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length?_audiobook.segments:typeof allSegments!="undefined"&&allSegments.length?allSegments:null;completedSegments&&_abRedrawSegmentsChunked(completedSegments);const cancelBtn=panel.querySelector("#ab-cv-cancel");cancelBtn&&(cancelBtn.hidden=!0);const foot=panel.querySelector("#ab-cv-foot");foot.hidden=!1;const sideEl=panel.querySelector(".ab-cv-side");if(sideEl){let doneEl=sideEl.querySelector(".ab-cv-side-done");doneEl||(doneEl=document.createElement("div"),doneEl.className="ab-cv-side-done",sideEl.appendChild(doneEl)),doneEl.innerHTML=` ${escHtml(summary)}`}const continueBtnHtml=_audiobook.completedChunks>0&&_audiobook.completedTotal>0&&_audiobook.completedChunks<_audiobook.completedTotal?``:"";foot.innerHTML=`${continueBtnHtml}`;const verificationPrompt=`Du bist ein Qualit\xE4tspr\xFCfer f\xFCr die Sprecherzuordnung eines bereits analysierten deutschen H\xF6rbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT \u2014 du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters geh\xF6rt, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer k\xF6nnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erz\xE4hltext hineingezogen?".
-
-WORAUF DU PR\xDCFST \u2014 IN DIESER REIHENFOLGE (h\xF6chste Priorit\xE4t zuerst):
-1. ZUERST alle 'Unknown'-Zeilen: L\xF6se den Sprecher \xFCber Kontext auf (Inquit-Formeln, Ping-Pong-Wechsel in Zwiegespr\xE4chen, Adressaten-Bezug, zuletzt genannte Person). 'Unknown' bleibt nur, wenn wirklich keine Ableitung m\xF6glich ist. Das ist die dringendste Kategorie \u2014 eine Zeile ohne jeden Sprecher ist schlimmer als eine falsch zugeordnete.
-2. DANACH Zeilenketten mit demselben Sprecher in Folge (2 oder mehr aufeinanderfolgende Zeilen \u2014 egal ob 'Narrator' oder ein Charakter): Das ist die zweith\xE4ufigste Fehlerquelle. Pr\xFCfe jede Zeile in einer solchen Kette einzeln: Ist das wirklich durchgehend derselbe Sprecher, oder wurde eine Sprecherwechsel-Zeile (z.B. eine Antwort einer anderen Figur, oder gesprochene Rede ohne erhaltene Anf\xFChrungszeichen) f\xE4lschlich in die Kette hineingezogen? Pr\xFCfe besonders bei 'Narrator'-Ketten: Ist wirklich jede Zeile neutrale Erz\xE4hlerbeschreibung, oder ist tats\xE4chlich eine Aussage dabei, die ein Charakter so gesagt haben k\xF6nnte \u2014 nur ohne erhaltene Anf\xFChrungszeichen (typisch bei PDF/OCR-Extraktion)? Pr\xFCfe Tonfall, Wortwahl und Perspektive: Klingt eine der Zeilen wie eine direkte Aussage/Reaktion einer Person in der Szene (Ich-Form, Anrede, Ausruf, Frage), nicht wie neutrale Erz\xE4hlerbeschreibung? Dann ist SIE Dialog, nicht Narration \u2014 auch wenn keine \xBB \xAB vorhanden sind, und auch wenn die Zeilen davor/danach in derselben Kette echt narrativ sind.
-3. ZULETZT ein allgemeiner Plausibilit\xE4ts-Check bei allen \xFCbrigen, bereits vermuteten Sprechern: Passt Tonfall/Wortwahl der Zeile zu dem, was \xFCber diese Figur im bisherigen Text bekannt ist (Sprechweise, Haltung, Beziehung zu anderen Figuren)? Wenn eine Zeile im Kontext eindeutig zu einer ANDEREN, im Text erkennbaren Person passt, korrigiere sie dorthin.
-4. Echte Narration NICHT anfassen: Reine Beschreibung, Handlung, \xDCberg\xE4nge, Kapitelanf\xE4nge bleiben 'Narrator'. Nur weil eine Figur erw\xE4hnt wird, wird der Satz nicht zu ihrem Dialog.
-
-DEDUKTIONS-WERKZEUGE f\xFCr die Sprecherzuordnung:
-- Doppelpunkt-Regel: Endet der Satz davor mit ":", spricht dessen Subjekt das Folgende.
-- Nachgestellte Zuordnung: Folgt einer als 'Narrator' markierten Zeile direkt eine kurze Inquit-Formel (Sprechverb + Name/Pronomen, z.B. "entgegnete Oberst von Blautann.", ", meldete sich Lysandra zu Wort.", "murmelte er."), dann war die VORHERIGE Zeile in Wahrheit das Zitat dieser Person \u2014 auch ohne Anf\xFChrungszeichen. Das ist der h\xE4ufigste Fehler der ersten Zuordnung: gesprochene Rede wird f\xE4lschlich als Narration markiert, weil die Anf\xFChrungszeichen bei der Extraktion verloren gingen.
-- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile AKTIV HANDELT (aufblickt, sich umdreht, den Kopf sch\xFCttelt), ist der wahrscheinlichste Sprecher dieser Zeile.
-- ZUH\xD6RER-AUSSCHLUSS (negative Evidenz): Wahrnehmungs- und Reaktionsverben kennzeichnen den ZUH\xD6RER, nicht den Sprecher ("Marcian sp\xFCrte ihre Tr\xE4nen", "Marcian h\xF6rte dar\xFCber hinweg" \u2192 Marcian h\xF6rt zu, jemand anderes spricht). Ebenso ist eine Person, die im Zitat per Vokativ ANGEREDET wird ("..., Marcian."), nicht der Sprecher. Pr\xFCfe diese Ausschl\xFCsse ZUERST, bevor du einen Namen aus dem Nachbarsatz \xFCbernimmst \u2014 das ist die h\xE4ufigste Fehlerquelle bei nicht zugeordneten Zeilen.
-- Pronomen-Inquit bei geteilten Zitaten: Eine Zeile wie ", antwortete er knapp." geh\xF6rt zum vorherigen Zitat; l\xF6se das Pronomen auf die zuletzt genannte Person passenden Geschlechts VOR dem Zitat auf (er \u2192 letzter Mann, sie \u2192 letzte Frau).
-- Ping-Pong-Prinzip: Zwei Personen im Gespr\xE4ch wechseln sich strikt ab, auch ohne Tags \u2014 verfolge die Kette zur letzten eindeutigen Nennung zur\xFCck.
-- Pronomen-Aufl\xF6sung: er/sie/es meint die zuletzt genannte Person passenden Geschlechts.
-- Eine Rollenbezeichnung ('Ork', 'Der Fremde', 'W\xE4chter') ist nur dann ein g\xFCltiger Sprecher, wenn die Person im Text keinen Eigennamen hat; sonst immer den Eigennamen verwenden.
-
-F\xDCR JEDES SEGMENT AUSGABE:
-- speaker: 'Narrator' f\xFCr Narration, oder EXAKT der Name des Charakters (nutze den in der bekannten Charakterliste gef\xFChrten Namen, nicht einen Spitznamen/Alias, falls der Kontext eindeutig zuordnet).
-- type: 'narration' oder 'dialogue'
-- text: EXAKT der WORTW\xD6RTLICHE Originaltext \u2014 KEINE \xC4nderungen, KEINE Auslassungen, KEINE Erg\xE4nzungen.
-- emotion: Bei Dialogen 1-2 deutsche W\xF6rter f\xFCr den Tonfall. Bei Narration leer ('').
-
-ABSOLUTE REGELN:
-- Alle Segmente zusammen M\xDCSSEN den Originaltext exakt, l\xFCckenlos und wortgetreu rekonstruieren.
-- Erfinde NIEMALS Text. Lasse NIEMALS W\xF6rter weg. F\xFCge NIEMALS etwas hinzu.
-- Mische NIEMALS Narration und Dialog in einem Segment.
-- Sei konservativ: \xE4ndere eine Zeile nur, wenn du nach dieser Pr\xFCfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend w\xE4re \u2014 nicht jede Zeile muss sich \xE4ndern.`;window._abVerificationPrompt=verificationPrompt;const runVerificationPass=()=>{const choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);closePanel(),audiobookRecastUnknown(savedChoice.url,savedChoice.model,{prompt:verificationPrompt,repairNarration:!0})},runConsistencyPass=async()=>{const active=_abActiveSegments(),segs=active.arr;if(!segs.length){toast("Nothing cast yet to check","error");return}const byName=new Map;segs.forEach((s,idx)=>{s.type!=="dialogue"||!s.speaker||/^Narrator$/i.test(s.speaker)||/^Unknown|Unbekannt/i.test(s.speaker)||(byName.has(s.speaker)||byName.set(s.speaker,[]),byName.get(s.speaker).push(idx))});const MIN_LINES=4,MAX_LINES_PER_CALL=60,candidates=[...byName.entries()].filter(([,idxs])=>idxs.length>=MIN_LINES);if(!candidates.length){toast("No characters with enough lines yet to check for consistency","error");return}const choice2=audiobookCurrentCastLlm(panel),savedChoice2=audiobookSaveLlmChoice(choice2.url,choice2.model);closePanel(),_abPushEditState(active.key,segs);const busy=_abShowBusyOverlay(`Checking voice consistency for ${candidates.length} character${candidates.length!==1?"s":""}\u2026`,!0);let checked=0,flaggedTotal=0,failedNames=[];const touchedSegs=[],knownNames=[...roster.keys()];try{for(const[name,idxs]of candidates){busy.setProgress(checked,candidates.length);let sampleIdxs=idxs;if(idxs.length>MAX_LINES_PER_CALL){const step=idxs.length/MAX_LINES_PER_CALL;sampleIdxs=Array.from({length:MAX_LINES_PER_CALL},(_,i)=>idxs[Math.floor(i*step)])}const lines=sampleIdxs.map(i=>({index:i,text:segs[i].text}));try{const r=await fetch("/api/audiobook-consistency-check",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({character:name,lines,known_characters:knownNames.filter(n=>n.toLowerCase()!==name.toLowerCase()),llm_url:savedChoice2.url,model:savedChoice2.model})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const data=await r.json(),outliers=Array.isArray(data==null?void 0:data.outliers)?data.outliers:[];for(const o of outliers){let idx=o.index;const quote=String(o.quote||"").trim().toLowerCase(),matchesQuote=i=>{var _a4;return!quote||(((_a4=segs[i])==null?void 0:_a4.text)||"").toLowerCase().includes(quote.slice(0,40))};if((typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name||!matchesQuote(idx))&&(idx=quote?sampleIdxs.find(i=>segs[i].speaker===name&&matchesQuote(i)):void 0),typeof idx!="number"||!segs[idx]||segs[idx].speaker!==name)continue;const suggested=String(o.suggested_speaker||"").trim();let newSpeaker=null;!suggested||/^unknown|unbekannt$/i.test(suggested)?newSpeaker="Unknown":newSpeaker=knownNames.find(n=>n.toLowerCase()===suggested.toLowerCase())||null,!(!newSpeaker||newSpeaker===name)&&(segs[idx].speaker=newSpeaker,newSpeaker==="Unknown"&&(segs[idx].type="dialogue"),touchedSegs.push(segs[idx]),flaggedTotal++)}}catch(err){failedNames.push(name),console.error("[consistency check]",name,err)}checked++,busy.setProgress(checked,candidates.length)}}finally{busy.remove()}flaggedTotal&&(await _abPatchScatteredRows(touchedSegs,()=>{})||await _abRedrawSegmentsChunked(active.arr),_abPersistManualEdit());const failSuffix=failedNames.length?` (${failedNames.length} character${failedNames.length!==1?"s":""} failed to check: ${failedNames.slice(0,5).join(", ")})`:"";toast((flaggedTotal?`Consistency check: ${flaggedTotal} line${flaggedTotal!==1?"s":""} reassigned across ${checked} character${checked!==1?"s":""}`:`Consistency check: no mismatches found across ${checked} character${checked!==1?"s":""}`)+failSuffix,failedNames.length&&!flaggedTotal?"error":"success")};foot.querySelector("#ab-cv-open-reh").addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()});const applyPromptAndRun2=callback=>{const newPrompt=panel.querySelector("#ab-cv-prompt-text").value,choice=audiobookCurrentCastLlm(panel),savedChoice=audiobookSaveLlmChoice(choice.url,choice.model);typeof _appSettings!="undefined"&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt})}).finally(()=>{closePanel(),callback&&callback(savedChoice.url,savedChoice.model)})},runIdentifyAll=async()=>{var _a4;const bookTitle2=((_a4=window.readerState)==null?void 0:_a4.title)||"";bookTitle2&&typeof window.openBookContextEditor=="function"&&!(typeof _getBookProfile=="function"?await _getBookProfile(bookTitle2):{}).confirmed&&!await window.openBookContextEditor(bookTitle2)||!await confirmDialog("Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.",{title:"Discard current cast?",okLabel:"Start from scratch",danger:!0})||applyPromptAndRun2(audiobookCast)},runIdentifyUnknown=()=>applyPromptAndRun2(audiobookRecastUnknown),runRepairNarration=()=>{const segs=_audiobook.segments||[],n=typeof _abSuspectNarration=="function"?_abSuspectNarration(segs).size:0;if(!n){toast("No narration lines look like mislabeled dialogue","success");return}toast(`Re-checking ${n} narration line${n!==1?"s":""} that look like spoken dialogue\u2026`,"info"),audiobookRecastUnknown(null,null,{repairNarration:!0,prompt:verificationPrompt})},runCastAll=()=>{typeof window.csForReader=="function"?window.csForReader():typeof csForReader=="function"?csForReader():toast("Character sheets not loaded yet","error")},runCastFresh=async()=>{await confirmDialog("Discard every generated character sheet for this book and rebuild every profile from a blank slate? This cannot be undone.",{title:"Discard all character sheets?",okLabel:"Discard & rebuild",danger:!0})&&(typeof window.csForReader=="function"?window.csForReader({fresh:!0}):typeof csForReader=="function"?csForReader({fresh:!0}):toast("Character sheets not loaded yet","error"))},runCastSelected=()=>{if(!existing.length){toast("No character sheets found yet for this book","error");return}_abOpenRecastSelectPopup(existing)},runCastContinueUncasted=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof csForReaderSelective!="function"||typeof CS_DETAIL_FIELDS=="undefined"){toast("Character sheets not loaded yet","error");return}let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const recByName=new Map;recs.forEach(r=>{var _a4;const name=String(r.name||"").trim().toLowerCase();if(name&&recByName.set(name,r),typeof clSplitIdentityTokens=="function")for(const a of clSplitIdentityTokens((_a4=r==null?void 0:r.sheet)==null?void 0:_a4.aliases,{aliases:!0})){const key=a.toLowerCase();recByName.has(key)||recByName.set(key,r)}});const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim())),MIN_LINES_TO_TRY=10,lineCounts=new Map;(_audiobook.segments||[]).forEach(s=>{if((s==null?void 0:s.type)!=="dialogue"||!s.speaker)return;const k=String(s.speaker).trim().toLowerCase();lineCounts.set(k,(lineCounts.get(k)||0)+1)});const tooSparse=[],incomplete=roster2.filter(n=>{const rec=recByName.get(String(n).trim().toLowerCase());return rec&&CS_DETAIL_FIELDS.some(f=>String((rec.sheet||{})[f]||"").trim())?!1:(lineCounts.get(String(n).trim().toLowerCase())||0){var _a4;if((_a4=document.getElementById("s-caststudio"))!=null&&_a4.classList.contains("is-active")&&typeof window.showStudioPhase=="function"){window.showStudioPhase(3);return}try{sessionStorage.setItem("ttsvc_cast_return","reader")}catch{}typeof navTo=="function"&&navTo("s-library"),typeof navLibraryView=="function"&&navLibraryView("characters")},runLibraryCleanup=async()=>{if(typeof clGetAllByTagOrBook!="function"||typeof clDelete!="function"){toast("Character library is not available right now","error");return}const roster2=(_audiobook.roster||[]).filter(n=>n&&!/^unknown$|^unbekannt$/i.test(n.trim()));if(!roster2.length){toast("No current roster to clean up against","error");return}const rosterSet=new Set(roster2.map(n=>n.trim().toLowerCase()));let recs=[];try{recs=await clGetAllByTagOrBook(bookTitle)}catch{}const toDelete=recs.filter(r=>!rosterSet.has(String(r.name||"").trim().toLowerCase()));if(!toDelete.length){toast(`Library already matches the current ${roster2.length}-name roster \u2014 nothing to remove`,"success");return}const preview=toDelete.slice(0,12).map(r=>r.name).join(", ")+(toDelete.length>12?`, +${toDelete.length-12} more`:"");if(!await confirmDialog(`Remove ${toDelete.length} character record${toDelete.length!==1?"s":""} that aren't in the current ${roster2.length}-name roster? This cannot be undone.
-
-${preview}`,{title:"Clean up character library?",okLabel:`Remove ${toDelete.length}`,danger:!0}))return;let removed=0;for(const r of toDelete)try{await clDelete(r.id),removed++}catch{}toast(`Removed ${removed} character record${removed!==1?"s":""} not in the current roster`,"success")},bookTitle=((_a3=window.readerState)==null?void 0:_a3.title)||"";let existing=[];(async()=>{if(!(!bookTitle||typeof clGetAllByTagOrBook!="function"))try{existing=await clGetAllByTagOrBook(bookTitle)}catch{}})();const identifyMenu=foot.querySelector("#ab-cv-menu-identify"),castMenu=foot.querySelector("#ab-cv-menu-cast"),viewCastMenu=foot.querySelector("#ab-cv-menu-viewcast");identifyMenu==null||identifyMenu.addEventListener("click",()=>_abToggleFootMenu(identifyMenu,[{icon:"mdi-refresh",label:"1. Identify all characters",title:"Scan the text and build the cast list from scratch",onClick:runIdentifyAll,danger:!0},{icon:"mdi-auto-fix",label:"2. Auto-repair cast (2.1\u20132.5)",title:"Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed",onClick:()=>audiobookAutoRepairCast()},{icon:"mdi-comment-alert-outline",label:"2.1 Repair mislabeled dialogue",title:"Finds spoken lines that were typed as narration (usually because the \xBB \xAB quote marks were lost in extraction) and re-checks only those \u2014 far cheaper than verifying every narration line",onClick:runRepairNarration},{icon:"mdi-account-question-outline",label:"2.2 Identify unknown characters",title:"Re-scan only the unknown segments with the current prompt",onClick:runIdentifyUnknown},{icon:"mdi-repeat",label:"2.3 Run until < N unknown\u2026",title:"Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls",onClick:()=>{const input=window.prompt("Stop once fewer than this many Unknown speakers remain:","10");if(input==null)return;const threshold=parseInt(input,10);if(!Number.isFinite(threshold)||threshold<0){toast("Enter a whole number of 0 or more","error");return}audiobookRecastUntilThreshold(threshold)}},{icon:"mdi-shield-check-outline",label:"2.4 Verify all characters",title:"Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches",onClick:runVerificationPass},{icon:"mdi-account-search-outline",label:"2.5 Check voice consistency",title:"Third-pass check: gathers every line already credited to each character across the whole book and flags any that don\u2019t match their established voice",onClick:runConsistencyPass},{icon:"mdi-traffic-light-outline",label:"2.6 Score attribution confidence",title:"Colour-code every line by how certain its speaker is (green/orange/red), then use F2 to jump between the uncertain ones. Instant \u2014 no LLM calls.",onClick:()=>{try{const segs=_audiobook.segments||[];if(!segs.length){toast("Nothing cast yet to score \u2014 open or cast a book first","error");return}_abStampConfidence(segs);const red=segs.filter(s=>typeof s.conf=="number"&&s.conf<50).length,amber=segs.filter(s=>typeof s.conf=="number"&&s.conf>=50&&s.conf<96).length,root=document.querySelector(".ab-castpanel-inline")||document;root.querySelectorAll(".ab-cv-row").forEach(row=>{const s=row.__seg;if(!s||typeof s.conf!="number")return;row.dataset.conf=String(s.conf);const existing2=row.querySelector(".ab-cv-conf");if(existing2)existing2.outerHTML=_abConfBadgeHtml(s);else{const spk=row.querySelector(".ab-cv-spk");spk?spk.insertAdjacentHTML("afterend",_abConfBadgeHtml(s)):row.insertAdjacentHTML("afterbegin",_abConfBadgeHtml(s))}}),typeof root._abRefreshReview=="function"&&root._abRefreshReview(),_audiobook.lastText&&_abSaveDraft(segs,_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0),toast(`Scored ${segs.length} lines \u2014 ${red} guess/doubtful, ${amber} worth a look. Use the review button or F2.`,"success")}catch(e){console.error("[confidence]",e),toast("Scoring failed: "+(e.message||e),"error")}}}])),window._abRunConsistencyPass=runConsistencyPass,castMenu==null||castMenu.addEventListener("click",()=>_abToggleFootMenu(castMenu,[{icon:"mdi-account-multiple-plus-outline",label:"Cast all character roles",title:"Generate / refresh the character sheets for every cast character",onClick:runCastAll},{icon:"mdi-account-arrow-right-outline",label:"Continue uncasted characters",title:"Only generate profiles for characters with no detail yet \u2014 skips anyone already fully cast",onClick:runCastContinueUncasted},{icon:"mdi-account-check-outline",label:"Select characters to cast or recast roles",title:existing.length?"Choose character roles to generate or refresh":"No character sheets found yet for this book",disabled:!existing.length,onClick:runCastSelected},{divider:!0},{icon:"mdi-refresh",label:"New recast (discard & rebuild all)",title:"Discard every generated character sheet and rebuild every profile from scratch",onClick:runCastFresh,danger:!0}])),viewCastMenu==null||viewCastMenu.addEventListener("click",()=>_abToggleFootMenu(viewCastMenu,[{icon:"mdi-eye-outline",label:"View cast",title:"View the cast overview in the library",onClick:runViewCast},{icon:"mdi-folder-zip-outline",label:"Export cast archive (.zip)",title:"Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character",onClick:audiobookExportCastMd},{divider:!0},{icon:"mdi-broom",label:"Clean up library to current roster",title:"Remove saved character records that aren't in the current roster \u2014 old spelling-variant duplicates and superseded names from past casting runs",onClick:runLibraryCleanup,danger:!0}])),(_b3=foot.querySelector("#ab-cv-continue"))==null||_b3.addEventListener("click",()=>applyPromptAndRun2((u,m)=>audiobookCast(u,m,{startIndex:_audiobook.completedChunks,segments:_audiobook.segments,roster:_audiobook.roster,narrationOnly:_audiobook.narratedPassages,degraded:_audiobook.degraded}))),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},done(options={}){var _a3,_b3,_c3,_d3,_e3;if(_abHideCastingControl(),!(options.stopped||_audiobook.cancel)){closePanel();return}this.clearProcessing(),count&&(count.hidden=!0);const castBar=panel.querySelector(".ab-castpanel-bar");castBar&&(castBar.hidden=!0),(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove();const message=options.message||"Casting stopped before any passage completed.";if(feed.querySelector(".ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page")){if(options.message){const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}}else{feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No completed characters yet.');const foot=panel.querySelector("#ab-cv-foot"),hasSegments=Array.isArray(_audiobook.segments)&&_audiobook.segments.length>0;foot.hidden=!1,foot.innerHTML=` ${escHtml(message)}${hasSegments?'':""}`,(_c3=foot.querySelector("#ab-cv-stopped-back"))==null||_c3.addEventListener("click",closePanel),(_d3=foot.querySelector("#ab-cv-stopped-review"))==null||_d3.addEventListener("click",async()=>{closePanel(),await audiobookOpenCurrentInRehearser()}),(_e3=foot.querySelector("#ab-cv-stopped-recast"))==null||_e3.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({audiobook_prompt:newPrompt||""})}).finally(()=>{closePanel(),audiobookCast(choice.url,choice.model)})}),panel.classList.add("ab-castpanel-done"),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!1)},loadingRestore(){var _a3,_b3;(_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent="Loading saved cast\u2026",feed.appendChild(r),chars.innerHTML='restoring\u2026';const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent="Checking saved cast before starting a new one.");const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="none")},setFreshState(message="Ready to identify characters."){var _a3,_b3;if((_a3=feed.querySelector(".ab-skel-feed"))==null||_a3.remove(),(_b3=chars.querySelector(".ab-skel-chars"))==null||_b3.remove(),!feed.querySelector(".ab-cv-row, .ab-cv-divider, .ab-cv-page")){feed.innerHTML="",_abCurPage=null;const r=document.createElement("div");r.className="ab-cv-note",r.textContent=message,feed.appendChild(r)}chars.querySelector(".ab-char-item")||(chars.innerHTML='No saved cast found.');const statusMsg=panel.querySelector("#ab-cv-status-msg");statusMsg&&(statusMsg.style.display="inline-block",statusMsg.textContent=message);const castBtn=panel.querySelector("#ab-cv-start-cast");castBtn&&(castBtn.style.display="inline-block",castBtn.addEventListener("click",()=>{var _a4;const newPrompt=(_a4=panel.querySelector("#ab-cv-prompt-text"))==null?void 0:_a4.value,currentChoice=audiobookCurrentCastLlm(panel),choice=audiobookSaveLlmChoice(currentChoice.url,currentChoice.model);typeof _appSettings!="undefined"&&newPrompt&&(_appSettings.audiobook_prompt=newPrompt),audiobookCast(choice.url,choice.model)}))}}}function _abBusyGuard(what){if(!_audiobook.running)return!1;try{toast(`${what} can't start yet \u2014 another pass is still running. Use Stop in the casting panel first.`,"error")}catch{}return!0}async function audiobookRecastUnknown(overrideUrl,overrideModel,options={}){var _a2,_b2,_c2,_d2,_e2,_f2;if(_abBusyGuard("Recast / verify"))return;const segs=_audiobook.segments;if(!segs||!segs.length)return;const originalSegments=segs.map(s=>({...s})),countUnknownDialogue=arr=>(arr||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length,beforeUnknownCount=countUnknownDialogue(segs),preResolved=audiobookResolveUnknowns(segs,[],_audiobook.roster||[]);preResolved.length&&toast(`${preResolved.length} Unknown line${preResolved.length!==1?"s":""} resolved by grammar rules`,"success");const unknownIdxs=[],_abRepairSuspects=options.repairNarration?_abSuspectNarration(segs):null;for(let i=0;iac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang();let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel()),promptOverride=typeof options.prompt=="string"?options.prompt:null;!promptOverride&&options.repairNarration&&typeof window._abVerificationPrompt=="string"&&(promptOverride=window._abVerificationPrompt);const groups=audiobookRecastGroups(unknownIdxs,segs),view=audiobookCastView(unknownIdxs.length,llm_url,model);view.recountRoster(segs);const _rcAliasMap=new Map;try{const bookTitle=((_a2=window.readerState)==null?void 0:_a2.title)||"",records=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(bookTitle):[];for(const rec of records||[])if(rec!=null&&rec.name&&(_rcAliasMap.set(rec.name.toLowerCase(),rec.name),typeof clSplitIdentityTokens=="function"))for(const a of clSplitIdentityTokens(rec.aliases,{aliases:!0}))_rcAliasMap.set(a.toLowerCase(),rec.name)}catch{}const canonicalizeSpeaker=name=>name&&_rcAliasMap.get(name.toLowerCase())||name;view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_b2=document.getElementById("ab-cv-llm-url"))==null?void 0:_b2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_c2=document.getElementById("ab-cv-llm-select"))==null?void 0:_c2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Character definition stopped before any lines were updated."}),toast("Character definition stopped. Existing cast preserved.","info");return}}let done=0,prevIdx=-2,groupsSinceSave=0;const pendingReplacements=new Map,normalizeRecastSegment=(seg,fallback)=>{const type=(seg==null?void 0:seg.type)==="narration"?"narration":"dialogue";return{speaker:type==="narration"?"Narrator":canonicalizeSpeaker((seg==null?void 0:seg.speaker)||(fallback==null?void 0:fallback.speaker)||"Unknown"),type,emotion:type==="dialogue"&&((seg==null?void 0:seg.emotion)||(fallback==null?void 0:fallback.emotion))||"",text:(seg==null?void 0:seg.text)||(fallback==null?void 0:fallback.text)||""}};try{for(let group of groups){if(_audiobook.cancel)break;const unresolvedGroup=[];for(const idx of group)audiobookIsSpeechTagOnly((_d2=segs[idx])==null?void 0:_d2.text)?(segs[idx].type="narration",segs[idx].speaker="Narrator",segs[idx].emotion=""):unresolvedGroup.push(idx);if(!unresolvedGroup.length){group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1],view.update(done,`Correcting narration tags ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}group=unresolvedGroup,group[0]!==prevIdx+1&&view.divider(prevIdx,group[0]),prevIdx=group[group.length-1];const recastCtx=audiobookRecastContext(segs,group),passageText=recastCtx.text,lineLabel=group.length>1?`Attributing lines ${done+1}-${done+group.length} / ${unknownIdxs.length}\u2026`:`Attributing line ${done+1} / ${unknownIdxs.length}\u2026`;view.update(done,lineLabel),view.processing(passageText.trim());let data=null;const recastBody=audiobookAttributeBody({text:passageText.trim(),known_characters:_audiobook.roster.slice(-40),recent:recastCtx.recent||"",language,llm_url:((_e2=document.getElementById("ab-cv-llm-url"))==null?void 0:_e2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_f2=document.getElementById("ab-cv-llm-select"))==null?void 0:_f2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)},promptOverride);try{try{data=await audiobookAttributeStream(recastBody,view,ac.signal,AUDIOBOOK_RECAST_TIMEOUT_MS)}catch(streamErr){if(streamErr.name==="AbortError")throw streamErr;const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(recastBody)},AUDIOBOOK_RECAST_TIMEOUT_MS+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}data=await r.json()}}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0;break}view.note(`API Error while checking ${group.length>1?"a group of Unknown lines":"Unknown line"}: ${err.message||err}`);for(const idx of group)view.addSegments([segs[idx]]),done++;continue}if(data&&data.segments){const used=new Set;for(const idx of group){const targetSeg=segs[idx],seqMatch=audiobookFindReturnedSegmentSequence(targetSeg,data.segments,used);if(seqMatch&&seqMatch.segs.length>1){const repl=seqMatch.segs.map(s=>normalizeRecastSegment(s,targetSeg)).filter(s=>(s.text||"").trim()),hasResolved=repl.some(s=>s.type==="narration"||s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)),hasUnresolvedDialogue=repl.some(s=>s.type==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))),hasImplausibleDialogue=repl.some(s=>s.type==="dialogue"&&_abImplausibleDialoguePiece(s.text));if(repl.length&&hasResolved&&!hasUnresolvedDialogue&&!hasImplausibleDialogue){seqMatch.idxs.forEach(i=>used.add(i)),pendingReplacements.set(idx,repl),repl.forEach(s=>{s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)&&!_audiobook.roster.includes(s.speaker)&&_audiobook.roster.push(s.speaker)});continue}}const match=audiobookFindReturnedSegment(targetSeg,data.segments,used);if(match&&match.seg.speaker){const speaker=match.seg.type==="narration"?"Narrator":canonicalizeSpeaker(match.seg.speaker);if(!speaker||/^Unknown|Unbekannt/i.test(speaker))continue;used.add(match.idx),targetSeg.type=match.seg.type==="narration"?"narration":"dialogue",targetSeg.speaker=speaker,targetSeg.emotion=targetSeg.type==="dialogue"&&(match.seg.emotion||targetSeg.emotion)||"",targetSeg.type==="dialogue"&&!_audiobook.roster.includes(speaker)&&_audiobook.roster.push(speaker)}}}for(const idx of group)view.addSegments(pendingReplacements.get(idx)||[segs[idx]]),done++;groupsSinceSave++,groupsSinceSave>=10&&_audiobook.lastText&&(groupsSinceSave=0,_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_audiobook.completedChunks||0,_audiobook.completedTotal||0),view.recountRoster(segs))}view.update(_audiobook.cancel?done:unknownIdxs.length)}catch(e){e.name!=="AbortError"&&view.note("Error defining unknowns: "+e.message)}finally{_audiobook.running=!1,_audiobook.abort=null,_abHideCastingControl()}try{pendingReplacements.size&&[...pendingReplacements.entries()].sort((a,b)=>b[0]-a[0]).forEach(([idx,repl])=>segs.splice(idx,1,...repl));const{segments:deduped,removed:dupRemoved}=_audiobookDedupNearbyDuplicates(segs);dupRemoved&&(segs.splice(0,segs.length,...deduped),view.note(`Removed ${dupRemoved} duplicated line${dupRemoved!==1?"s":""} introduced by this verification pass.`));const afterUnknownCount=countUnknownDialogue(segs);if(afterUnknownCount>beforeUnknownCount){segs.splice(0,segs.length,...originalSegments);const how=_audiobook.cancel?"Stopped run":"Quality run";view.note(`${how} rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`),toast(`${how} rolled back because it increased Unknown speakers.`,"error")}const _rcDone=_audiobook.completedChunks||segs.length,_rcTotal=_audiobook.completedTotal||_rcDone;if(_audiobook.cancel){_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal),view.done({stopped:!0,message:"Character definition stopped. Existing cast preserved."}),toast("Character definition stopped. Existing cast preserved.","info");return}{const _ph=_audiobookDropPhantomSpeakers(segs);_ph.dropped&&console.info("[recast] dropped",_ph.dropped,"phantom speaker line(s):",_ph.names.join(", "))}{const _role=_audiobookResolveRoleAliases(segs,_audiobook.lastText||"");_role.moved&&(console.info("[recast] resolved role labels to names:",_role.pairs.join(", "),`(${_role.moved} lines)`),view.note(`Resolved ${_role.merged} role label${_role.merged!==1?"s":""} to character names (${_role.moved} lines).`))}{const _ident=_audiobookConsolidateSpeakerAliases(segs);_ident.merged&&(console.info("[recast] consolidated",_ident.merged,"speaker label variant(s),",_ident.moved,"line(s)"),view.note(`Merged ${_ident.merged} duplicate character label${_ident.merged!==1?"s":""} (${_ident.moved} lines).`))}{const _dm=_audiobookMergeAdjacentDialogue(segs);_dm.merged&&(segs.splice(0,segs.length,..._dm.segments),console.info("[recast] merged",_dm.merged,"consecutive same-speaker dialogue segment(s)"),view.note(`Merged ${_dm.merged} consecutive same-speaker line${_dm.merged!==1?"s":""}.`))}_abStampConfidence(segs),_audiobook.lastText&&_abSaveDraft(_audiobook.segments||[],_audiobook.roster||[],_audiobook.lastText,_rcDone,_rcTotal);const speakers=new Set(segs.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${segs.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)}catch(e){console.error("[audiobookRecastUnknown] post-processing failed",e),view.done({stopped:!0,message:"Finished checking speakers, but saving/cleanup failed: "+e.message+" \u2014 your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted."}),toast("Casting finished but cleanup failed: "+e.message,"error")}}const AB_AUTO_REPAIR_KEY="ab-auto-repair-enabled";function audiobookAutoRepairEnabled(){try{return localStorage.getItem(AB_AUTO_REPAIR_KEY)!=="0"}catch{return!0}}async function audiobookAutoRepairCast(threshold=10,maxRounds=4){if(!(_audiobook.segments||[]).length){toast("Nothing cast yet to repair","error");return}const countUnknown=()=>(_audiobook.segments||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length;let prev=countUnknown();if(prev<=threshold){toast(`Cast looks clean \u2014 ${prev} unknown speaker${prev!==1?"s":""}`,"success");return}toast(`Auto-repair: ${prev} unknown speakers \u2014 running repair passes\u2026`,"info");for(let round=1;round<=maxRounds;round++){if(await audiobookRecastUnknown(null,null,{repairNarration:!0}),_audiobook.cancel){toast("Auto-repair stopped.","info");return}const now=countUnknown();if(now<=threshold){toast(`Auto-repair done: ${now} unknown left after ${round} round${round!==1?"s":""}.`,"success");return}if(now>=prev)break;prev=now}toast(`2.4 Still ${prev} unknown \u2014 running full verification pass\u2026`,"info"),await audiobookRecastUnknown(null,null,{repairNarration:!0});const afterVerify=countUnknown();if(typeof window._abRunConsistencyPass=="function"&&!_audiobook.cancel){toast("2.5 Checking voice consistency\u2026","info");try{await window._abRunConsistencyPass()}catch(e){console.warn("[auto-repair 2.5]",e)}}const final=countUnknown();toast(final<=threshold?`Auto-repair done: ${final} unknown left.`:`Auto-repair finished: ${final} unknown remain (target was under ${threshold}).`,final<=threshold?"success":"error")}window.audiobookAutoRepairCast=audiobookAutoRepairCast;async function audiobookRecastUntilThreshold(threshold=10,maxPasses=8){if(_audiobook.running){toast("Casting is already running","error");return}const segs=_audiobook.segments;if(!segs||!segs.length){toast("No cast to check","error");return}const countUnknown=()=>(segs||[]).filter(s=>(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length;let prev=countUnknown();if(prev<=threshold){toast(`Already at ${prev} unknown speakers (target: <${threshold})`,"success");return}toast(`Running recast + verify passes until under ${threshold} unknown speakers (currently ${prev})\u2026`,"info");for(let pass=1;pass<=maxPasses;pass++){if(await audiobookRecastUnknown(null,null,{repairNarration:!0}),_audiobook.cancel){toast("Stopped \u2014 cancelled mid-pass.","info");return}const now=countUnknown();if(now<=threshold){toast(`Done: ${now} unknown speakers remain (target reached in ${pass} pass${pass!==1?"es":""}).`,"success");return}if(now>=prev){toast(`Stopped after ${pass} pass${pass!==1?"es":""}: no further progress (${now} unknown remain, target was <${threshold}). Likely GPU/LLM contention or genuinely ambiguous lines.`,"error");return}prev=now}toast(`Stopped after ${maxPasses} passes: ${prev} unknown speakers remain (target was <${threshold}).`,"error")}window.audiobookRecastUntilThreshold=audiobookRecastUntilThreshold;async function audiobookOpenCastView(){var _a2;if(_audiobook.running)return;const text=audiobookScopeText();if(!text){toast("Import a document first","error");return}const _curBook=window.readerState&&readerState.savedId||null;_curBook&&_audiobook.bookId&&_audiobook.bookId!==_curBook&&(_audiobook.segments=null,_audiobook.lastText=null,_audiobook.roster=null),_audiobook.bookId=_curBook;const llm_url=audiobookLlmUrl(),model=audiobookLlmModel(),chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,AUDIOBOOK_CHUNK_CHARS):[text];if(_audiobook.segments&&_audiobook.segments.length>0&&_audiobook.lastText===text){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.rebuild(_audiobook.segments);const speakers=new Set(_audiobook.segments.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} character${speakers.size!==1?"s":""} \xB7 ${_audiobook.segments.length} segments`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown);return}const _applyDraft=(draft,view,source)=>{const draftDone=Number.isFinite(Number(draft.done))?Number(draft.done):0,draftTotal=Number.isFinite(Number(draft.total))?Number(draft.total):0;_abStampSegmentPages(draft.segments,draft.pageMarks,text),_audiobook.segments=draft.segments,_audiobook.roster=draft.roster||[],_audiobook.lastText=text,_audiobook.pageMarks=draft.pageMarks||[],_audiobook.rehId=draft.rehId||null,_audiobook.completedChunks=draftDone>0?draftDone:0,_audiobook.completedTotal=draftTotal>0?draftTotal:0,view.rebuild(draft.segments);const ageMs=Date.now()-(draft.savedAt||0),ageMins=Math.round(ageMs/6e4),ageStr=ageMins<1?"gerade eben":ageMins<60?`vor ${ageMins} Min.`:`vor ${Math.round(ageMins/60)} Std.`,pct=draftTotal>0?Math.max(0,Math.min(100,Math.round(draftDone/draftTotal*100))):100,wasDone=draftTotal>0&&draftDone>=draftTotal,canContinue=draftDone>0&&draftTotal>0&&draftDones.type==="dialogue"&&s.speaker).map(s=>s.speaker)),summary=`${speakers.size} Charakter${speakers.size!==1?"e":""} \xB7 ${draft.segments.length} Segmente`;view.complete(summary,audiobookShowPreview,audiobookCast,audiobookRecastUnknown)},_localDraft=_abLoadDraft(text);if(_localDraft&&_localDraft.segments&&_localDraft.segments.length>0){const view=audiobookCastView(chunks.length,llm_url,model,!1),bookId2=_abBookId();let chosen=_localDraft,source="local",serverDraft=null;if(bookId2){try{serverDraft=await _abLoadDraftServer(bookId2)}catch{}if(serverDraft&&(serverDraft.savedAt||0)>(_localDraft.savedAt||0)){chosen=serverDraft,source="server";try{localStorage.setItem(_abDraftKey(bookId2),JSON.stringify(serverDraft))}catch{}}}_applyDraft(chosen,view,source),bookId2&&source==="local"&&(!serverDraft||(_localDraft.savedAt||0)>(serverDraft.savedAt||0))&&fetch(`/api/reader/docs/${encodeURIComponent(bookId2)}/scripts/cast`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({..._localDraft,bookId:bookId2,title:((_a2=window.readerState)==null?void 0:_a2.title)||_localDraft.title||""})}).catch(()=>{});return}const bookId=_abBookId();if(bookId){const view=audiobookCastView(chunks.length,llm_url,model,!1);view.loadingRestore();try{const serverDraft=await _abLoadDraftServer(bookId);if(serverDraft&&serverDraft.segments&&serverDraft.segments.length>0){try{localStorage.setItem(_abDraftKey(bookId),JSON.stringify(serverDraft))}catch{}_applyDraft(serverDraft,view,"server")}else view.setFreshState("No saved cast was found for this saved book. Starting a new cast will create a new draft.")}catch{view.setFreshState("Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.")}return}audiobookCastView(chunks.length,llm_url,model,!0)}function _abSourceSentences(text){const out=[];for(const line of String(text||"").split(`
-`)){if(!line.trim())continue;const sentences=line.match(/[^.!?]+[.!?]+\s*/g)||[],rest=line.replace(/[^.!?]+[.!?]+\s*/g,"").trim();for(const s of sentences){const t=s.trim();t&&out.push(t)}rest&&out.push(rest)}return out}function _abNormForCoverage(t){return String(t||"").replace(/[«»„“”"'’‚‹›]/g,"").replace(/\s+/g," ").trim().toLowerCase()}function _abRecoverDroppedText(sourceText,segs){if(!segs.length)return{segs,recovered:0};const sentences=_abSourceSentences(sourceText).filter(s=>_abNormForCoverage(s).length>=8);if(!sentences.length)return{segs,recovered:0};const segNorm=segs.map(s=>_abNormForCoverage(s.text)),matchIdx=sentences.map(s=>segNorm.findIndex(t=>t.includes(_abNormForCoverage(s)))),out=[];let copiedUpTo=-1,gap=[],recovered=0;const flushGap=beforeSegIdx=>{if(gap.length){for(;copiedUpTo+1{if(matchIdx[i]===-1){gap.push(s);return}flushGap(matchIdx[i])});copiedUpTo+10&&resume.startIndex0;_audiobook.cancel=!1,isResume||_abClearDraft(),typeof window.setNavCastingBadge=="function"&&window.setNavCastingBadge(!0);const ac=new AbortController;_audiobook.abort=()=>ac.abort(),overrideUrl&&typeof overrideUrl!="string"&&(overrideUrl=null);const llm_url=overrideUrl||audiobookLlmUrl(),language=audiobookLang(text);let model=audiobookSafeLlmModel(overrideModel||audiobookLlmModel());const view=audiobookCastView(chunks.length,llm_url,model,!1);isResume&&resume.segments&&resume.segments.length&&view.rebuild(resume.segments),view.processing("Waking up LLM model (this may take a few minutes if cold-booting)\u2026");try{await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify({text:"Wake up.",known_characters:[],recent:"",language,llm_url:((_a2=document.getElementById("ab-cv-llm-url"))==null?void 0:_a2.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b2=document.getElementById("ab-cv-llm-select"))==null?void 0:_b2.value)||model),timeout_seconds:audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)})},AUDIOBOOK_WARMUP_TIMEOUT_MS+5e3)}catch(err){if(err.name==="AbortError"){_audiobook.cancel=!0,_audiobook.running=!1,_audiobook.abort=null,view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}}const allSegments2=isResume?resume.segments.slice():[];_audiobook.liveSegments=allSegments2;const roster=isResume?(resume.roster||[]).slice():[];let narrationOnly=isResume&&resume.narrationOnly||0,degraded=isResume&&resume.degraded||0,recoveredGaps=isResume&&resume.recoveredGaps||0,completedChunks=startIndex;_abStartDraftAutosave(()=>{allSegments2.length&&_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)});const _pgMarks=(_audiobook.pageMarks||[]).slice();_pgMarks.length&&_pgMarks[0].offset<=2&&_pgMarks.shift();let _pgMarkIdx=0,_pgCharPos=0,_curPageNum=1;if(isResume){for(let k=0;k0&&(_curPageNum=_pgMarks[_pgMarkIdx-1].page+1)}try{for(let i=startIndex;i=_pgMarks[_pgMarkIdx].offset;)_curPageNum=_pgMarks[_pgMarkIdx].page+1,view.pagemark(_curPageNum),_pgMarkIdx++;if(_pgCharPos+=chunks[i].length+1,!audiobookHasDialogue(chunks[i])){const seg={speaker:"Narrator",type:"narration",text:chunks[i],emotion:"",page:_curPageNum};allSegments2.push(seg),narrationOnly++,view.addSegments([seg]),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length);continue}const recent=allSegments2.filter(s=>s.type==="dialogue"&&s.speaker&&!/^Unknown|Unbekannt/i.test(s.speaker)).slice(-6).map(s=>`${s.speaker}: ${(s.text||"").slice(0,80)}`).join(`
-`);view.processing(chunks[i]);const attributeChunk=async(chunkText,recentCtx,timeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS)=>{var _a3,_b3;const body={text:chunkText,known_characters:roster.slice(-40),recent:recentCtx,language,llm_url:((_a3=document.getElementById("ab-cv-llm-url"))==null?void 0:_a3.value.trim())||llm_url,model:audiobookSafeLlmModel(((_b3=document.getElementById("ab-cv-llm-select"))==null?void 0:_b3.value)||model),timeout_seconds:audiobookTimeoutSeconds(timeoutMs)};try{const d=await audiobookAttributeStream(body,view,ac.signal,timeoutMs);return Array.isArray(d.segments)?d.segments:null}catch(err){if(err.name==="AbortError")throw err}try{const r=await audiobookFetchWithTimeout("/api/attribute-dialogue",{method:"POST",headers:{"Content-Type":"application/json"},signal:ac.signal,body:JSON.stringify(body)},timeoutMs+5e3);if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||e.error||r.statusText||"HTTP "+r.status)}const d=await r.json();return Array.isArray(d.segments)?d.segments:null}catch(err){if(err.name==="AbortError")throw err;return{error:err.message}}};let segs;try{let result=await attributeChunk(chunks[i],recent),data=null;if(result&&!result.error)data=result;else if(result&&result.error){view.note(`\u26A0\uFE0F Passage ${i+1} timed out \u2014 retrying in two halves\u2026`);const half=Math.floor(chunks[i].length/2),splitAt=chunks[i].lastIndexOf(" ",half)||half,chunkA=chunks[i].slice(0,splitAt).trim(),chunkB=chunks[i].slice(splitAt).trim(),resA=await attributeChunk(chunkA,recent,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS),resB=await attributeChunk(chunkB,recent,AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS),segsA=Array.isArray(resA)?resA:null,segsB=Array.isArray(resB)?resB:null;if(segsA||segsB){const arrA=segsA||audiobookSplitByQuotes(chunkA),arrB=segsB||audiobookSplitByQuotes(chunkB);data=[...arrA,...arrB],segsA||(degraded++,view.note(`\u26A0\uFE0F Passage ${i+1} (first half) \u2014 auto-detected (retry also failed)`)),segsB||(degraded++,view.note(`\u26A0\uFE0F Passage ${i+1} (second half) \u2014 auto-detected (retry also failed)`))}else view.note(`\u274C Passage ${i+1} \u2014 LLM error: ${result.error}`),data=null}segs=Array.isArray(data)?data:data&&Array.isArray(data.segments)?data.segments:[];const hasDialogue=segs.some(s=>s.type==="dialogue");if(!segs.length||!hasDialogue&&audiobookHasDialogue(chunks[i])){segs=audiobookSplitByQuotes(chunks[i]),degraded++;const named=segs.filter(s=>s.type==="dialogue"&&!/^Unknown|Unbekannt/i.test(s.speaker)).length;view.note(`Passage ${i+1} \u2014 auto-detected dialogue${named?` (${named} speaker${named!==1?"s":""} from tags)`:" (set speakers in review)"}`)}else{let cleanedSegs=[];for(let s of segs){if(s.text=s.text||"",s.type==="dialogue"&&audiobookIsSpeechTagOnly(s.text)&&(s.type="narration",s.speaker="Narrator",s.emotion=""),s.type==="dialogue"&&s.text.trim().length>10){const tText=s.text.trim(),idx=chunks[i].indexOf(tText);if(idx!==-1){const surround=chunks[i].slice(Math.max(0,idx-8),idx)+chunks[i].slice(idx+tText.length,idx+tText.length+8);/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)||(s.type="narration",s.speaker="Narrator",s.emotion="")}}if(s.type==="dialogue"&&_abImplausibleDialoguePiece(s.text)&&(s.type="narration",s.speaker="Narrator",s.emotion=""),cleanedSegs.length>0){let last=cleanedSegs[cleanedSegs.length-1];if(s.text.trim()===last.text.trim())if(s.type==="dialogue"&&last.type!=="dialogue"){cleanedSegs[cleanedSegs.length-1]=s;continue}else{if(s.type!=="dialogue"&&last.type==="dialogue")continue;continue}if(s.type==="dialogue"&&last.type==="narration"){const tS=s.text.trim(),tL=last.text.trim();tL.endsWith(tS)&&(last.text=tL.slice(0,-tS.length).trim(),last.text||cleanedSegs.pop())}const _cut=_abPrefixDupCut(s.text,last.text);_cut>0&&(s.text=s.text.slice(_cut).replace(/^[\s«»„“”"‟‚‘’›‹]*,?\s*/,"").trim(),s.text&&last.type==="dialogue"&&s.type==="dialogue"&&(s.type="narration",s.speaker="Narrator",s.emotion=""))}s.text.trim()&&cleanedSegs.push(s)}let mergedSegs=[];for(let s of cleanedSegs){if(mergedSegs.length>0){let last=mergedSegs[mergedSegs.length-1];if(s.type==="narration"&&last.type==="narration"&&(s.speaker||"Narrator").toLowerCase()==="narrator"&&(last.speaker||"Narrator").toLowerCase()==="narrator"){/[.!?]$/.test(last.text.trim())?last.text=last.text.trimEnd()+`
-
-`+s.text.trimStart():last.text=last.text.trimEnd()+" "+s.text.trimStart();continue}}mergedSegs.push(s)}let respiltSegs=[];for(const s of mergedSegs)s.type==="narration"&&audiobookHasDialogue(s.text)?respiltSegs.push(...audiobookSplitByQuotes(s.text)):respiltSegs.push(s);for(const s of respiltSegs)s.type==="dialogue"&&_abImplausibleDialoguePiece(s.text)&&(s.type="narration",s.speaker="Narrator",s.emotion="");segs=respiltSegs}}catch(chunkErr){if(chunkErr.name==="AbortError")throw chunkErr;degraded++,segs=audiobookSplitByQuotes(chunks[i]),view.note(`\u274C Passage ${i+1} \u2014 unexpected error (${chunkErr.message}) \u2014 auto-detected dialogue instead`)}{const _rec=_abRecoverDroppedText(chunks[i],segs);_rec.recovered&&(segs=_rec.segs,recoveredGaps+=_rec.recovered,view.note(`\u26A0\uFE0F Passage ${i+1} \u2014 recovered ${_rec.recovered} sentence/paragraph gap${_rec.recovered!==1?"s":""} the LLM dropped`))}audiobookResolveUnknowns(segs,allSegments2.slice(-2),roster),segs.forEach(s=>{const speakerName=s.type!=="dialogue"||!s.speaker||s.speaker.toLowerCase()==="narrator"?"Narrator":s.speaker;!/^Unknown|Unbekannt/i.test(speakerName)&&!roster.includes(speakerName)&&roster.push(speakerName)}),segs.forEach(s=>{s.page=_curPageNum,allSegments2.push(s)}),view.addSegments(segs),completedChunks=i+1,_abSaveDraft(allSegments2,roster,text,completedChunks,chunks.length)}view.update(_audiobook.cancel?completedChunks:chunks.length)}catch(err){err.name==="AbortError"?(_audiobook.cancel=!0,view.note("Casting stopped. Completed passages were preserved.")):(_audiobook.cancel=!0,view.note(`\u274C Casting stopped \u2014 unexpected error: ${err.message}`))}finally{_abStopDraftAutosave(),_audiobook.running=!1,_audiobook.abort=null,_abHideCastingControl()}if(!allSegments2.length){if(_audiobook.cancel){view.done({stopped:!0,message:"Casting stopped before any passage completed."}),toast("Casting stopped before any passages were saved.","info");return}view.done(),toast("No segments produced","error");return}try{const _quoteFixedSegs=_audiobookFixOrphanedQuoteMarks(allSegments2),{segments:_rejoined,merged:_mergedCount}=_audiobookMergeSplitSentences(_quoteFixedSegs);_mergedCount&&console.info("[cast] rejoined",_mergedCount,"sentence(s) split by quoted terms");const _mergedSegs=_audiobookMergeAdjacentSameSpeaker(_rejoined);allSegments2.length=0,allSegments2.push(..._mergedSegs);{const _ph=_audiobookDropPhantomSpeakers(allSegments2);_ph.dropped&&console.info("[cast] dropped",_ph.dropped,"phantom speaker line(s):",_ph.names.join(", "))}{const _role=_audiobookResolveRoleAliases(allSegments2,text);if(_role.moved){console.info("[cast] resolved role labels to names:",_role.pairs.join(", "),`(${_role.moved} lines)`);try{toast(`Resolved ${_role.merged} role label${_role.merged!==1?"s":""} to character names (${_role.moved} lines)`,"success")}catch{}}}{const _ident=_audiobookConsolidateSpeakerAliases(allSegments2);if(_ident.merged){console.info("[cast] consolidated",_ident.merged,"speaker label variant(s),",_ident.moved,"line(s)");try{toast(`Merged ${_ident.merged} duplicate character label${_ident.merged!==1?"s":""} (${_ident.moved} lines)`,"success")}catch{}}}_abStampConfidence(allSegments2),_audiobook.segments=allSegments2,_audiobook.lastText=text,_audiobook.roster=roster,_audiobook.narratedPassages=narrationOnly,_audiobook.degraded=degraded,_audiobook.recoveredGaps=recoveredGaps,_audiobook.completedChunks=_audiobook.cancel?completedChunks:chunks.length,_audiobook.completedTotal=chunks.length,_abSaveDraft(allSegments2,roster,text,_audiobook.completedChunks,chunks.length),audiobookSaveAsRehearsal({silent:!0}),_audiobook.cancel&&toast("Casting stopped early. Progress preserved.","info");const speakers=new Set(allSegments2.filter(s=>s.type==="dialogue"&&s.speaker).map(s=>s.speaker)),stoppedEarly=_audiobook.cancel&&completedChunks(s==null?void 0:s.type)==="dialogue"&&(!s.speaker||/^Unknown|Unbekannt/i.test(s.speaker))).length>10&&setTimeout(()=>{audiobookAutoRepairCast().catch(e=>console.warn("[auto-repair]",e))},800)}catch(e){console.error("[audiobookCast] post-processing failed",e),view.done({stopped:!0,message:"Finished casting, but saving/cleanup failed: "+e.message+" \u2014 your progress up to this point is kept in memory; try Save or re-open the book to confirm it persisted."}),toast("Casting finished but cleanup failed: "+e.message,"error")}}async function audiobookOpenCurrentInRehearser(){var _a2;const segs=_audiobook.segments||[];if(!segs.length)return toast("No cast to open","error"),!1;if(typeof window.rehLoadRecord=="function"&&typeof _audiobookBuildRehRecord=="function")try{const rec=await _audiobookBuildRehRecord(segs);return _audiobook.rehId&&(rec.id=_audiobook.rehId),(_a2=document.getElementById("audiobook-preview"))==null||_a2.remove(),typeof navTo=="function"&&navTo("s-rehearser"),window.rehLoadRecord(rec),rehState.lines.length&&typeof buildScriptPage=="function"?(buildScriptPage(),typeof showPhase=="function"&&showPhase(3),typeof highlightCurrentLine=="function"&&highlightCurrentLine()):typeof showPhase=="function"&&showPhase(3),toast("Opened cast in Script Rehearser","success"),audiobookSaveAsRehearsal({silent:!0}).catch(()=>{}),!0}catch(err){console.warn("[audiobook] failed to build rehearser record directly, falling back:",err)}await audiobookSaveAsRehearsal({silent:!0});const{script,emotions}=audiobookBuildScript(segs);return await audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions),rehState.lines.length&&typeof buildScriptPage=="function"?(buildScriptPage(),typeof showPhase=="function"&&showPhase(3),typeof highlightCurrentLine=="function"&&highlightCurrentLine()):typeof showPhase=="function"&&showPhase(3),!0}function audiobookShowPreview(){var _a2;audiobookOpenCurrentInRehearser()}function audiobookApplyPreviewAndOpen(){const segs=_audiobook.segments;document.querySelectorAll("#audiobook-seglist .audiobook-seg-sp").forEach(inp=>{const i=+inp.dataset.i,v=inp.value.trim()||"Narrator";segs[i].speaker=v,segs[i].type=v.toLowerCase()==="narrator"?"narration":"dialogue"}),document.querySelectorAll("#audiobook-seglist .audiobook-seg-emo").forEach(inp=>{const i=+inp.dataset.i;segs[i].emotion=inp.value.trim()});const{script,emotions}=audiobookBuildScript(segs);audiobookOpenInRehearser(script,readerState.title||"Audiobook",emotions)}function audiobookBuildScript(segments){let script="";const emotions=[],marks=_audiobook.pageMarks||[],src=_audiobook.lastText||"";let markIdx=0,searchPos=0;marks.length&&marks[0].offset<=2&&(markIdx=1);for(const s of segments){const t=(s.text||"").trim();if(!t)continue;if(src&&markIdx=0?at:searchPos;for(at>=0&&(searchPos=at+probe.length);markIdx=marks[markIdx].offset;)script.trim()&&(script+=`
-\f${marks[markIdx].page+1}
-`),markIdx++}s.type==="dialogue"&&s.speaker&&s.speaker.toLowerCase()!=="narrator"?(script+=`
-`+s.speaker.toUpperCase()+`
-`+t+`
-`,emotions.push(s.emotion||"")):script+=`
-`+t+`
-`}return{script:script.trim(),emotions}}async function audiobookOpenInRehearser(script,title,dialogueEmotions){$("reh-script-text")&&($("reh-script-text").value=script),$("reh-script-title")&&($("reh-script-title").value=title),typeof navTo=="function"&&navTo("s-rehearser");const btn=$("reh-parse-btn");btn?btn.click():typeof parseScript=="function"&&(rehState.lines=parseScript(script));let speakers=0,lines=0;if(window.rehState&&Array.isArray(rehState.lines)){let k=0;rehState.lines.forEach(l=>{if(l.type==="dialog"){const e=dialogueEmotions[k++];e&&(l.emotion=e),lines++}}),speakers=Object.keys(rehState.cast||{}).filter(s=>!String(s).includes("NARRATOR")).length}let saved=!1;if(typeof saveToLibrary=="function")try{rehState.savedId=null,await saveToLibrary(),saved=!0}catch{}toast(`Cast ${speakers} character${speakers!==1?"s":""} \xB7 ${lines} lines`+(saved?" \u2014 saved to Rehearser \u2192 Bibliothek":""),"success")}function audiobookIsChapter(line){if(line.type==="act"||line.type==="scene")return!0;const t=(typeof stripMarkdown=="function"?stripMarkdown(line.text||""):line.text||"").trim();if(!t||t.length>60)return!1;const NUM=/^(?:[ivxlcdm]+(?=[.\s]|$)|\d{1,3})\.?\s*/i,rest=t.replace(NUM,""),KEYWORD=/^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i;if(!KEYWORD.test(rest))return!1;const afterKeyword=rest.replace(KEYWORD,"").trim();return afterKeyword===""||/^[ivxlcdm\d]{1,6}\.?$/i.test(afterKeyword)?!0:/^[ivxlcdm\d]{0,6}\.?\s*[-:–—]\s*.{1,40}$/i.test(afterKeyword)}const _AB_DE_ORDINAL_STEM_1_19=["","erst","zweit","dritt","viert","f\xFCnft","sechst","siebt","acht","neunt","zehnt","elft","zw\xF6lft","dreizehnt","vierzehnt","f\xFCnfzehnt","sechzehnt","siebzehnt","achtzehnt","neunzehnt"],_AB_DE_CARDINAL_TENS={2:"zwanzig",3:"drei\xDFig",4:"vierzig",5:"f\xFCnfzig",6:"sechzig",7:"siebzig",8:"achtzig",9:"neunzig"},_AB_DE_CARDINAL_ONES=["","ein","zwei","drei","vier","f\xFCnf","sechs","sieben","acht","neun"];function _abGermanOrdinalWord(n,gender){let stem;if(n>=1&&n<=19)stem=_AB_DE_ORDINAL_STEM_1_19[n];else if(n>=20&&n<=99){const tens=_AB_DE_CARDINAL_TENS[Math.floor(n/10)],ones=n%10;if(!tens)return null;stem=(ones?_AB_DE_CARDINAL_ONES[ones]+"und":"")+tens+"st"}else return null;return stem+(gender==="m"?"er":"es")}const _AB_DE_HEADING_GENDER={kapitel:"n",buch:"n",teil:"m"};function _abSpokenChapterHeading(text,language){if(!/^de/i.test(language||""))return text;const m=/^(\d{1,2})\.\s*(Kapitel|Teil|Buch)\b(.*)$/i.exec((text||"").trim());if(!m)return text;const gender=_AB_DE_HEADING_GENDER[m[2].toLowerCase()]||"n",word=_abGermanOrdinalWord(parseInt(m[1],10),gender);return word?`${word[0].toUpperCase()}${word.slice(1)} ${m[2]}${m[3]}`:text}function _abChapterLabels(buckets){const frontMatter=buckets.length>1&&!buckets[0].title;let num=0;return buckets.map((b,i)=>frontMatter&&i===0?"Front matter":"Chapter "+ ++num+(b.title?": "+b.title:""))}function _abPauseSettings(){const num=(key,def)=>{let v;try{v=parseFloat(localStorage.getItem(key))}catch{v=NaN}return Number.isFinite(v)&&v>=0?v:def};return{paragraphMs:num("ttsvc_ab_pause_paragraph_s",2)*1e3,chapterMs:num("ttsvc_ab_pause_chapter_s",4)*1e3}}function _abSetPauseSetting(key,seconds){try{localStorage.setItem(key,String(seconds))}catch{}}function _abChapterSfxDataUrl(){try{return localStorage.getItem("ttsvc_ab_chapter_sfx")||null}catch{return null}}function _abSetChapterSfx(dataUrl){try{dataUrl?localStorage.setItem("ttsvc_ab_chapter_sfx",dataUrl):localStorage.removeItem("ttsvc_ab_chapter_sfx")}catch{toast("Could not save chapter sound \u2014 file may be too large","error")}}async function _abChapterSfxWavBlob(fmt){const dataUrl=_abChapterSfxDataUrl();if(!dataUrl)return null;try{const arrayBuf=await(await fetch(dataUrl)).arrayBuffer(),AC=window.AudioContext||window.webkitAudioContext,OAC=window.OfflineAudioContext||window.webkitOfflineAudioContext,tmpCtx=new AC,decoded=await tmpCtx.decodeAudioData(arrayBuf.slice(0));typeof tmpCtx.close=="function"&&tmpCtx.close();const offline=new OAC(fmt.channels,Math.max(1,Math.ceil(decoded.duration*fmt.sampleRate)),fmt.sampleRate),src=offline.createBufferSource();src.buffer=decoded,src.connect(offline.destination),src.start();const rendered=await offline.startRendering(),frames=rendered.length,pcm=new Int16Array(frames*fmt.channels);for(let ch=0;ch_abSetPauseSetting("ttsvc_ab_pause_paragraph_s",Math.max(0,parseFloat(pEl.value)||0))),cEl.addEventListener("change",()=>_abSetPauseSetting("ttsvc_ab_pause_chapter_s",Math.max(0,parseFloat(cEl.value)||0))),sfxClear==null||sfxClear.addEventListener("click",()=>{_abSetChapterSfx(null),sfxFile&&(sfxFile.value=""),sfxCurrent&&(sfxCurrent.textContent="No custom sound")}),sfxFile==null||sfxFile.addEventListener("change",()=>{const file=sfxFile.files&&sfxFile.files[0];if(!file)return;if(file.size>2*1024*1024){toast("Chapter sound is too large (max 2 MB) \u2014 trim it to a short chime/sting","error"),sfxFile.value="";return}const reader=new FileReader;reader.onload=()=>{_abSetChapterSfx(reader.result),sfxCurrent&&(sfxCurrent.textContent=file.name)},reader.onerror=()=>toast("Could not read that audio file","error"),reader.readAsDataURL(file)}))}window._abInitPauseUI=_abInitPauseUI;function audiobookLineVoice(l){if(l.type==="dialog"){const c=rehState.cast[l.speaker]||{};return{voice:c.voice,instruct:typeof _buildInstruct=="function"?_buildInstruct(c.instruct,l.emotion,c.voice):""}}return{voice:rehState.narratorVoice,instruct:""}}async function _abSynthesizeLongText(voice,text,instruct,backend){const pieces=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,900):[text];if(pieces.length<=1)return fetchTtsPreviewBlob(voice,text,"wav",instruct,backend);const blobs=[];for(const piece of pieces)blobs.push(await fetchTtsPreviewBlob(voice,piece,"wav",instruct,backend));return mergeWavBlobs(blobs,120)}async function audiobookExport(opts={}){var _a2,_b2;if(_abBusyGuard("Export"))return;if(!window.rehState||!(rehState.lines||[]).length){toast("Open a script in the rehearser first","error");return}if(!rehState.backend){toast("Select a TTS backend in the rehearser first","error");return}typeof _ensureNarrator=="function"&&_ensureNarrator();const speakable=i=>{const l=rehState.lines[i];if(!l||l.ignored||l.hidden)return!1;if(l.type==="dialog"){const c=rehState.cast[l.speaker];return!!(c&&c.voice&&c.voice!=="me")}return!!(rehState.narratorVoice&&(l.text||"").trim())},buckets=[];let cur=null;rehState.lines.forEach((l,i)=>{audiobookIsChapter(l)&&(cur={title:(typeof stripMarkdown=="function"?stripMarkdown(l.text):l.text).trim().slice(0,50),idx:[]},buckets.push(cur)),speakable(i)&&(cur||(cur={title:"",idx:[]},buckets.push(cur)),cur.idx.push(i))});let activeBuckets=buckets;opts.selectedChapterIndices&&(activeBuckets=buckets.filter((_,i)=>opts.selectedChapterIndices.includes(i)));const allIdx=activeBuckets.flatMap(b=>b.idx);if(!allIdx.length){toast("Nothing to synthesise \u2014 cast voices first","error");return}const activeLabels=_abChapterLabels(activeBuckets),lineChapterPos=new Map;activeBuckets.forEach((b,pos)=>b.idx.forEach(idx=>lineChapterPos.set(idx,pos)));const progLabel=(doneCount,i)=>{const label=activeLabels[lineChapterPos.get(i)];return(label?label+" \xB7 ":"")+`Synthesising line ${doneCount} / ${allIdx.length}\u2026`};_audiobook.running=!0,_audiobook.cancel=!1;const prog=audiobookProgress(allIdx.length),wavClips=new Map,failedLines=[];let done=0;const queue=allIdx.slice(),worker=async()=>{for(;queue.length&&!_audiobook.cancel;){const i=queue.shift();if(rehState.synthCache.has(i)&&!rehState.staleLines.has(i)){wavClips.set(i,rehState.synthCache.get(i)),prog.update(++done,progLabel(done,i));continue}try{const l=rehState.lines[i],{voice,instruct}=audiobookLineVoice(l);let text=typeof _rehInlineTone=="function"?_rehInlineTone(stripMarkdown(l.text),l.emotion):typeof stripMarkdown=="function"?stripMarkdown(l.text):l.text;l.type!=="dialog"&&audiobookIsChapter(l)&&(text=_abSpokenChapterHeading(text,audiobookLang()));let blob=null,cacheKey=null,book=null;if(typeof _lineAudioCacheKey=="function"&&(book=_lineAudioBookName(),cacheKey=await _lineAudioCacheKey(text,voice,instruct),blob=await _lineAudioCacheGet(book,cacheKey)),!blob){let lastErr;for(let attempt=1;attempt<=3;attempt++)try{blob=await _abSynthesizeLongText(voice,text,instruct,_ttsBackendForVoice(voice,rehState.backend)),lastErr=null;break}catch(e){lastErr=e,attempt<3&&await new Promise(r=>setTimeout(r,3e3*attempt))}if(lastErr)throw lastErr;cacheKey&&_lineAudioCachePut(book,cacheKey,blob)}wavClips.set(i,blob),rehState.synthCache.set(i,blob)}catch(e){failedLines.push(i),console.error("[audiobook export] synth failed for line",i,e)}prog.update(++done,progLabel(done,i))}};try{await worker()}catch(e){console.error("[audiobook export] worker crashed:",e),toast("Export stopped unexpectedly: "+(e.message||e),"error");return}finally{prog.done(),_audiobook.running=!1}if(_audiobook.cancel){toast("Export cancelled","error");return}if(failedLines.length){toast(failedLines.length+" line(s) failed to synthesise \u2014 retry them (Script Rehearser \u2192 re-synthesise stale) before exporting, or they will silently drop from the audiobook","error");return}const title=typeof readerSafeName=="function"?readerSafeName(((_a2=$("reh-script-title"))==null?void 0:_a2.value)||"Audiobook"):((_b2=$("reh-script-title"))==null?void 0:_b2.value)||"Audiobook",realChapters=buckets.filter(b=>b.title).length>0;let files=0;const savedFiles=[],bookForExport=_lineAudioBookName(),_folderRaw=(opts.folder||"").trim().replace(/\/+$/,""),folder=_folderRaw&&_folderRaw.toLowerCase()!=="exports"?_folderRaw:"",folderQS=folder?"?folder="+encodeURIComponent(folder):"";try{const key="ttsvc_export_folder:"+bookForExport;folder?localStorage.setItem(key,folder):localStorage.removeItem(key)}catch{}const encMsg=$("audiobook-msg"),pauses=_abPauseSettings();if(opts.format==="single"){let masterBlobs=[];for(let c=0;cwavClips.get(i)).filter(Boolean);if(!blobs.length)continue;encMsg&&(encMsg.textContent=`Merging chapter ${c+1} / ${activeBuckets.length}\u2026`);let mergedWav=await mergeWavBlobs(blobs,pauses.paragraphMs);if(c>0&&activeBuckets[c].title&&(pauses.chapterMs>0||_abChapterSfxDataUrl())){const ref=_parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt,lead=[],sfx=await _abChapterSfxWavBlob(ref);sfx&&lead.push(sfx),pauses.chapterMs>0&&lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs,ref),ref)),lead.length&&(mergedWav=await mergeWavBlobs([...lead,mergedWav]))}masterBlobs.push(mergedWav)}if(masterBlobs.length){encMsg&&(encMsg.textContent="Encoding full book to MP3\u2026");let finalWav=await mergeWavBlobs(masterBlobs,0),blob=finalWav;try{const encResp=await fetch("/api/audio/encode-mp3",{method:"POST",body:finalWav});encResp.ok&&(blob=await encResp.blob())}catch{}const name=`${title}.${blob===finalWav?"wav":"mp3"}`;typeof readerDownload=="function"&&readerDownload(blob,name);try{const exportUrl=`/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`;(await fetch(exportUrl,{method:"POST",body:blob})).ok&&savedFiles.push({name,url:exportUrl})}catch{}files++}}else for(let c=0;cwavClips.get(i)).filter(Boolean);if(!blobs.length)continue;encMsg&&(encMsg.textContent=`Merging chapter ${c+1} / ${activeBuckets.length}\u2026`);let mergedWav=await mergeWavBlobs(blobs,pauses.paragraphMs);if(c>0&&activeBuckets[c].title&&(pauses.chapterMs>0||_abChapterSfxDataUrl())){const ref=_parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt,lead=[],sfx=await _abChapterSfxWavBlob(ref);sfx&&lead.push(sfx),pauses.chapterMs>0&&lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs,ref),ref)),lead.length&&(mergedWav=await mergeWavBlobs([...lead,mergedWav]))}encMsg&&(encMsg.textContent=`Encoding chapter ${c+1} / ${activeBuckets.length}\u2026`);let blob=mergedWav;try{const encResp=await fetch("/api/audio/encode-mp3",{method:"POST",body:mergedWav});encResp.ok?blob=await encResp.blob():console.error("[audiobook export] mp3 encode failed, shipping wav instead:",encResp.status)}catch(e){console.error("[audiobook export] mp3 encode request failed, shipping wav instead:",e)}const ext=blob===mergedWav?"wav":"mp3",ch=buckets[c].title?" "+readerSafeName(buckets[c].title):"",name=realChapters||buckets.length>1?`${title} - ${String(c+1).padStart(2,"0")}${ch}.${ext}`:`${title}.${ext}`;typeof readerDownload=="function"&&readerDownload(blob,name);try{const saveUrl=`/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`;(await fetch(saveUrl,{method:"POST",body:blob})).ok&&savedFiles.push({name,url:saveUrl})}catch{}files++,await new Promise(r=>setTimeout(r,400))}toast("Exported audiobook \xB7 "+files+(realChapters?" chapter file(s)":" file(s)"),"success"),savedFiles.length&&typeof _abShowExportResults=="function"&&_abShowExportResults(bookForExport,savedFiles,{folder})}function _abShowExportResults(book,files,opts={}){var _a2;(_a2=document.getElementById("ab-export-results"))==null||_a2.remove();const ov=document.createElement("div");ov.id="ab-export-results",ov.className="audiobook-overlay";const folderQS=opts.folder?"?folder="+encodeURIComponent(opts.folder):"",zipUrl=`/api/audiobook-export/${encodeURIComponent(book)}/zip${folderQS}`;ov.innerHTML=`
Saved on the server${opts.browsing?"":", in case the browser\u2019s own download went somewhere you don\u2019t check"} \u2014 come back and download or play any of these again any time, without re-exporting.
`,document.body.appendChild(ov);const audioEl=new Audio;let playingIdx=null;const setIcon=(idx,playing)=>{const icon=ov.querySelector(`.ab-export-file-row[data-idx="${idx}"] .ab-export-play-btn .mdi`);icon&&(icon.className="mdi "+(playing?"mdi-pause":"mdi-play"))},stop=()=>{audioEl.pause(),playingIdx!==null&&setIcon(playingIdx,!1),playingIdx=null};ov.querySelectorAll(".ab-export-play-btn").forEach(btn=>{btn.addEventListener("click",()=>{const idx=parseInt(btn.closest(".ab-export-file-row").dataset.idx,10);if(playingIdx===idx&&!audioEl.paused){stop();return}const prevIdx=playingIdx;audioEl.src=files[idx].url,audioEl.play().catch(e=>toast("Could not play: "+(e.message||e),"error")),prevIdx!==null&&setIcon(prevIdx,!1),playingIdx=idx,setIcon(idx,!0)})}),audioEl.addEventListener("ended",stop);const close=()=>{stop(),ov.remove()};ov.querySelector("#ab-export-close").addEventListener("click",close),ov.addEventListener("click",e=>{e.target===ov&&close()})}async function audiobookBrowseExports(){var _a2;const book=typeof _lineAudioBookName=="function"?_lineAudioBookName():((_a2=$("reh-script-title"))==null?void 0:_a2.value.trim())||"Untitled";let folder="";try{folder=localStorage.getItem("ttsvc_export_folder:"+book)||""}catch{}const folderQS=folder?"?folder="+encodeURIComponent(folder):"";try{const r=await fetch(`/api/audiobook-export/${encodeURIComponent(book)}${folderQS}`);if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const d=await r.json();if(!d.files||!d.files.length){toast('No saved exports yet for this book \u2014 run "Audiobook" first',"info");return}const files=d.files.map(f=>({name:f.name,url:`/api/audiobook-export/${encodeURIComponent(book)}/${encodeURIComponent(f.name)}${folderQS}`}));_abShowExportResults(book,files,{browsing:!0,folder})}catch(e){toast("Could not load saved exports: "+(e.message||e),"error")}}(_qc=$("reh-tb-browse-exports"))==null||_qc.addEventListener("click",audiobookBrowseExports),(_rc=$("reader-audiobook-btn"))==null||_rc.addEventListener("click",audiobookOpenCastView),(_sc=$("reh-tb-audiobook"))==null||_sc.addEventListener("click",audiobookExportDialog);async function _audiobookBuildRehRecord(segs){const{script,emotions}=audiobookBuildScript(segs),title=typeof readerState!="undefined"&&readerState.title?readerState.title:"Audiobook",lines=typeof parseScript=="function"?parseScript(script):[];if(emotions&&emotions.length){let eIdx=0;lines.forEach(l=>{l.type==="dialog"&&eIdx{cast[sp]={voice:def.voice,color:def.color,instruct:"",lang:"",gender:"",tags:"",soul:"",ignored:!1,hidden:!1,voiceData:null}})}let existing=null;if(_audiobook.rehId&&typeof window.rehDbGetById=="function")try{existing=await window.rehDbGetById(_audiobook.rehId)}catch{}existing&&existing.cast&&Object.entries(existing.cast).forEach(([sp,info])=>{cast[sp]?cast[sp]={...cast[sp],...info}:cast[sp]=info});const emotions_map={};return lines.forEach((l,i)=>{l.type==="dialog"&&l.emotion&&(emotions_map[i]=l.emotion)}),{title,script,cast,emotions:emotions_map,notes:existing?existing.notes||{}:{},ignored:existing?existing.ignored||{}:{},hidden:existing?existing.hidden||{}:{},backend:existing&&existing.backend||"",narratorVoice:existing&&existing.narratorVoice||"",lineIndex:existing&&existing.lineIndex||0,clips:existing?existing.clips||[]:[],created:existing?existing.created:new Date,updated:new Date}}let _abRehSaveTimer=null;async function audiobookSaveAsRehearsal(opts){const silent=opts&&opts.silent,segs=_audiobook.segments;if(!segs||!segs.length){silent||toast("No segments to save","error");return}if(typeof rehDbAdd!="function"){silent||toast("Rehearser DB not available","error");return}try{const rec=await _audiobookBuildRehRecord(segs);_audiobook.rehId?(rec.id=_audiobook.rehId,await rehDbPut(rec)):_audiobook.rehId=await rehDbAdd(rec),typeof renderLibraryList=="function"&&renderLibraryList(),silent||(_abClearDraft(),toast("Saved as Script Rehearsal","success"))}catch(e){silent?console.warn("[audiobook] auto-save failed:",e):toast("Failed to save rehearsal: "+e.message,"error")}}function _audiobookDebouncedSave(){clearTimeout(_abRehSaveTimer),_abRehSaveTimer=setTimeout(()=>audiobookSaveAsRehearsal({silent:!0}),1500)}function audiobookExportDialog(){var _a2,_b2,_c2;if(_audiobook.running)return;if(!window.rehState||!(rehState.lines||[]).length){toast("Open a script in the rehearser first","error");return}if(!rehState.backend){toast("Select a TTS backend in the rehearser first","error");return}const speakable=i=>{const l=rehState.lines[i];if(!l||l.ignored||l.hidden)return!1;if(l.type==="dialog"){const c=rehState.cast[l.speaker];return!!(c&&c.voice&&c.voice!=="me")}return!!(rehState.narratorVoice&&(l.text||"").trim())},buckets=[];let cur=null;rehState.lines.forEach((l,i)=>{audiobookIsChapter(l)&&(cur={title:(typeof stripMarkdown=="function"?stripMarkdown(l.text):l.text).trim().slice(0,50),idx:[]},buckets.push(cur)),speakable(i)&&(cur||(cur={title:"",idx:[]},buckets.push(cur)),cur.idx.push(i))});const listEl=document.getElementById("ab-export-chapter-list");if(listEl){listEl.innerHTML="";const countEl=document.getElementById("ab-export-chapter-count");countEl&&(countEl.textContent=buckets.length);const labels=_abChapterLabels(buckets);buckets.forEach((b,i)=>{const lbl=document.createElement("label");lbl.style.display="block",lbl.style.marginBottom="4px";const chk=document.createElement("input");chk.type="checkbox",chk.checked=!0,chk.value=i,chk.className="ab-export-chapter-cb",lbl.appendChild(chk),lbl.appendChild(document.createTextNode(" "+labels[i])),listEl.appendChild(lbl)})}const warn=document.getElementById("reh-tone-warn"),slot=document.getElementById("ab-export-tone-warn-slot");warn&&slot&&(warn.hidden=!1,slot.appendChild(warn)),typeof _abInitPauseUI=="function"&&_abInitPauseUI();const modal=document.getElementById("audiobook-export-modal");modal&&(modal.parentElement!==document.body&&(document.body.appendChild(modal),(_a2=document.getElementById("ab-export-modal-close"))==null||_a2.addEventListener("click",()=>modal.style.display="none"),(_b2=document.getElementById("ab-export-modal-cancel"))==null||_b2.addEventListener("click",()=>modal.style.display="none"),(_c2=document.getElementById("ab-export-modal-start"))==null||_c2.addEventListener("click",()=>{modal.style.display="none";const formatEl=document.querySelector('input[name="ab_export_format"]:checked'),format=formatEl?formatEl.value:"chapter",folderEl=document.getElementById("ab-export-server-folder"),folder=folderEl?folderEl.value:"",selectedChapterIndices=Array.from(document.querySelectorAll(".ab-export-chapter-cb")).filter(cb=>cb.checked).map(cb=>parseInt(cb.value,10));audiobookExport({format,selectedChapterIndices,folder})})),modal.style.display="flex")}async function audiobookFetchWithTimeout(url,options={},timeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const parentSignal=options.signal,ac=new AbortController;let timedOut=!1;const timer=setTimeout(()=>{timedOut=!0,ac.abort()},timeoutMs),onParentAbort=()=>ac.abort(parentSignal==null?void 0:parentSignal.reason);parentSignal&&(parentSignal.aborted?onParentAbort():parentSignal.addEventListener("abort",onParentAbort,{once:!0}));try{return await fetch(url,{...options,signal:ac.signal})}catch(err){if(timedOut){const timeoutErr=new Error(`Timed out after ${Math.ceil(timeoutMs/1e3)}s`);throw timeoutErr.name="TimeoutError",timeoutErr}throw err}finally{clearTimeout(timer),parentSignal&&parentSignal.removeEventListener("abort",onParentAbort)}}function audiobookTimeoutSeconds(timeoutMs){return Math.max(5,Math.round(timeoutMs/1e3))}async function audiobookAttributeStream(body,view,outerSignal,idleTimeoutMs=AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS){const ctl=new AbortController,onAbort=()=>ctl.abort();outerSignal&&(outerSignal.aborted?ctl.abort():outerSignal.addEventListener("abort",onAbort,{once:!0}));let idleTimer=null;const armIdle=()=>{clearTimeout(idleTimer),idleTimer=setTimeout(()=>ctl.abort(),idleTimeoutMs)};try{armIdle();const r=await fetch("/api/attribute-dialogue/stream",{method:"POST",headers:{"Content-Type":"application/json"},signal:ctl.signal,body:JSON.stringify({...body,want_reasoning:!0})});if(!r.ok||!r.body)throw new Error("stream HTTP "+r.status);const reader=r.body.getReader(),dec=new TextDecoder;let buf="",result=null;for(;;){const{done,value}=await reader.read();if(done)break;armIdle(),buf+=dec.decode(value,{stream:!0});let at;for(;(at=buf.indexOf(`
-
-`))>=0;){const line=buf.slice(0,at).trim();if(buf=buf.slice(at+2),!line.startsWith("data:"))continue;let d;try{d=JSON.parse(line.slice(5))}catch{continue}if(d.t&&(view!=null&&view.thinking)&&view.thinking(d.t),d.error)throw new Error(d.error);d.done&&(result=d.result||null)}}if(!result)throw new Error("stream ended without result");return result}catch(err){throw(err==null?void 0:err.name)==="AbortError"&&!(outerSignal&&outerSignal.aborted)?new Error("stream idle timeout"):err}finally{clearTimeout(idleTimer),outerSignal&&outerSignal.removeEventListener("abort",onAbort)}}const CS_CHUNK_CHARS=4e3,_cs={running:!1,cancel:!1,cache:{}},CS_DEFAULT_PROMPT=`You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich character sheets passage by passage as a book is read. Extract playable, action-oriented sheets an actor can use to immediately know how to PLAY the character.
- PROGRESSIVE FILLING: you may be given the sheets built so far. For returning characters, ADD any NEW detail this passage reveals and refine vague fields; do not contradict solid earlier facts or blank out a field you cannot improve. In this cast-character pass, ONLY refine the already-casted roster and do NOT invent new profiles, places, or institutions. The text may be a focused evidence window around a mention, so use the nearby paragraphs as context. Leave a field empty if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue and actions when reasonable, and mark any deduced value with a trailing ' *'.
-For each character output these fields:
-- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.
-- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'the Executioner, Bloodfang, Marcus the Executioner'). Leave empty when uncertain.
- - first_name, last_name: split the character identity when known. Leave unknown parts empty.
- - title: nobility title only, if the text explicitly gives one (e.g. 'Graf', 'Baron', 'Ritter').
- - profession: occupation / job / role in the story (e.g. 'Inquisitor', 'Soldier', 'Merchant', 'Priest').
-- age_estimate: estimated age or age range, if inferable
-- race_species: species, race, or kind (human, elf, ork, vampire, etc.) if relevant
-- languages: spoken languages / dialects / tongues, comma-separated if multiple
-- nationality_background: homeland, culture, origin, or social background if known
-- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.)
-- archetype: a two-word role summary (e.g. 'Ruthless Scholar')
-- gender: 'male', 'female', or 'nonbinary' \u2014 as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.
-- physical: the character's BASELINE appearance \u2014 how they normally look through most of the book. Height, weight, build, hair, eyes, skin, posture, gait, permanent distinguishing features (scars they carry throughout, lasting disabilities), fantasy-specific extras. Use metric units when size/weight is known. Do NOT describe a temporary state: wounds received in one scene, blood, gore, nudity, dirt, being maimed, flayed, transformed, dying or dead, unconscious, or how they look at their worst or most monstrous moment. Those belong in 'notes' or 'arc_note'. If the book only ever describes this character vividly during such a scene, still write their ORDINARY appearance here (clothed, uninjured, in their normal form) and infer it from their role, rank and origin, marking inferred traits with '*'. This field is used verbatim to generate the character's portrait, so a transient injury or transformation written here becomes their permanent face.
-- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look
-- alignment: strict moral code + the one line they will never cross
-- moral_alignment_score: integer 0\u2013100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous
-- arc_direction: one of: 'stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex'
-- arc_note: one sentence explaining the arc or moral position visible so far
-- attribute_high / attribute_low: highest and lowest natural attribute (Charisma, Intelligence, Wisdom, Agility\u2026)
-- skills: what they are demonstrably good at in the story
-- capabilities: combat, magic, social, technical, or other demonstrated abilities
-- backstory: origin, formative background and history revealed in the text
-- relationships: key allies, family, rivals and enemies \u2014 name them and how they relate
-- motivation: the inner drive \u2014 WHY they pursue what they pursue (distinct from the win condition)
-- fears: their deepest fears, phobias or dread
-- mannerisms: habitual gestures, tics, body language, habits and quirks
-- communication_style: how they communicate socially \u2014 blunt, formal, warm, guarded, sarcastic, etc.
-- voice_pattern: speech style \u2014 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.
-- inventory: 1-3 defining items/props/clothing (array of short strings)
-- secret: dark secret or fatal flaw
-- conflict_style: fight, flight, or manipulate \u2014 how they act when cornered
-- win_condition: the specific event that would make them feel they have won
-- reputation: how other characters or society see them
-- religious_beliefs: faith, religion, worship, or lack of belief if the text shows it
-- notes: brief catch-all notes for useful details that do not fit elsewhere
-- tier: 'main' or 'supporting'
-- sources: array of {page, quote, line_hint} \u2014 the page number from the nearest [p.N] marker, a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.
-IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Marcus', 'the Executioner', and 'Bloodfang' may all refer to ONE profile if context shows they are the same person. Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title and keep one canonical name.
-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 \u2014 title, role, profession, race/species, physical trait, relationship, backstory detail, or any other field \u2014 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 \u2014 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":""}]}]}
-/no-think`;function csLlmUrl(){var _a2;const explicit=(_a2=$("reh-llm-url"))==null?void 0:_a2.value.trim();if(explicit)return explicit;const fromSettings=typeof _appSettings!="undefined"&&_appSettings&&_appSettings.llm_url?_appSettings.llm_url:"";return fromSettings&&!/localhost:11434|127\.0\.0\.1:11434/.test(fromSettings)?fromSettings:""}function csLlmModel(){var _a2;return((_a2=$("reh-llm-model"))==null?void 0:_a2.value)||""}function csLang(){var _a2;return((_a2=$("reh-design-lang"))==null?void 0:_a2.value)||""}function csReaderPageHost(){return $("reader-charsheets-panel")}async function csEnsureBookContext(book,text,roster,opts={}){if(!book||!text||typeof _getBookProfile!="function")return{};let existing={};try{existing=await _getBookProfile(book)}catch{}if(!["genre","setting","era","language"].filter(k=>!String((existing==null?void 0:existing[k])||"").trim()).length&&!opts.force)return existing;try{const r=await fetch("/api/book-profile/generate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({book,text,known_characters:Array.isArray(roster)?roster:[],llm_url:csLlmUrl(),model:csLlmModel(),language:csLang()})});if(!r.ok)throw new Error((await r.text()).slice(0,300)||"HTTP "+r.status);const data=await r.json();return typeof _bookProfileCache!="undefined"&&_bookProfileCache.set(book,data.profile||{}),data.profile||existing}catch(e){return console.warn("[book context] automatic generation failed:",e),typeof toast=="function"&&toast("Book context could not be generated; profile casting will continue","info"),existing}}function csReaderText(){var _a2,_b2,_c2;if(typeof readerScopeIndices!="function"||!((_a2=readerState==null?void 0:readerState.sentences)!=null&&_a2.length))return"";let out="",lastPage=-1;for(const i of readerScopeIndices()){const u=readerState.sentences[i],pg=(_c2=(_b2=u.words)==null?void 0:_b2[0])==null?void 0:_c2.page;readerState.mode==="pdf"&&pg!=null&&pg!==lastPage&&(out+=`
-[p.${pg+1}] `,lastPage=pg),out+=u.text+" "}return out.trim()}function csRehearserText(){if(!window.rehState||!(rehState.lines||[]).length)return"";let out="",page=1,started=!1;for(const l of rehState.lines){if(l.type==="pagebreak"){page++,out+=`
-[p.${page}] `;continue}const t=(typeof stripMarkdown=="function"?stripMarkdown(l.text||""):l.text||"").trim();t&&(started||(out+="[p.1] ",started=!0),out+=(l.type==="dialog"&&l.speaker?l.speaker+": ":"")+t+`
-`)}return out.trim()}const CS_SCALAR_FIELDS=["aliases","first_name","last_name","full_name","title","profession","age_estimate","race_species","languages","nationality_background","social_class","archetype","physical","clothing","alignment","arc_note","attribute_high","attribute_low","skills","capabilities","backstory","relationships","motivation","fears","mannerisms","communication_style","voice_pattern","secret","conflict_style","win_condition","reputation","religious_beliefs","notes","voice_design_prompt","image_prompt","silly_tavern_prompt","concept_art_prompt"],CS_DETAIL_FIELDS=CS_SCALAR_FIELDS.filter(f=>!["aliases","first_name","last_name","full_name","title"].includes(f)),CS_IDENTITY_FIELDS=["name","aliases","first_name","last_name","full_name"],CS_ALIAS_MAX_TOKENS=12,CS_ALIAS_MAX_CHARS=500,CS_SOURCE_FIELDS={physical:["physical","appearance","body","look"],clothing:["clothing","appearance","armour","armor","item"],age_estimate:["age","older","young","teen","adult"],race_species:["race","species","kind","orc","elf","human","vampire"],languages:["language","languages","tongue","dialect","speech"],nationality_background:["nationality","background","origin","homeland","culture"],social_class:["class","social","status","rank"],relationships:["relationship","ally","rival","enemy","family"],motivation:["motivation","intention","goal","desire"],fears:["fear","dread"],mannerisms:["mannerism","habit","gesture","voice"],communication_style:["communication","communicate","demeanor","style"],voice_pattern:["voice","speech","dialogue"],backstory:["backstory","origin","history"],alignment:["alignment","ethos","morality"],profession:["profession","occupation","job","role"],skills:["skill","capability","ability"],capabilities:["capability","ability","combat","magic"],reputation:["reputation","known as","status","perceived"],religious_beliefs:["religion","faith","belief","priest","god","gods","temple"],notes:["note","misc","miscellaneous"],secret:["secret","flaw"],conflict_style:["conflict","fight","flight","manipulate"],win_condition:["win","goal"]};function csExistingSummary(map){if(!map.size)return"";const KEY_FIELDS=["age_estimate","race_species","languages","nationality_background","social_class","profession","physical","clothing","communication_style","voice_pattern","backstory","motivation","relationships","reputation","religious_beliefs","notes"];return[...map.values()].slice(0,40).map(s=>{const missing=KEY_FIELDS.filter(f=>!(s[f]||"").trim()),suffix=missing.lengths.trim()).filter(Boolean).filter(s=>s.length<=80&&!/^needs?:/i.test(s)&&!/^complete$/i.test(s));return opts.aliases&&(raw.length>CS_ALIAS_MAX_CHARS||parts.length>CS_ALIAS_MAX_TOKENS)?[]:parts.slice(0,opts.aliases?CS_ALIAS_MAX_TOKENS:void 0)}const CS_ROLE_LABEL_MIN_LINES=4;function csCleanRoster(names,lineCounts){const out=[],seen=new Set,counts=lineCounts instanceof Map?lineCounts:null,isGeneric=typeof _abIsGenericRoleLabel=="function"?_abIsGenericRoleLabel:()=>!1;return(names||[]).forEach(n=>{const name=_csStr(n).trim(),key=name.toLowerCase();!name||seen.has(key)||key==="narrator"||/^unknown|unbekannt$/i.test(name)||counts&&isGeneric(name)&&(counts.get(key)||0){name=_csStr(name).trim();const key=name.toLowerCase();!name||key==="narrator"||/^unknown|unbekannt$/i.test(name)||names.some(n=>n.toLowerCase()===key)||names.push(name)},ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook;return ab&&((ab.roster||[]).forEach(add),[ab.segments,ab.liveSegments].forEach(segs=>{(segs||[]).forEach(s=>{(s==null?void 0:s.type)==="dialogue"&&add(s.speaker)})})),document.querySelectorAll("#ab-cv-chars .ab-char-item[data-name], #audiobook-roster option[value]").forEach(el=>{add(el.dataset.name||el.value||el.textContent)}),csCleanRoster(names)}function csBlankSheet(name){return{name,aliases:"",first_name:"",last_name:"",full_name:"",title:"",profession:"",age_estimate:"",race_species:"",languages:"",nationality_background:"",social_class:"",archetype:"",gender:"",physical:"",clothing:"",alignment:"",arc_note:"",attribute_high:"",attribute_low:"",skills:"",capabilities:"",backstory:"",relationships:"",motivation:"",fears:"",mannerisms:"",communication_style:"",voice_pattern:"",secret:"",conflict_style:"",win_condition:"",reputation:"",religious_beliefs:"",notes:"",voice_design_prompt:"",image_prompt:"",forms:[],active_form_id:"",inventory:[],sources:[],moral_alignment_score:50,arc_direction:"neutral",tier:"supporting"}}function csSeedSheet(raw){const name=_csStr(raw==null?void 0:raw.name).trim(),seed=csBlankSheet(name);if(!name)return seed;raw!=null&&raw.image&&(seed._image=raw.image);const src=raw!=null&&raw.sheet&&typeof raw.sheet=="object"?raw.sheet:raw;if(CS_SCALAR_FIELDS.forEach(f=>{src&&src[f]!=null&&(seed[f]=_csStr(src[f]))}),Array.isArray(src==null?void 0:src.inventory)?seed.inventory=[...src.inventory]:typeof(src==null?void 0:src.inventory)=="string"&&(seed.inventory=src.inventory.split(",").map(x=>x.trim()).filter(Boolean)),Array.isArray(src==null?void 0:src.sources)&&(seed.sources=src.sources.map(item=>item&&typeof item=="object"?{page:item.page!=null?item.page:null,quote:_csStr(item.quote).slice(0,240),line_hint:_csStr(item.line_hint).slice(0,60)}:null).filter(Boolean)),Array.isArray(src==null?void 0: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,12e3),clothing:_csStr(form.clothing).slice(0,12e3),image_prompt:_csStr(form.image_prompt).slice(0,8e3),concept_art_prompt:_csStr(form.concept_art_prompt).slice(0,8e3),inventory:Array.isArray(form.inventory)?form.inventory.filter(x=>typeof x=="string").slice(0,12):[]}))),src!=null&&src.active_form_id&&(seed.active_form_id=String(src.active_form_id).slice(0,120)),src!=null&&src.tier&&(seed.tier=String(src.tier).toLowerCase().startsWith("main")?"main":"supporting"),(src==null?void 0:src.moral_alignment_score)!=null){const mas=parseInt(src.moral_alignment_score,10);Number.isNaN(mas)||(seed.moral_alignment_score=Math.max(0,Math.min(100,mas)))}return src!=null&&src.arc_direction&&(seed.arc_direction=src.arc_direction),src!=null&&src.gender&&(seed.gender=String(src.gender).trim().toLowerCase()),(src==null?void 0:src.line_count)!=null&&(seed.line_count=src.line_count),seed}function csNameTokens(sheet){const out=new Set;for(const f of CS_IDENTITY_FIELDS){const raw=f==="name"?sheet==null?void 0:sheet.name:sheet==null?void 0:sheet[f];csSplitIdentityTokens(raw,{aliases:f==="aliases"}).forEach(s=>out.add(s.toLowerCase()))}return out}function csSheetMatchesRoster(sheet,roster=[]){const allowed=new Set((roster||[]).map(csRosterKey).filter(Boolean));if(!allowed.size)return!0;for(const token of csNameTokens(sheet))for(const name of allowed)if(token===name||token.includes(name)||name.includes(token))return!0;return!1}function csRosterCanonicalName(sheet,roster=[]){const names=csCleanRoster(roster);if(!names.length)return"";const tokens=csNameTokens(sheet);return names.find(n=>tokens.has(n.toLowerCase()))||""}function csCanonicalizeRosterNames(sheets,roster=[]){const canonical=new Map;for(const original of sheets||[]){if(!original)continue;const sheet={...original},rosterName=csRosterCanonicalName(sheet,roster);rosterName&&sheet.name&&sheet.name.toLowerCase()!==rosterName.toLowerCase()&&(sheet.aliases=csMergeAliases({name:rosterName,aliases:sheet.aliases},{name:sheet.name,aliases:""}),sheet.name=rosterName),rosterName&&(sheet._rosterNameAuthoritative=!0);const key=String(sheet.name||"").trim().toLowerCase();key&&(canonical.has(key)?csMerge(canonical,[sheet]):canonical.set(key,sheet))}return[...canonical.values()]}function csIdentityNeedles(value){return csSplitIdentityTokens(value,{aliases:!0}).filter(v=>{const n=String(v||"").trim();return n&&n.length>1&&!/^(die|der|das|den|dem|des|ein|eine|einer|er|sie|es|ich|du|wir|ihr)$/i.test(n)})}function csRecordNeedles(rec){var _a2,_b2,_c2;const seen=new Set,out=[],add=value=>{csIdentityNeedles(value).forEach(v=>{const key=v.toLowerCase();seen.has(key)||(seen.add(key),out.push(v))})};return add(rec==null?void 0:rec.name),add((_a2=rec==null?void 0:rec.sheet)==null?void 0:_a2.aliases),add([(_b2=rec==null?void 0:rec.sheet)==null?void 0:_b2.first_name,(_c2=rec==null?void 0:rec.sheet)==null?void 0:_c2.last_name].filter(Boolean).join(" ")),out}function csParagraphBlocks(text){return String(text||"").replace(/\r\n/g,`
-`).split(/\n\s*\n+/).map(s=>s.trim()).filter(Boolean)}function csReaderParagraphBlocks(){var _a2,_b2,_c2;if(readerState.mode==="text"&&readerState.docText)return csParagraphBlocks(readerState.docText);const base=Array.isArray(readerState.baseSentences)&&readerState.baseSentences.length?readerState.baseSentences:Array.isArray(readerState.sentences)?readerState.sentences:[];if(!base.length)return csParagraphBlocks(csReaderText());const paras=[];let cur="",curPage=null;for(const s of base){const page=(_c2=(_b2=(_a2=s==null?void 0:s.words)==null?void 0:_a2[0])==null?void 0:_b2.page)!=null?_c2:null,pageChanged=readerState.mode==="pdf"&&page!=null&&curPage!=null&&page!==curPage;(s!=null&&s.paraStart||pageChanged)&&cur.trim()&&(paras.push(cur.trim()),cur=""),!cur&&readerState.mode==="pdf"&&page!=null&&(cur+=`[p.${page+1}] `),cur+=(cur&&!/\s$/.test(cur)?" ":"")+String((s==null?void 0:s.text)||"").trim(),curPage=page!=null?page:curPage}return cur.trim()&¶s.push(cur.trim()),paras}function csParagraphHasNeedle(paragraph,needle){const p=String(paragraph||""),n=String(needle||"").trim();if(!n||n.length<2)return!1;const lowerP=p.toLowerCase(),lowerN=n.toLowerCase();if(lowerN.includes(" "))return lowerP.includes(lowerN);const esc=lowerN.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return new RegExp(`(^|[^\\p{L}\\p{N}_])${esc}(?=$|[^\\p{L}\\p{N}_])`,"iu").test(p)}function csEvidenceWindowText(text,needles,before=2,after=2){const paras=Array.isArray(text)?text.slice():csParagraphBlocks(text);if(!paras.length)return String(text||"").trim();const hits=new Set;return paras.forEach((para,idx)=>{if((needles||[]).some(n=>csParagraphHasNeedle(para,n)))for(let i=idx-before;i<=idx+after;i++)i>=0&&ia-b).map(i=>paras[i]).join(`
-
-`).trim():""}function csFindMergeKey(map,sheet){const incoming=csNameTokens(sheet);if(!incoming.size)return(sheet.name||"").trim().toLowerCase();for(const[key,existing]of map.entries()){const existingNames=csNameTokens(existing);for(const n of incoming)if(existingNames.has(n))return key}return(sheet.name||"").trim().toLowerCase()}function csMergeAliases(existing,incoming){const names=new Map,add=v=>csSplitIdentityTokens(v,{aliases:!0}).forEach(s=>names.set(s.toLowerCase(),s));return add(existing.aliases),add(incoming.aliases),incoming.name&&incoming.name!==existing.name&&add(incoming.name),[...names.values()].filter(n=>n.toLowerCase()!==String(existing.name||"").toLowerCase()).join(", ")}function csMergeForms(existing,incoming){const forms=Array.isArray(existing)?existing.map(f=>({...f})):[];return(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),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,12e3),clothing:_csStr(incomingForm.clothing).slice(0,12e3),image_prompt:_csStr(incomingForm.image_prompt).slice(0,8e3),concept_art_prompt:_csStr(incomingForm.concept_art_prompt).slice(0,8e3),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]);value.length>_csStr(found[key]).length&&(found[key]=value.slice(0,key==="name"?160:key.includes("prompt")?8e3:12e3))}),Array.isArray(incomingForm.inventory)&&incomingForm.inventory.forEach(item=>{typeof item=="string"&&item&&!found.inventory.includes(item)&&found.inventory.length<12&&found.inventory.push(item.slice(0,300))})}),forms.slice(0,8)}function csMerge(map,sheets,opts={}){const changed=[];for(const s of sheets){const name=(s.name||"").trim();if(!name)continue;s.aliases=csMergeAliases({name,aliases:""},s);const key=opts.strictRoster?name.toLowerCase():csFindMergeKey(map,s);if(!map.has(key)){const copy={...s,name,forms:csMergeForms([],s.forms),inventory:[...s.inventory||[]],sources:[...s.sources||[]]};CS_SCALAR_FIELDS.forEach(f=>{copy[f]=_csStr(copy[f])}),map.set(key,copy),changed.push({key,name:copy.name,status:"new"});continue}const e=map.get(key),before=JSON.stringify(e),oldAliases=e.aliases;CS_SCALAR_FIELDS.forEach(f=>{const sv=_csStr(s[f]);sv.length>(e[f]||"").length&&(e[f]=sv)}),e.aliases=csMergeAliases({...e,aliases:oldAliases},s),e.forms=csMergeForms(e.forms,s.forms),s.tier==="main"&&(e.tier="main"),s.moral_alignment_score!=null&&(e.moral_alignment_score=e.moral_alignment_score!=null?Math.round((e.moral_alignment_score+s.moral_alignment_score)/2):s.moral_alignment_score),s.arc_direction&&s.arc_direction!=="neutral"&&(e.arc_direction=s.arc_direction),s.gender&&!e.gender&&(e.gender=s.gender),(s.inventory||[]).forEach(it=>{it&&!e.inventory.includes(it)&&e.inventory.length<3&&e.inventory.push(it)}),(s.sources||[]).forEach(src=>{src&&src.quote&&e.sources.length<12&&!e.sources.some(x=>x.quote===src.quote)&&e.sources.push(src)}),JSON.stringify(e)!==before&&changed.push({key,name:e.name,status:"updated"})}return changed}function csConsolidateAliasDuplicates(map,establishedKeys,establishedCreated,roster=[]){let changed=!0;for(;changed;){changed=!1;for(const[key,s]of map){const dupKey=csSplitIdentityTokens(s.aliases,{aliases:!0}).map(a=>a.toLowerCase()).find(a=>a!==key&&map.has(a));if(!dupKey)continue;const other=map.get(dupKey),sEst=establishedKeys.has(key),oEst=establishedKeys.has(dupKey),rosterNames=csCleanRoster(roster),sRoster=rosterNames.findIndex(n=>n.toLowerCase()===key),oRoster=rosterNames.findIndex(n=>n.toLowerCase()===dupKey);let keepKey=key,dropKey=dupKey;if(sRoster>=0&&oRoster>=0&&sRoster!==oRoster)oRoster=0)keepKey=dupKey,dropKey=key;else if(oEst&&!sEst)keepKey=dupKey,dropKey=key;else if(sEst===oEst){const filled=sh=>CS_DETAIL_FIELDS.filter(f=>_csStr(sh[f]).trim()).length,sFilled=filled(s),oFilled=filled(other);if(oFilled>sFilled)keepKey=dupKey,dropKey=key;else if(oFilled===sFilled&&establishedCreated){const sT=establishedCreated.get(key),oT=establishedCreated.get(dupKey);sT!=null&&oT!=null&&oT{const dv=_csStr(drop[f]);dv.length>(keep[f]||"").length&&(keep[f]=dv)}),keep.aliases=csMergeAliases({...keep,aliases:oldAliases},drop),drop.tier==="main"&&(keep.tier="main"),drop.line_count!=null&&(keep.line_count=Math.max(keep.line_count||0,drop.line_count)),(drop.inventory||[]).forEach(it=>{it&&!keep.inventory.includes(it)&&keep.inventory.length<3&&keep.inventory.push(it)}),(drop.sources||[]).forEach(src=>{src&&src.quote&&keep.sources.length<12&&!keep.sources.some(x=>x.quote===src.quote)&&keep.sources.push(src)}),map.set(keepKey,keep),map.delete(dropKey),changed=!0;break}}}function csProgressSnapshot(map,changed=[]){const changedByKey=new Map(changed.map(x=>[x.key,x.status]));return[...map.entries()].map(([key,s])=>{const filled=CS_DETAIL_FIELDS.filter(f=>_csStr(s[f]).trim()).length;return{key,name:s.name||key,alias:[s.title,s.profession].filter(Boolean).join(" \xB7 "),status:changedByKey.get(key)||"",filled,main:s.tier==="main",gender:s.gender||"",lineCount:Number(s.line_count)||0,image:s._image||""}}).sort((a,b)=>(b.status?1:0)-(a.status?1:0)||b.filled-a.filled||a.name.localeCompare(b.name))}async function csGenerateStream(body,onDelta,outerSignal,idleTimeoutMs=6e4){const ctl=new AbortController,onAbort=()=>ctl.abort();outerSignal&&(outerSignal.aborted?ctl.abort():outerSignal.addEventListener("abort",onAbort,{once:!0}));let idleTimer=null;const armIdle=()=>{clearTimeout(idleTimer),idleTimer=setTimeout(()=>ctl.abort(),idleTimeoutMs)};try{armIdle();const r=await fetch("/api/character-sheets/stream",{method:"POST",headers:{"Content-Type":"application/json"},signal:ctl.signal,body:JSON.stringify(body)});if(!r.ok||!r.body)throw new Error("stream HTTP "+r.status);const reader=r.body.getReader(),dec=new TextDecoder;let buf="",result=null;for(;;){const{done,value}=await reader.read();if(done)break;armIdle(),buf+=dec.decode(value,{stream:!0});let at;for(;(at=buf.indexOf(`
-
-`))>=0;){const line=buf.slice(0,at).trim();if(buf=buf.slice(at+2),!line.startsWith("data:"))continue;let d;try{d=JSON.parse(line.slice(5))}catch{continue}if(d.t&&onDelta&&onDelta(d.t),d.error)throw new Error(d.error);d.done&&(result=d.result||null)}}if(!result)throw new Error("stream ended without result");return result}finally{clearTimeout(idleTimer),outerSignal&&outerSignal.removeEventListener("abort",onAbort)}}async function csGenerate(text,cacheKey,initialRoster,opts={}){if(_cs.running)return null;if(!text)return toast("Nothing to analyse","error"),null;const chunks=typeof splitTextIntoChunks=="function"?splitTextIntoChunks(text,CS_CHUNK_CHARS):[text];_cs.running=!0,_cs.cancel=!1;try{const prog=csProgress(chunks.length,opts.pageHost||null),llm_url=csLlmUrl(),model=csLlmModel();let language=csLang();if(typeof detectLang=="function"){const detected=detectLang(text);if(detected&&(!language||/^auto$/i.test(language)||language.toLowerCase()==="english"&&detected.toLowerCase()!=="english")){language=detected;const sel=$("reh-design-lang");if(sel){const o=[...sel.options].find(x=>x.value.toLowerCase()===detected.toLowerCase()||x.textContent.toLowerCase()===detected.toLowerCase());o&&(sel.value=o.value)}}}const map=new Map,lineCounts=opts.lineCounts instanceof Map?opts.lineCounts:new Map,seedSheets=Array.isArray(opts.seedSheets)?opts.seedSheets:[],establishedKeys=new Set,establishedCreated=new Map;seedSheets.forEach(seedRaw=>{const seed=csSeedSheet(seedRaw),name=String(seed.name||"").trim();if(!name)return;const lc=lineCounts.get(name.toLowerCase());lc!=null&&(seed.line_count=lc),map.set(name.toLowerCase(),seed),establishedKeys.add(name.toLowerCase());const created=seedRaw!=null&&seedRaw.created?new Date(seedRaw.created).getTime():NaN;isNaN(created)||establishedCreated.set(name.toLowerCase(),created)});const roster=csCleanRoster(initialRoster,lineCounts),targetMode=roster.length>0,knownTotal=targetMode?roster.length:null;roster.forEach(name=>{const key=name.toLowerCase();if(!map.has(key)){const blank=csBlankSheet(name),lc=lineCounts.get(key);lc!=null&&(blank.line_count=lc),map.set(key,blank)}});try{for(let i=0;iCS_DETAIL_FIELDS.some(f=>(s[f]||"").trim())).length,charLabel=knownTotal?`${knownTotal} cast characters queued \xB7 ${detailed} profiles with details`:`${map.size} characters found`;prog.update(i,`Passage ${i+1} / ${chunks.length}\u2026`,charLabel,csProgressSnapshot(map),[...map.values()]),prog.startPassage(chunks[i]);const maxAttempts=4;for(let attempt=1;attempt<=maxAttempts;attempt++)try{const character_sheets_prompt=typeof _appSettings!="undefined"&&_appSettings.character_sheets_prompt||CS_DEFAULT_PROMPT,body={text:chunks[i],known_characters:roster.slice(0,120),target_mode:targetMode,existing:csExistingSummary(map),language,llm_url,model,character_sheets_prompt};let data=null,sawDelta=!1;try{data=await csGenerateStream(body,delta=>{sawDelta=!0,prog.thinking(delta)}),sawDelta||prog.noStream()}catch{sawDelta||prog.noStream();const r=await fetch("/api/character-sheets",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){let detail=`HTTP ${r.status}`;try{const e=await r.json();detail=e.detail||e.error||detail}catch{}throw new Error(detail)}const raw=await r.text();try{data=JSON.parse(raw)}catch{throw new Error("Invalid JSON from server (passage may be too large)")}}const incomingSheets=targetMode?(data.sheets||[]).filter(s=>roster.some(n=>n.toLowerCase()===String((s==null?void 0:s.name)||"").trim().toLowerCase())):data.sheets||[];incomingSheets.forEach(s=>{const ownName=String(s.name||"").trim().toLowerCase();["first_name","last_name","full_name"].forEach(f=>{const v=String(s[f]||"").trim().toLowerCase();v&&v!==ownName&&roster.some(n=>n.toLowerCase()===v)&&(console.warn(`[character sheets] dropped ${f}="${s[f]}" from "${s.name}" \u2014 matches a different known character`),s[f]="")})});const changed=csMerge(map,incomingSheets,{strictRoster:targetMode});changed.length&&prog.focus(changed[0].name),targetMode||((data.characters||[]).forEach(n=>{roster.some(r=>r.toLowerCase()===String(n||"").toLowerCase())||roster.push(n)}),(data.sheets||[]).forEach(s=>{csNameTokens(s).forEach(n=>{const display=[s.name,s.title,s.profession,s.aliases].filter(Boolean).join(", ").split(/[,;/|]/).map(x=>x.trim()).find(x=>x.toLowerCase()===n)||n;display&&!roster.some(r=>r.toLowerCase()===display.toLowerCase())&&roster.push(display)})}));const newDetailed=[...map.values()].filter(s=>CS_DETAIL_FIELDS.some(f=>(s[f]||"").trim())).length,newCharLabel=knownTotal?`${knownTotal} cast characters queued \xB7 ${newDetailed} profiles with details`:`${map.size} characters found`;prog.update(i+1,`Passage ${i+1} / ${chunks.length}\u2026`,newCharLabel,csProgressSnapshot(map,changed),[...map.values()]);break}catch(e){const msg=e.message||String(e),isRateLimit=/429|too many requests|rate.?limit/i.test(msg),isNetworkError=/failed to fetch|network\s*error|load failed|timed?\s*out|timeout|\b50[234]\b|econnreset|econnrefused|socket hang up|connection (refused|reset|closed)/i.test(msg);if((isRateLimit||isNetworkError)&&attemptsetTimeout(res,waitMs));continue}console.error("Character sheets passage",i+1,"failed:",e),toast("Passage "+(i+1)+" failed: "+msg,"error");break}}}finally{prog.done()}if(_cs.cancel)return toast("Cancelled","error"),null;targetMode||csConsolidateAliasDuplicates(map,establishedKeys,establishedCreated,roster);const sheets=csCanonicalizeRosterNames([...map.values()],roster);return cacheKey&&(_cs.cache[cacheKey]=sheets),sheets}catch(e){return console.error("[character sheets] generation crashed:",e),toast("Character sheet generation crashed: "+(e&&e.message?e.message:String(e)),"error"),null}finally{_cs.running=!1}}function csProgress(total,hostEl=null){var _a2,_b2;const usingHost=!!hostEl;let root=usingHost?hostEl:document.getElementById("cs-progress");if(!root){if(usingHost)return null;root=document.createElement("div"),root.id="cs-progress",root.className="audiobook-overlay",document.body.appendChild(root)}if(!root.querySelector("#cs-progress-fill")){usingHost&&(root.innerHTML=""),root.innerHTML=`
-
-
- Character sheets
-
-
-
Analysing\u2026
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Select a character to preview the sheet as it fills.
-
-
-
-
- Passage / Live output watching\u2026
-
-
-
-
-
-
Passage
-
-
-
-
Live output
-
-
-
-
-
-
-
-
- Characters found
-
-
-
-
-
-
-
-
-
-
-
-
`,(_a2=root.querySelector("#cs-progress-cancel"))==null||_a2.addEventListener("click",()=>{_cs.cancel=!0});const promptBtn=root.querySelector("#cs-prompt-btn"),promptPanel=root.querySelector("#cs-prompt-panel"),promptText=root.querySelector("#cs-prompt-text");promptText&&(promptText.value=typeof _appSettings!="undefined"&&_appSettings.character_sheets_prompt||CS_DEFAULT_PROMPT),promptBtn==null||promptBtn.addEventListener("click",()=>{if(!promptPanel)return;promptPanel.hidden=!promptPanel.hidden;const chevron=root.querySelector("#cs-prompt-chevron");chevron&&(chevron.className=promptPanel.hidden?"mdi mdi-chevron-down":"mdi mdi-chevron-up")});let savedCsPrompts=[];try{savedCsPrompts=JSON.parse(localStorage.getItem("ttsvc_cs_prompts")||"[]")}catch{savedCsPrompts=[]}const libSelect=root.querySelector("#cs-prompt-lib"),delBtn=root.querySelector("#cs-prompt-del"),nameInput=root.querySelector("#cs-prompt-name"),renderCsPromptLib=(selectedIdx=-1)=>{!libSelect||!delBtn||(libSelect.innerHTML=''+savedCsPrompts.map((p,i)=>``).join(""),selectedIdx>=0?(libSelect.value=selectedIdx,delBtn.style.display="inline-flex"):(libSelect.value="",delBtn.style.display="none"))};renderCsPromptLib(),libSelect==null||libSelect.addEventListener("change",()=>{const idx=parseInt(libSelect.value,10);!isNaN(idx)&&savedCsPrompts[idx]?(promptText&&(promptText.value=savedCsPrompts[idx].prompt),nameInput&&(nameInput.value=savedCsPrompts[idx].name),delBtn.style.display="inline-flex"):(nameInput&&(nameInput.value=""),delBtn.style.display="none")}),delBtn==null||delBtn.addEventListener("click",()=>{const idx=parseInt(libSelect.value,10);isNaN(idx)||confirm("Delete this saved prompt preset?")&&(savedCsPrompts.splice(idx,1),localStorage.setItem("ttsvc_cs_prompts",JSON.stringify(savedCsPrompts)),renderCsPromptLib(),toast("Prompt deleted","success"))}),(_b2=root.querySelector("#cs-prompt-save"))==null||_b2.addEventListener("click",async()=>{const val=(promptText==null?void 0:promptText.value.trim())||"";if(!val){toast("Prompt is empty","error");return}const name=(nameInput==null?void 0:nameInput.value.trim())||"Custom Prompt "+(savedCsPrompts.length+1);let targetIdx=parseInt((libSelect==null?void 0:libSelect.value)||"",10);!isNaN(targetIdx)&&savedCsPrompts[targetIdx]&&savedCsPrompts[targetIdx].name===name?savedCsPrompts[targetIdx].prompt=val:(savedCsPrompts.push({name,prompt:val}),targetIdx=savedCsPrompts.length-1),localStorage.setItem("ttsvc_cs_prompts",JSON.stringify(savedCsPrompts)),renderCsPromptLib(targetIdx),toast("Prompt preset saved","success")});let promptSaveTimer=null;promptText==null||promptText.addEventListener("input",()=>{clearTimeout(promptSaveTimer),promptSaveTimer=setTimeout(()=>{const val=promptText.value;typeof _appSettings!="undefined"&&(_appSettings.character_sheets_prompt=val),fetch("/api/settings",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({character_sheets_prompt:val})}).catch(()=>{})},500)})}usingHost||(root.style.display="flex");const fill=root.querySelector("#cs-progress-fill"),msg=root.querySelector("#cs-progress-msg"),chars=root.querySelector("#cs-progress-chars"),sideTitle=root.querySelector("#cs-progress-side-title"),sideDone=root.querySelector("#cs-progress-side-done"),sideCollapse=root.querySelector("#cs-progress-side-collapse"),leftPane=root.querySelector(".cs-progress-left"),outputPanel=root.querySelector("#cs-progress-output"),outputCollapse=root.querySelector("#cs-progress-output-collapse"),outputResize=root.querySelector("#cs-progress-output-resize"),preview=root.querySelector("#cs-progress-preview"),search=root.querySelector("#cs-progress-search"),passagePre=root.querySelector("#cs-progress-passage-pre"),livePre=root.querySelector("#cs-progress-live-pre"),liveStatus=root.querySelector("#cs-progress-live-status");let currentRoster=[],currentSheets=[],selectedName="",searchQuery="",rosterSort=(()=>{try{return localStorage.getItem("ttsvc_cs_side_sort")||"lines"}catch{return"lines"}})();const keyFor=v=>String(v||"").trim().toLowerCase(),findSheet=(name,sheets=currentSheets)=>(Array.isArray(sheets)?sheets:[]).find(s=>keyFor(s==null?void 0:s.name)===keyFor(name))||null;let outputCollapsed=!1,outputExpandedHeight=0;const clampOutputHeight=h=>{const max=Math.max(140,Math.round(window.innerHeight*.82));return Math.max(140,Math.min(max,Math.round(h)))},setOutputHeight=(h,persist=!0)=>{const next=clampOutputHeight(h);if(outputExpandedHeight=next,!outputCollapsed&&outputPanel&&(outputPanel.style.height=`${next}px`,outputPanel.style.maxHeight=`${next}px`,outputPanel.dataset.csHeight=String(next)),persist)try{localStorage.setItem("ttsvc_cs_output_height",String(next))}catch{}},applyOutputCollapse=collapsed=>{if(outputCollapsed=!!collapsed,outputPanel==null||outputPanel.classList.toggle("is-collapsed",outputCollapsed),leftPane==null||leftPane.classList.toggle("is-output-collapsed",outputCollapsed),outputCollapse){const icon=outputCollapse.querySelector(".mdi");icon&&(icon.className="mdi "+(outputCollapsed?"mdi-chevron-up":"mdi-chevron-down")),outputCollapse.title=outputCollapsed?"Expand passage and live output":"Collapse passage and live output"}if(outputPanel)if(outputCollapsed){if(!outputExpandedHeight){const cur=parseInt(outputPanel.dataset.csHeight||"",10)||parseInt(outputPanel.style.height||"",10)||outputPanel.getBoundingClientRect().height;cur&&(outputExpandedHeight=clampOutputHeight(cur))}const collapsedH=138;outputPanel.style.height=`${collapsedH}px`,outputPanel.style.maxHeight=`${collapsedH}px`}else{const h=outputExpandedHeight||parseInt(outputPanel.dataset.csHeight||"",10)||260;outputPanel.style.height=`${clampOutputHeight(h)}px`,outputPanel.style.maxHeight=`${clampOutputHeight(h)}px`,outputPanel.dataset.csHeight=String(clampOutputHeight(h))}try{localStorage.setItem("ttsvc_cs_output_collapsed",outputCollapsed?"1":"0")}catch{}};let sideCollapsed=!1;const applySideCollapse=collapsed=>{var _a3,_b3;if(sideCollapsed=!!collapsed,(_a3=root.querySelector("#cs-progress-layout"))==null||_a3.classList.toggle("side-collapsed",sideCollapsed),(_b3=root.querySelector("#cs-progress-side"))==null||_b3.classList.toggle("is-collapsed",sideCollapsed),sideCollapse){const icon=sideCollapse.querySelector(".mdi");icon&&(icon.className="mdi "+(sideCollapsed?"mdi-chevron-left":"mdi-chevron-right")),sideCollapse.title=sideCollapsed?"Expand character list":"Collapse character list"}try{localStorage.setItem("ttsvc_cs_side_collapsed",sideCollapsed?"1":"0")}catch{}};try{sideCollapsed=localStorage.getItem("ttsvc_cs_side_collapsed")==="1"}catch{}try{const savedOutputCollapsed=localStorage.getItem("ttsvc_cs_output_collapsed");savedOutputCollapsed!==null&&(outputCollapsed=savedOutputCollapsed==="1");const savedOutputHeight=parseInt(localStorage.getItem("ttsvc_cs_output_height")||"",10);!Number.isNaN(savedOutputHeight)&&savedOutputHeight>0&&(outputExpandedHeight=clampOutputHeight(savedOutputHeight))}catch{}if(applySideCollapse(sideCollapsed),applyOutputCollapse(outputCollapsed),sideCollapse==null||sideCollapse.addEventListener("click",()=>applySideCollapse(!sideCollapsed)),outputCollapse==null||outputCollapse.addEventListener("click",()=>applyOutputCollapse(!outputCollapsed)),outputResize){let dragging=!1,startY=0,startH=0;const onMove=e=>{dragging&&(outputCollapsed&&applyOutputCollapse(!1),setOutputHeight(startH-(e.clientY-startY)),e.preventDefault())},stopDrag=()=>{dragging=!1,document.body.classList.remove("cs-output-resizing"),document.removeEventListener("mousemove",onMove),document.removeEventListener("mouseup",stopDrag)};outputResize.addEventListener("mousedown",e=>{e.preventDefault(),outputPanel&&(outputCollapsed&&applyOutputCollapse(!1),dragging=!0,startY=e.clientY,startH=parseInt(outputPanel.dataset.csHeight||"",10)||outputPanel.getBoundingClientRect().height||260,document.body.classList.add("cs-output-resizing"),document.addEventListener("mousemove",onMove),document.addEventListener("mouseup",stopDrag))})}const renderPreview=()=>{var _a3,_b3,_c2,_d2,_e2,_f2;if(!preview)return;const sheet=findSheet(selectedName)||currentSheets[0]||null;if(!sheet){preview.innerHTML='
Select a character to preview the sheet as it fills.
',chars.querySelectorAll(".ab-char-item").forEach(el=>{el.addEventListener("click",()=>{selectedName=keyFor(el.dataset.name),renderPreview()})})};search==null||search.addEventListener("input",()=>{searchQuery=search.value.trim().toLowerCase(),renderChars()});const sortSel=root.querySelector("#cs-progress-sort");return sortSel&&(sortSel.value=rosterSort,sortSel.addEventListener("change",()=>{rosterSort=sortSel.value;try{localStorage.setItem("ttsvc_cs_side_sort",rosterSort)}catch{}renderChars()})),renderPreview(),{startPassage(text){passagePre&&(passagePre.textContent=text||""),livePre&&(livePre.textContent="Waiting for streamed JSON output\u2026"),liveStatus&&(liveStatus.textContent="watching\u2026",liveStatus.className="cs-progress-live-status"),outputPanel==null||outputPanel.classList.add("is-working")},thinking(delta){!livePre||!delta||(liveStatus&&(liveStatus.textContent="streaming",liveStatus.className="cs-progress-live-status is-live"),outputPanel==null||outputPanel.classList.add("is-working"),livePre.textContent=(livePre.textContent+delta).slice(-2e4),livePre.scrollTop=livePre.scrollHeight)},noStream(){liveStatus&&(liveStatus.textContent="no live output for this model",liveStatus.className="cs-progress-live-status"),livePre&&livePre.textContent==="Waiting for streamed JSON output\u2026"&&(livePre.textContent="This model doesn't stream incremental output \u2014 the full result arrives at once when each passage finishes.")},focus(name){name&&(selectedName=keyFor(name),renderPreview())},update(d,label,charLabel,roster=[],sheets=[]){if(fill&&(fill.style.width=d/total*100+"%"),msg&&label&&(msg.textContent=label),sideTitle&&(sideTitle.textContent="Characters found"),sideDone&&(sideDone.innerHTML=charLabel?` ${escHtml(charLabel)}`:""),outputPanel==null||outputPanel.classList.add("is-working"),currentRoster=Array.isArray(roster)?roster:[],currentSheets=Array.isArray(sheets)?sheets:[],!selectedName&¤tRoster.length&&(selectedName=keyFor((currentRoster.find(c=>c.status)||currentRoster[0]||{}).name)),currentSheets.length&&!findSheet(selectedName)){const changedSheet=currentRoster.find(c=>c.status&&findSheet(c.name,currentSheets));changedSheet&&(selectedName=keyFor(changedSheet.name))}renderChars(),renderPreview()},done(){outputPanel==null||outputPanel.classList.remove("is-working"),usingHost||(root.style.display="none")}}}function csAlignmentBar(score,arcDirection,arcNote){if(score==null)return"";const pct=Math.max(0,Math.min(100,score)),arcMap={"stable-good":{arrow:"\u2192",label:"Stable Good",color:"#66bb6a"},"stable-bad":{arrow:"\u2192",label:"Stable Evil",color:"#aaa"},neutral:{arrow:"\u2192",label:"Neutral",color:"#aaa"},"good-to-bad":{arrow:"\u2198",label:"Descends toward evil",color:"#ff7043"},"bad-to-good":{arrow:"\u2197",label:"Redeems toward good",color:"#66bb6a"},complex:{arrow:"\u2195",label:"Complex arc",color:"#ab47bc"}},arc=arcMap[arcDirection]||arcMap.neutral,label=pct>=70?"Good":pct<=30?"Evil":"Neutral/Ambiguous",showArrow=arcDirection&&arcDirection!=="neutral";return`
-
EvilGood
-
-
- ${showArrow?`${arc.arrow}`:""}
-
-
- ${arcNote?`
${escHtml(arc.label)} \xB7 ${escHtml(arcNote)}
`:`
${escHtml(arc.label)}
`}
-
`}function csWorldGroundingClause(bookProfile){const bp=bookProfile||{},bits=[bp.genre,bp.setting,bp.era].filter(Boolean);return bits.length?`WORLD AND PERIOD (authoritative, overrides any era implied above): this character belongs to ${bits.join(", ")}. Every visual choice \u2014 clothing, materials, cut and tailoring, armour, weapons, insignia, jewellery, technology level and architecture \u2014 must fit THAT world and period. A rank or occupation title (Colonel, Admiral, General, Captain) must NOT be drawn as a modern or 19th-century military uniform: no epaulettes, no braided frogging, no peaked caps, no industrial-era tailoring, no machine-made fabric, no zips or modern buttons. Use only what that period could actually make.`:""}function csBuildImagePrompt(s,bookProfile){var _a2;const llm=_csStr(s.image_prompt).trim();if(llm)return llm+" "+csWorldGroundingClause(bookProfile);const parts=[];s.gender&&parts.push(`exactly ${s.gender}`),s.race_species&&parts.push(s.race_species),s.archetype&&parts.push(s.archetype),s.physical&&parts.push(s.physical),s.clothing&&parts.push(s.clothing),s.alignment&&parts.push(s.alignment);const pct=(_a2=s.moral_alignment_score)!=null?_a2:50;parts.push(pct>=70?"benevolent expression":pct<=30?"dark and menacing presence":"ambiguous expression"),s.arc_direction==="bad-to-good"&&parts.push("redemptive aura"),s.arc_direction==="good-to-bad"&&parts.push("ominous aura, turning to darkness");const bp=bookProfile||{},settingBits=[bp.genre,bp.setting,bp.era].filter(Boolean),settingClause=settingBits.length?`This character belongs to the following book/world: ${settingBits.join(", ")}. Every visual choice \u2014 architecture, clothing materials, weaponry, ethnicity mix, technology level \u2014 must fit THAT world, not a generic modern or real-world default. `:"";return`Create a photorealistic live-action square character portrait for an original character named ${s.name}, ${parts.filter(Boolean).join(", ")}. VISUAL IDENTITY LOCK: use the exact gender, age impression, species, face shape, skin tone, hair, eyes, facial hair, distinguishing marks, build, clothing, colors, armor, jewelry, weapons, tools, and signature props established here. Keep one identical face identity anchor for future concept art and profile images. `+settingClause+`Base the setting, era, and art style strictly on the character's own described archetype and clothing above \u2014 do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual choice should fit the world implied by the description, not the real one, unless the description itself is explicitly modern/contemporary. Show a head-and-shoulders portrait facing camera, with a natural in-character expression, on a subdued neutral studio background. Photorealistic cinematic fantasy-film photography: natural skin texture and pores, believable anatomy, authentic cloth/leather/metal, physically plausible lighting and shadows. Not a drawing, painting, anime, game render, plastic skin, collage, text, logo, watermark, or copyrighted character.`}function csBuildVoicePrompt(s){var _a2;if(_csStr(s.voice_design_prompt).trim())return _csStr(s.voice_design_prompt).trim();const parts=[];s.voice_pattern&&parts.push(s.voice_pattern),s.mannerisms&&parts.push(s.mannerisms),s.archetype&&parts.push(`archetype: ${s.archetype}`);const pct=(_a2=s.moral_alignment_score)!=null?_a2:50;return pct>=70?parts.push("warm, trustworthy tone"):pct<=30?parts.push("cold, threatening or sinister tone"):parts.push("neutral, measured tone"),parts.filter(Boolean).join(". ")}async function csDeepAnalysis(sheet,sourceText){const llm_url=csLlmUrl(),model=csLlmModel(),language=csLang(),modal=document.createElement("div");modal.className="audiobook-overlay",modal.innerHTML=`
-
- Deep Analysis \u2014 ${escHtml(sheet.name)}
-
-
-
-
-
- Running deep psychological analysis\u2026
-
-
-
`,document.body.appendChild(modal),modal.querySelector("#cs-deep-close").addEventListener("click",()=>modal.remove()),modal.addEventListener("click",e=>{e.target===modal&&modal.remove()});try{const summary=[sheet.archetype,sheet.alignment,sheet.secret,sheet.win_condition,sheet.conflict_style].filter(Boolean).join("; "),r=await fetch("/api/character-deep-analysis",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:sheet.name,role:sheet.tier,goal:sheet.win_condition,summary,text_excerpt:(sourceText||"").slice(0,8e3),language,llm_url,model})});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.statusText);const a=(await r.json()).analysis||{},section=(icon,title,content)=>content?`
"}).join(""),list.querySelectorAll(".lib-book").forEach(function(el){const id=el.dataset.id,title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||(window._readerStartView="main",typeof navTo=="function"&&navTo("s-reader"),typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(id))});const reh=el.querySelector(".lib-act-rehearse");reh&&reh.addEventListener("click",function(e){e.stopPropagation(),productionOpenInRehearser(title)});const del=el.querySelector(".lib-act-del-book");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete book?","Audio files will be removed.",async function(){try{if(!(await fetch(LIB_READER_API+"/"+id,{method:"DELETE"})).ok)throw new Error("delete failed");toast("Book deleted","success"),libraryRenderBooks()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function libraryRenderPlays(){const list=document.getElementById("lib-plays-list");if(!list)return;list.innerHTML=_libSkeleton(3);let all=[];try{all=typeof rehDbGetAll=="function"?await rehDbGetAll():[]}catch{all=[]}if(!all.length){list.innerHTML='
No theater plays yet.
Open Script Rehearsal \u2192 Import / Export to add a script, or cast a book as an audiobook.
';return}all.sort(function(a,b){return new Date(b.updated||0)-new Date(a.updated||0)}),list.innerHTML=all.map(function(rec){const speakers=Object.keys(rec.cast||{}),total=typeof parseScript=="function"?parseScript(rec.script||"").filter(function(l){return l.type==="dialog"}).length:0,pct=total?Math.round((rec.lineIndex||0)/total*100):0,date=rec.updated?new Date(rec.updated).toLocaleDateString():"\u2014",cov=libBookCover(rec.title||"Untitled"),avatars=speakers.slice(0,5).map(function(sp){return''+(sp[0]||"?").toUpperCase()+""}).join("");return'
'+escHtml(rec.title||"Untitled")+'
'+avatars+"
"+total+" lines \xB7 "+speakers.length+' cast
'+pct+"% \xB7 "+date+"
"}).join(""),list.querySelectorAll(".lib-play").forEach(function(el){const id=parseInt(el.dataset.id,10),title=el.dataset.title;el.addEventListener("click",function(e){e.target.closest(".reh-book-act")||openPlayInRehearser(id)});const ra=el.querySelector(".lib-act-readaloud");ra&&ra.addEventListener("click",function(e){e.stopPropagation(),productionOpenInReader(title)});const del=el.querySelector(".lib-act-del-play");del&&del.addEventListener("click",function(e){e.stopPropagation(),libConfirmDelete(el,"Delete rehearsal?","This cannot be undone.",async function(){try{typeof rehDbDelete=="function"&&await rehDbDelete(id),toast("Rehearsal deleted","success"),libraryRenderPlays()}catch(err){toast(err.message||"Delete failed","error")}})})})}async function openPlayInRehearser(id){try{const rec=typeof rehDbGetById=="function"?await rehDbGetById(id):null;rec&&typeof loadRecord=="function"?loadRecord(rec):toast("Rehearsal not found","error")}catch{toast("Could not open rehearsal","error")}}async function productionOpenInRehearser(title){const key=prodKey(title);try{const match=(typeof rehDbGetAll=="function"?await rehDbGetAll():[]).find(function(p){return prodKey(p.title)===key});if(match){openPlayInRehearser(match.id);return}}catch{}let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&book.kind!=="pdf")try{const sr=await fetch(LIB_READER_API+"/"+book.id+"/source"),text=sr.ok?await sr.text():"";if(text&&typeof audiobookOpenInRehearser=="function"){audiobookOpenInRehearser(text,title,[]);return}}catch{}if(book&&book.kind==="pdf"){typeof readerOpenLibraryDoc=="function"&&readerOpenLibraryDoc(book.id),toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal',"info");return}toast("No source to rehearse for this title yet","error")}async function productionOpenInReader(title){const key=prodKey(title);let books=[];try{const r=await fetch(LIB_READER_API);r.ok&&(books=(await r.json()).docs||[])}catch{}const book=books.find(function(b){return prodKey(b.title)===key});if(book&&typeof readerOpenLibraryDoc=="function"){readerOpenLibraryDoc(book.id);return}typeof navTo=="function"&&navTo("s-reader"),toast("No audiobook for this title yet \u2014 import its source in Read Aloud","info")}async function castForProduction(title){const out={};if(typeof clGetAllByTagOrBook!="function")return out;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{recs=[]}return recs.forEach(function(r){const name=(r.name||"").trim();if(!name)return;const voice=r.voice&&r.voice.id?r.voice.id:typeof r.voice=="string"?r.voice:"";out[name.toLowerCase()]={name,voice:voice||"",gender:r.sheet&&r.sheet.gender||"",soul:r.sheet&&(r.sheet.voice_pattern||r.sheet.motivation)||"",tags:r.tags||""}}),out}window.castForProduction=castForProduction;async function castWriteBack(title,castMap){if(!title||!castMap||typeof clGetAllByTagOrBook!="function"||typeof clPut!="function")return;let recs=[];try{recs=await clGetAllByTagOrBook(title)}catch{return}if(!recs.length)return;const byName={};recs.forEach(function(r){byName[(r.name||"").trim().toLowerCase()]=r});let n=0;for(const sp of Object.keys(castMap)){if(String(sp).includes("NARRATOR"))continue;const voice=(castMap[sp]||{}).voice;if(!voice||voice==="me")continue;const rec=byName[String(sp).trim().toLowerCase()];if(!(!rec||(rec.voice&&rec.voice.id?rec.voice.id:typeof rec.voice=="string"?rec.voice:"")===voice)){rec.voice={id:voice},rec.updated=new Date;try{await clPut(rec),n++}catch{}}}return n}window.castWriteBack=castWriteBack;function libBookCover(title){let h=0;const s=String(title||"Untitled");for(let i=0;i'+heading+'
'+sub+'
',o.addEventListener("click",function(e){e.stopPropagation()}),o.querySelector("[data-lib-cancel]").addEventListener("click",function(e){e.stopPropagation(),o.remove()}),o.querySelector("[data-lib-ok]").addEventListener("click",async function(e){e.stopPropagation(),o.innerHTML='',await onConfirm()}),cardEl.appendChild(o)}window.libraryRenderBooks=libraryRenderBooks,window.libraryRenderPlays=libraryRenderPlays,window.productionOpenInRehearser=productionOpenInRehearser,window.productionOpenInReader=productionOpenInReader,window.prodKey=prodKey;const _CHAR_PROFILE_COMPLETENESS_FIELDS=["gender","age_estimate","race_species","languages","nationality_background","social_class","profession","title","archetype","physical","clothing","alignment","arc_direction","arc_note","attribute_high","attribute_low","skills","capabilities","backstory","relationships","motivation","fears","mannerisms","communication_style","voice_pattern","secret","conflict_style","win_condition","reputation","religious_beliefs","notes","inventory"];window._CHAR_PROFILE_COMPLETENESS_FIELDS=_CHAR_PROFILE_COMPLETENESS_FIELDS;const _CHAR_TABLE_COLUMN_DEFS=[["select","Select"],["avatar","Avatar"],["name","Name"],["gender","Gender"],["age","Age"],["lines","Lines"],["language","Language"],["alignment","Good / Evil"],["profile","Profile"],["voice","Voice"],["rating","Rating"],["prompt","Voice Design Prompt"],["notes","Notes"],["tags","Tags"],["book","Book / Script"],["export","Export"]];function _charTableHiddenColumns(){try{const saved=JSON.parse(localStorage.getItem("ttsvc_libchars_columns")||"{}");return saved&&typeof saved=="object"?saved:{}}catch{return{}}}function _applyCharTableColumns(table,hidden){table&&_CHAR_TABLE_COLUMN_DEFS.forEach(function(def,index){var _a2;const isHidden=hidden[def[0]]===!1;table.querySelectorAll("tr").forEach(function(row){const cell=row.children[index];cell&&cell.classList.toggle("lib-chars-col-hidden",isHidden)});const col=(_a2=table.querySelector("colgroup"))==null?void 0:_a2.children[index];col&&col.classList.toggle("lib-chars-col-hidden",isHidden)})}function _charProfileCompleteness(sheet){const sh=sheet||{};return{filled:_CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function(key){return _libStr(sh[key]).trim().length>0}).length,total:_CHAR_PROFILE_COMPLETENESS_FIELDS.length}}let _libCharsRenderSeq=0;async function _libReviewVisualPairs(button,container){if(!button)return;button.disabled=!0;const oldLabel=button.innerHTML;button.innerHTML=' Reviewing\u2026';try{const response=await fetch("/api/characters/visual-pair-review"),payload=await response.json();if(!response.ok)throw new Error(payload.detail||"Visual-pair review failed");let panel=container.querySelector("#lib-chars-visual-review");panel||(panel=document.createElement("div"),panel.id="lib-chars-visual-review",panel.className="lib-chars-visual-review",container.insertBefore(panel,container.firstChild)),!payload.issues||!payload.issues.length?(panel.innerHTML=' Visual pairs passed the metadata and asset-reuse checks ('+payload.checked+" characters reviewed).",typeof toast=="function"&&toast("Visual pair review passed","success")):(panel.innerHTML=' Review '+payload.issues.length+" character pair"+(payload.issues.length===1?"":"s")+": "+payload.issues.map(function(item){return''+escHtml(item.name||item.id)+" \u2014 "+escHtml(item.issues.join(", "))+""}).join(""),typeof toast=="function"&&toast(payload.issues.length+" visual pair(s) need review","warning"))}catch(error){typeof toast=="function"&&toast(error.message||"Visual-pair review failed","error")}finally{button.disabled=!1,button.innerHTML=oldLabel}}async function libraryRenderCharacters(){var _a2;const container=document.getElementById("lib-chars-list");if(!container)return;const renderSeq=++_libCharsRenderSeq;if((!Array.isArray(window._voices)||!window._voices.length)&&typeof loadVoiceLibrary=="function"){try{await loadVoiceLibrary()}catch(e){console.warn("[characters] voice library load failed",e)}if(renderSeq!==_libCharsRenderSeq)return}let all=[];try{all=typeof clGetAll=="function"?await clGetAll():[]}catch(e){console.warn("[characters] load failed",e),typeof toast=="function"&&toast("Failed to load characters \u2014 keeping the current view","error");return}if(renderSeq!==_libCharsRenderSeq)return;const mainEl=document.getElementById("main-content"),savedScrollTop=!window._libCharsScrollToBook&&mainEl?mainEl.scrollTop:null;container.innerHTML='
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")+'
":""}function _lcdPromptBox(label,value,sheetKey){const has=!!(value&&String(value).trim()),isVoicePrompt=sheetKey==="voice_design_prompt",saveAction='';return'"+escHtml(label)+(isVoicePrompt?' Edit the description to improve the voice':"")+(has||isVoicePrompt?"":' \u2014 not generated yet')+'
This will replace '+escHtml(rec.voice||fileName)+' with a new voice generated from the visible prompt.
':''}()+_lcdPromptBox("Character Image Prompt",sh.image_prompt,"image_prompt")+_lcdPromptBox("SillyTavern Character Prompt",sh.silly_tavern_prompt,"silly_tavern_prompt")+_lcdPromptBox("Concept Art Prompt",sh.concept_art_prompt,"concept_art_prompt")+"
':""),ul.querySelectorAll(".lib-vp-item").forEach(function(item){item.addEventListener("click",async function(){const vid=item.dataset.vid;await clPut(Object.assign({},rec,{voice:vid,updated:new Date})),rec.voice=vid,_syncVoicePictureFromChar(rec),popup.remove(),onDone()})})}renderList(""),popup.querySelector(".lib-vp-input").addEventListener("input",function(e){renderList(e.target.value)}),voices.length===0&&typeof loadVoiceLibrary=="function"&&loadVoiceLibrary().then(function(){popup.isConnected&&(voices=window._voices||[],renderList(popup.querySelector(".lib-vp-input").value||""))}).catch(function(){}),document.body.appendChild(popup);const rect=cardEl.getBoundingClientRect(),popupWidth=260;popup.style.position="fixed",popup.style.left=Math.max(8,Math.min(rect.left,window.innerWidth-popupWidth-8))+"px",popup.style.width=popupWidth+"px";const spaceBelow=window.innerHeight-rect.bottom;spaceBelow>300||spaceBelow>rect.top?popup.style.top=rect.bottom+4+"px":popup.style.bottom=window.innerHeight-rect.top+4+"px",setTimeout(function(){function close(e){popup.contains(e.target)||(popup.remove(),document.removeEventListener("click",close))}document.addEventListener("click",close)},0),popup.querySelector(".lib-vp-input").focus()}async function _findVoiceFromSameCharacterElsewhere(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey)return null;let all=[];try{all=await clGetAll()}catch{return null}const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null,match=all.find(function(r){if(r.id===rec.id||!r.voice||String(r.name||"").trim().toLowerCase()!==nameKey)return!1;const vId=typeof r.voice=="object"?r.voice.id:r.voice;return!(!_voiceExists(vId)||bookCode&&_voiceLangCode(vId)!==bookCode)});return match?{voiceId:typeof match.voice=="object"?match.voice.id:match.voice,book:match.book}:null}function _voiceLangCode(voiceId){const m=/^([A-Za-z]{2,3})_/.exec(String(voiceId||""));return m?m[1].toUpperCase():null}async function _findVoiceByCharacterName(rec){const nameKey=String(rec.name||"").trim().toLowerCase();if(!nameKey||nameKey.length<3)return null;const hits=(window._voices||[]).filter(function(v){return v.enabled!==!1}).filter(function(v){return String(v.id||v.name||"").toLowerCase().includes(nameKey)});if(!hits.length)return null;const bookLang=await _resolveBookLang(rec),bookCode=bookLang&&typeof DESIGN_LANG_CODE!="undefined"?DESIGN_LANG_CODE[bookLang]:null;if(bookCode){const langHits=hits.filter(function(v){return _voiceLangCode(v.id)===bookCode});return langHits.length?langHits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]:null}return hits.sort(function(a,b){return String(b.id).length-String(a.id).length})[0]}async function _autoAssignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}const named=await _findVoiceByCharacterName(rec);if(named){await clPut(Object.assign({},rec,{voice:named.id,updated:new Date})),rec.voice=named.id,_syncVoicePictureFromChar(rec),toast(named.id+" \u2192 "+rec.name+" (matching voice already in the library)","success");return}if(typeof _charAutoDesignVoice=="function"){await _charAutoDesignVoice(rec);return}toast("No matching voice found","error")}function _charLang(rec){const sh=rec.sheet||{},text=[sh.backstory,sh.voice_pattern,sh.mannerisms,sh.relationships,sh.motivation,sh.archetype].filter(Boolean).join(" ");return typeof detectLang=="function"?detectLang(text):""}const _bookProfileCache=new Map;async function _getBookProfile(book){const key=String(book||"").trim();if(!key)return{};if(_bookProfileCache.has(key))return _bookProfileCache.get(key);let profile={};try{const r=await fetch("/api/book-profile?book="+encodeURIComponent(key));r.ok&&(profile=await r.json())}catch(e){console.warn("[book profile]",e)}return _bookProfileCache.set(key,profile),profile}function _getBookProfileSync(book){return _bookProfileCache.get(String(book||"").trim())||{}}async function _saveBookProfile(book,profile){const key=String(book||"").trim(),r=await fetch("/api/book-profile",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Object.assign({book:key,confirmed:!0},profile))});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return _bookProfileCache.set(key,d.profile||profile),d.profile}const _bookLangCache=new Map;async function _resolveBookLang(rec){const book=rec.book||"",profile=await _getBookProfile(book);if(profile&&profile.language)return profile.language;const direct=_charLang(rec);if(direct)return direct;if(_bookLangCache.has(book))return _bookLangCache.get(book);let lang="";try{const siblings=typeof clGetAllByTagOrBook=="function"?await clGetAllByTagOrBook(book):[],counts={};siblings.forEach(function(s){const l=_charLang(s);l&&(counts[l]=(counts[l]||0)+1)});let best="",bestN=0;Object.keys(counts).forEach(function(l){counts[l]>bestN&&(best=l,bestN=counts[l])}),lang=best}catch(e){console.warn("[book lang]",e)}return _bookLangCache.set(book,lang),lang}const _VOICE_TEXTURE_POOL=["a warm, breathy timbre","a bright, clear timbre","a low, husky timbre","a crisp, silvery timbre","a soft, velvety timbre","a slightly nasal, reedy timbre","a rich, resonant timbre","a light, airy timbre"],_VOICE_PACE_POOL=["an unhurried, deliberate pace","a quick, energetic pace","a measured, even pace","a pace that quickens when excited or nervous"];function _hashPick(str,pool){let h=0;for(let i=0;i>>0;return pool[h%pool.length]}function _voiceAudibleSpeciesCue(value){const v=String(value||"").toLowerCase();return/\b(ork|orc|orcs)\b/.test(v)?"Orc: chest-led, gravel-edged resonance; clipped consonants, forceful breath, fully intelligible.":/\b(vampir|vampire)\b/.test(v)?"Vampire: cool velvety timbre, precise diction, restrained predatory calm.":/\b(elf|elv|elfe)\b/.test(v)?"Elf: clear airy resonance, precise musical diction, adult and grounded.":/\b(d[aä]mon|demon|teufel|devil)\b/.test(v)?"Demon: dark resonant timbre with a subtle otherworldly undertone; fully intelligible.":/\b(zwerg|dwarf)\b/.test(v)?"Dwarf: compact earthy resonance, sturdy cadence, deliberate articulation.":/\b(fee|fae|geist|spirit|untot|undead)\b/.test(v)?"Otherworldly: unusual natural resonance, measured breath, emotionally believable.":""}function _voiceAudibleRoleCue(sheet){const v=[sheet.profession,sheet.title,sheet.archetype,sheet.social_class,sheet.capabilities].filter(Boolean).join(" ").toLowerCase();return/krieger|soldat|ritter|wache|kämpfer|warrior|soldier|knight|guard|general|anführer|commander/.test(v)?"Warrior/commander: grounded projection, disciplined breath, concise command cadence.":/schmied|blacksmith|handwerk|artisan|craft/.test(v)?"Craftsperson: practical work-worn texture, measured pace, plain-spoken certainty.":/magier|zauber|hexe|hexer|wizard|mage|sorcer|druid/.test(v)?"Magic-user: measured phrasing, precise diction, contained intensity.":/priester|inquis|mönch|monk|priest|cleric|prophet/.test(v)?"Religious authority: formal diction, calm conviction, weighted pauses.":/könig|königin|baron|graf|adel|noble|lord|lady|herrscher|herrscherin/.test(v)?"High-status authority: polished diction, composed pace, effortless command.":/dieb|spion|assassin|schurke|rogue|thief/.test(v)?"Covert operator: quiet confidence, economical phrasing, alert timing.":/gelehrt|scholar|lehrer|teacher|arzt|heiler|healer/.test(v)?"Learned professional: articulate, patient pace, observant and assured.":""}function _voiceIdentityPrompt(rec,sourcePrompt,langName){const sh=rec.sheet||{},rawGender=String(sh.gender||_genderFromGenericName(rec.name)||"").toLowerCase(),gender=rawGender.startsWith("f")?"female":rawGender.startsWith("m")?"male":rawGender.startsWith("non")?"androgynous/nonbinary":"",hardGender=gender?"Adult "+gender+" voice, clearly "+(gender==="female"?"feminine and never masculine-coded":gender==="male"?"masculine and never feminine-coded":"androgynous")+".":"",age=String(sh.age_estimate||"").trim(),ageCue=age?"Age: "+age.slice(0,36)+".":"",lang=String(langName||"").trim(),languageCue=lang?lang.toLowerCase()==="english"?"Neutral international English.":"Native "+lang+" delivery, never American-accented.":"",species=_voiceAudibleSpeciesCue([sh.race_species,sh.archetype,sh.profession,sh.aliases].filter(Boolean).join(" ")),role=_voiceAudibleRoleCue(sh),prefix=[hardGender,ageCue,languageCue,species,role,"Distinct natural audiobook voice; no imitation."].filter(Boolean).join(" "),base=String(sourcePrompt||"").replace(/\s+/g," ").trim(),room=Math.max(0,480-prefix.length-(prefix&&base?1:0));return(prefix+(base?" "+base.slice(0,room):"")).slice(0,480)}function _buildVoicePrompt(rec,profile,langName){const sh=rec.sheet||{},g=String(sh.gender||"").toLowerCase(),genderWord=g.startsWith("f")?"female":g.startsWith("m")?"male":"",bits=[],lang=String(langName||"").trim();lang&&lang.toLowerCase()!=="english"?bits.push("Speak with an authentic native "+lang+" accent \u2014 not American-accented, not an English speaker doing "+lang+"."):lang&&bits.push("English with a neutral British or international accent, explicitly not American/US-accented.");const settingBits=[profile&&profile.genre,profile&&profile.setting,profile&&profile.era].filter(Boolean);return settingBits.length&&bits.push("Setting: "+settingBits.join(", ")+"."),bits.push("A "+(genderWord?genderWord+" ":"")+"voice"+(sh.archetype?" for "+sh.archetype.toLowerCase():"")+","),bits.push("with "+_hashPick(rec.name||rec.id||"",_VOICE_TEXTURE_POOL)+" and "+_hashPick((rec.name||rec.id||"")+"_pace",_VOICE_PACE_POOL)+"."),sh.voice_pattern&&bits.push(sh.voice_pattern),sh.mannerisms&&bits.push("Mannerisms: "+sh.mannerisms),sh.physical&&bits.push(sh.physical),sh.alignment&&bits.push("Disposition: "+sh.alignment),bits.join(" ").slice(0,600)}function _selectLoose(sel,val){if(!sel||!val)return;const v=String(val).toLowerCase(),opt=[...sel.options].find(function(o){const ov=o.value.toLowerCase(),ot=o.textContent.toLowerCase();return ov===v||ot===v||ov.startsWith(v)||ot.startsWith(v)||v.startsWith(ov)});opt&&(sel.value=opt.value,sel.dispatchEvent(new Event("change")))}function _charSearchOnline(rec){typeof navTo=="function"&&navTo("s-studio");const lang=_charLang(rec);setTimeout(function(){const fishTab=document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');fishTab&&fishTab.click(),setTimeout(function(){const langSel=document.getElementById("fa-lang");langSel&&_selectLoose(langSel,lang);const search=document.getElementById("fa-search");search&&(search.value=rec.name,search.dispatchEvent(new KeyboardEvent("keydown",{key:"Enter",bubbles:!0})))},120)},120),toast("Searching online voices for "+rec.name+(lang?" ("+lang+")":""),"info")}function _editBookProfile(book,afterSave){_getBookProfile(book).then(function(profile){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Book context \u2014 '+escHtml(book)+`
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.
',document.body.appendChild(ov);const close=function(result){ov.remove(),typeof afterSave=="function"&&afterSave(result||null)};ov.querySelector("#bctx-cancel").addEventListener("click",close),ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#bctx-save").addEventListener("click",async function(){const btn=this;btn.disabled=!0;try{const saved=await _saveBookProfile(book,{genre:ov.querySelector("#bctx-genre").value,setting:ov.querySelector("#bctx-setting").value,era:ov.querySelector("#bctx-era").value,language:ov.querySelector("#bctx-lang").value});toast("Book context saved for "+book,"success"),close(saved)}catch(e){toast("Failed to save: "+(e.message||e),"error"),btn.disabled=!1}})})}window.openBookContextEditor=function(book){return new Promise(function(resolve){_editBookProfile(book,resolve)})};function _confirmVoiceReuse(rec,reuse){return new Promise(function(resolve){const ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Existing voice found for '+escHtml(rec.name)+'
"'+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?
',document.body.appendChild(ov);let audioEl=null,audioObjectUrl=null;ov.querySelector("#cvr-play").addEventListener("click",async function(e){const btn=e.currentTarget,icon=btn.querySelector(".mdi");if(audioEl&&!audioEl.paused){audioEl.pause(),icon.className="mdi mdi-play";return}btn.disabled=!0,icon.className="mdi mdi-loading mdi-spin";try{const langHint=typeof _resolveBookLang=="function"?await _resolveBookLang(rec).catch(function(){return""}):"",text=typeof _charSampleTextFor=="function"&&_charSampleTextFor(rec,langHint)||"Hallo, ich bin "+rec.name+".",rv=(window._voices||[]).find(x=>x.id===reuse.voiceId),rBackend=rv&&!rv.has_ref?"voice_design":"voice_clone",blob=await fetchTtsPreviewBlob(reuse.voiceId,text,"wav","",rBackend);audioEl||(audioEl=new Audio,audioEl.addEventListener("ended",function(){icon.className="mdi mdi-play",audioObjectUrl&&(URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=null)})),audioObjectUrl&&URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=URL.createObjectURL(blob),audioEl.src=audioObjectUrl,await audioEl.play(),icon.className="mdi mdi-pause"}catch(err){toast("Could not play sample: "+(err.message||err),"error"),icon.className="mdi mdi-play"}finally{btn.disabled=!1}});const cleanup=function(result){audioEl&&audioEl.pause(),audioObjectUrl&&(URL.revokeObjectURL(audioObjectUrl),audioObjectUrl=null),ov.remove(),resolve(result)};ov.querySelector("#cvr-cancel").addEventListener("click",function(){cleanup("cancel")}),ov.querySelector("#cvr-use").addEventListener("click",function(){cleanup("use")}),ov.querySelector("#cvr-new").addEventListener("click",function(){cleanup("new")}),ov.addEventListener("click",function(e){e.target===ov&&cleanup("cancel")})})}async function _charDesignVoice(rec){const reuse=await _findVoiceFromSameCharacterElsewhere(rec);if(reuse){const choice=await _confirmVoiceReuse(rec,reuse);if(choice==="cancel")return;if(choice==="use"){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec),toast(reuse.voiceId+" \u2192 "+rec.name+' (reused from "'+reuse.book+'" for series consistency)',"success");return}}typeof navTo=="function"&&navTo("s-design");const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(sh.voice_design_prompt).trim();setTimeout(function(){_selectLoose(document.getElementById("design-gender"),sh.gender),_selectLoose(document.getElementById("design-language"),lang);const instruct=document.getElementById("design-instruct");instruct&&(instruct.value=_voiceIdentityPrompt(rec,savedPrompt||_buildVoicePrompt(rec,null,lang),lang));const nm=document.getElementById("design-preset-name");nm&&(nm.value=rec.name)},140),toast("Voice design prepared for "+rec.name+(lang?" \xB7 "+lang:""),"info")}function _charDesignVoiceInline(rec,promptOverride){const sh=rec.sheet||{},lang=_charLang(rec),savedPrompt=_libStr(promptOverride||sh.voice_design_prompt).trim(),instruct=savedPrompt||_voiceIdentityPrompt(rec,_buildVoicePrompt(rec,null,lang),lang),ov=document.createElement("div");ov.className="audiobook-overlay",ov.innerHTML='
Voice design prompt \u2014 '+escHtml(rec.name)+'
Edit the description, then generate a new voice from it. This replaces '+(rec.voice?"the current voice":"this character\u2019s voice")+'.
',document.body.appendChild(ov);const close=function(){ov.remove()};ov.addEventListener("click",function(e){e.target===ov&&close()}),ov.querySelector("#cdi-cancel").addEventListener("click",close);let promptSaveTimer=null;const saveInlinePrompt=async function(text){if(text=String(text||"").trim(),!!text&&(rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,rec.updated=new Date,typeof clPut=="function")){const saved=await clPut(rec);saved&&saved.sheet&&(rec.sheet=saved.sheet)}},promptInput=ov.querySelector("#cdi-instruct");promptInput.addEventListener("input",function(){clearTimeout(promptSaveTimer),promptSaveTimer=setTimeout(function(){saveInlinePrompt(promptInput.value).catch(function(err){console.warn("[character prompt] autosave failed",err)})},600)}),ov.querySelector("#cdi-save").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;clearTimeout(promptSaveTimer),btn.disabled=!0,btn.innerHTML=' Saving\u2026';try{await saveInlinePrompt(promptInput.value),toast("Voice design prompt saved","success")}catch(err){toast("Prompt save failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),ov.querySelector("#cdi-rewrite").addEventListener("click",async function(e){const btn=e.currentTarget,original=btn.innerHTML;btn.disabled=!0,btn.innerHTML=' Rewriting\u2026';try{const target=typeof statusLlmTarget=="function"?statusLlmTarget():{url:"",model:""},sample=[sh.physical,sh.backstory,sh.motivation].filter(Boolean).join(" "),language=typeof detectLang=="function"&&sample&&detectLang(sample)||"",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:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},sheet:sh,language,llm_url:target.url,model:target.model,fields:["voice_design_prompt"]})});if(!r.ok)throw new Error((await r.json().catch(function(){return{}})).detail||r.statusText);const d=await r.json();if(!d.voice_design_prompt)throw new Error("The rewrite returned an empty prompt");ov.querySelector("#cdi-instruct").value=d.voice_design_prompt,await saveInlinePrompt(d.voice_design_prompt),toast("Voice design prompt rewritten \u2014 review it, then generate the voice","success")}catch(err){toast("Prompt rewrite failed: "+(err.message||err),"error")}finally{btn.disabled=!1,btn.innerHTML=original}}),ov.querySelector("#cdi-generate").addEventListener("click",async function(e){const btn=e.currentTarget,text=ov.querySelector("#cdi-instruct").value.trim();if(!text){toast("Prompt is empty","error");return}clearTimeout(promptSaveTimer),btn.disabled=!0;const icon=btn.querySelector(".mdi");icon&&(icon.className="mdi mdi-loading mdi-spin");try{await saveInlinePrompt(text),await _charAutoDesignVoice(rec,!0,text),_schedulePendingTtsRestart(),rec.sheet||(rec.sheet={}),rec.sheet.voice_design_prompt=text,close(),toast("New voice designed for "+rec.name,"success");const detailPage=document.querySelector(".lib-char-page"),detailOpts=window._libDetailOpts;if(detailPage&&detailOpts&&typeof _charDetailPage=="function"){const all=typeof clGetAll=="function"?await clGetAll().catch(function(){return[]}):[],updated=all.find(function(r){return r.id===rec.id})||rec;_charDetailPage(updated,all.filter(function(r){return r.book===updated.book}),detailOpts)}else typeof libraryRenderCharacters=="function"&&await libraryRenderCharacters();typeof _libRefreshDetailModal=="function"&&_libRefreshDetailModal(rec)}catch(err){toast("Voice design failed: "+(err.message||err),"error"),btn.disabled=!1,icon&&(icon.className="mdi mdi-creation")}})}function _charCloneVoice(rec){typeof navTo=="function"&&navTo("s-clone"),setTimeout(function(){const nm=document.getElementById("clone-your-name");nm&&(nm.value=rec.name)},140),toast("Clone a Voice prepared for "+rec.name+" \u2014 pick a mic take, file, or YouTube URL","info")}const _DESIGN_SAMPLE_FALLBACK={German:"Ich habe lange auf diesen Moment gewartet, und jetzt, da er da ist, wei\xDF ich genau, was zu tun ist.",English:"I have waited a long time for this moment, and now that it is here, I know exactly what to do."};function _charRealLine(rec){const ab=typeof _audiobook!="undefined"?_audiobook:window._audiobook,nameLower=String(rec.name||"").trim().toLowerCase(),line=(ab&&ab.segments||[]).find(function(s){return s&&s.type==="dialogue"&&String(s.speaker||"").trim().toLowerCase()===nameLower&&s.text&&s.text.trim().length>=20&&s.text.trim().length<=200});if(line)return line.text.trim();const quotes=(Array.isArray(rec.sheet&&rec.sheet.sources)?rec.sheet.sources:[]).map(function(s){return s&&s.quote?String(s.quote).trim():""}).filter(function(q){return q.length>=20&&q.length<=240}),spoken=quotes.find(function(q){return/[""„"]/.test(q)});return spoken||(quotes.length?quotes[0]:null)}function _charSampleTextFor(rec,langNameHint){const lang=_charLang(rec)||langNameHint||"English",greeting=lang==="German"?`Hallo, ich bin ${rec.name}.`:`Hello, I am ${rec.name}.`,line=_charRealLine(rec);return line?`${greeting} ${line}`:_DESIGN_SAMPLE_FALLBACK[lang]||_DESIGN_SAMPLE_FALLBACK.English}const _GENERIC_NAME_GENDER={frau:"female",dame:"female",junge_frau:"female",m\u00E4dchen:"female",maedchen:"female",mann:"male",herr:"male",junge:"male",knabe:"male"};function _genderFromGenericName(name){const key=String(name||"").trim().toLowerCase().replace(/\s+/g,"_");return _GENERIC_NAME_GENDER[key]||""}let _voiceRestartPending=!1;async function _flushPendingTtsRestart(){if(_voiceRestartPending){_voiceRestartPending=!1;try{const r=await fetch("/api/tts/restart",{method:"POST"});r.ok?toast("TTS backend restarted to pick up the newly designed voice(s)","success"):console.warn("[tts restart] failed:",r.status)}catch(e){console.warn("[tts restart]",e)}}}let _voiceRestartDebounceTimer=null;function _schedulePendingTtsRestart(){clearTimeout(_voiceRestartDebounceTimer),_voiceRestartDebounceTimer=setTimeout(_flushPendingTtsRestart,4e3)}async function _fetchRetryingNetworkErrors(url,opts,tries){tries=tries||3;for(let i=1;i<=tries;i++)try{return await fetch(url,opts)}catch(e){if(i===tries)throw e;await new Promise(function(r){setTimeout(r,2500*i)})}}function _designBenchmarkWpmBad(b){if(!b||!b.ok||!b.audio_sec||!b.text)return!1;const wpm=String(b.text).trim().split(/\s+/).length/(b.audio_sec/60);return wpm<80||wpm>400}async function _charAutoDesignVoice(rec,force,instructOverride){const reuse=force||instructOverride?null:await _findVoiceFromSameCharacterElsewhere(rec);if(reuse&&reuse.voiceId!==rec.voice){await clPut(Object.assign({},rec,{voice:reuse.voiceId,updated:new Date})),rec.voice=reuse.voiceId,_syncVoicePictureFromChar(rec);return}const sh=rec.sheet||{},langName=await _resolveBookLang(rec)||"English",instruct=instructOverride?_libStr(instructOverride).trim():_voiceIdentityPrompt(rec,_libStr(sh.voice_design_prompt).trim()||_buildVoicePrompt(rec,await _getBookProfile(rec.book),langName),langName);if(!instruct.trim())throw new Error("No character description to design a voice from yet");instructOverride&&(sh.voice_design_prompt=instruct);const langCode=typeof DESIGN_LANG_CODE!="undefined"&&DESIGN_LANG_CODE[langName]||"EN",genderWord=String(sh.gender||"").toLowerCase()||_genderFromGenericName(rec.name),genderLetter=genderWord.startsWith("f")?"F":genderWord.startsWith("m")?"M":"N",sampleText=_charSampleTextFor(rec,langName),dialogue=typeof isDialogueDesign=="function"?isDialogueDesign(instruct,sampleText,null):!1,baseName=typeof designSafeName=="function"?designSafeName(rec.name):(typeof _umlautSafe=="function"?_umlautSafe(rec.name||"VoiceDesign"):String(rec.name||"VoiceDesign")).replace(/[^A-Za-z0-9]+/g,"_"),voiceId=(langCode+"_"+genderLetter+"_"+baseName).slice(0,96),maxAttempts=3;let saved=null;for(let attempt=1;attempt<=maxAttempts;attempt++){const r1=await _fetchRetryingNetworkErrors("/api/voice-design",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({instruct,sample_text:sampleText,language:langName,gender:genderLetter,dialogue})});if(!r1.ok){const e=await r1.json().catch(function(){return{}});throw new Error(e.detail||r1.statusText)}const designed=await r1.json(),tryId=(voiceId+"__try"+attempt).slice(0,96),r2=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:tryId,transcript:sampleText})});if(!r2.ok){const e=await r2.json().catch(function(){return{}});throw new Error(e.detail||r2.statusText)}await r2.json();let bad=!1;if(typeof runVoiceBenchmark=="function")try{const d=await runVoiceBenchmark(tryId,{text:sampleText}),hit=(d&&d.voices||[]).find(function(x){return x.voice_id===tryId}),b=hit&&hit.benchmark;!b||!b.ok&&/connection (refused|reset|aborted)|max retries exceeded|newconnectionerror|econnrefused|timed? ?out/i.test(String(b.error||""))?console.warn("[voice design] benchmark unreachable, accepting unverified:",b&&b.error):bad=!!(b.clipped||_designBenchmarkWpmBad(b))}catch(e){console.warn("[voice benchmark]",e)}if(!bad&&typeof _voiceRoundtripCheck=="function")try{const rt=await _voiceRoundtripCheck(tryId,sampleText,"voice_design");rt.score<.5&&(bad=!0,console.warn("[voice design] STT roundtrip mismatch (score "+rt.score.toFixed(2)+'): said "'+rt.transcript+'"'))}catch(e){console.warn("[voice design roundtrip]",e)}if(!bad){const r3=await _fetchRetryingNetworkErrors("/api/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:designed.id,voice_id:voiceId,transcript:sampleText})});if(!r3.ok){const e=await r3.json().catch(function(){return{}});throw new Error(e.detail||r3.statusText)}saved=await r3.json(),saved.needs_tts_restart&&(_voiceRestartPending=!0),await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){});break}if(await fetch("/api/voice/"+encodeURIComponent(tryId),{method:"DELETE"}).catch(function(){}),attempt===maxAttempts)throw new Error('Voice design for "'+voiceId+'" produced broken audio after '+maxAttempts+" attempts \u2014 left the previous voice in place, try again later")}return typeof saveMeta=="function"&&await saveMeta(saved.voice_id,{gender:genderLetter,flag:typeof LANG_FLAG_DEFAULT!="undefined"?LANG_FLAG_DEFAULT[langCode]:void 0,origin:"designed",group:rec.book||void 0,tag:rec.book||void 0,transcript:sampleText,voice_design_prompt:instruct}).catch(function(){}),rec.voice=saved.voice_id,await clUpsert(rec.book,Object.assign({},rec.sheet,{name:rec.name,voice:saved.voice_id}),rec.id),_syncVoicePictureFromChar(rec),saved.voice_id}function _libActiveCharacterForm(rec){const sheet=rec&&rec.sheet&&typeof rec.sheet=="object"?rec.sheet:{},forms=Array.isArray(sheet.forms)?sheet.forms:[],activeId=String(sheet.active_form_id||"").trim();return activeId&&forms.find(function(form){return String((form==null?void 0:form.id)||"")===activeId})||null}async function _libSaveCharacterVisual(rec,fields){const target=(typeof clGet=="function"?await clGet(rec.id).catch(function(){return null}):null)||rec,form=_libActiveCharacterForm(target);return form?(target.sheet=Object.assign({},target.sheet||{}),target.sheet.forms=Array.isArray(target.sheet.forms)?target.sheet.forms.map(function(item){return String((item==null?void 0:item.id)||"")===String(form.id||"")?Object.assign({},item,fields):item}):[]):Object.assign(target,fields),target.updated=new Date,typeof clPut=="function"&&await clPut(target),target!==rec&&(rec.image=target.image,rec.sheet=target.sheet),target}async function _charAutoGenerateImage(rec,provider){const form=_libActiveCharacterForm(rec),sh=form?Object.assign({},rec.sheet||{},form):rec.sheet||{},hasExplicitPrompt=!!_libStr(sh.image_prompt).trim();if(!hasExplicitPrompt&&[sh.archetype,sh.physical,sh.clothing].filter(Boolean).join(" ").trim().length<20)throw new Error("Not enough character detail to generate a meaningful portrait \u2014 skipped instead of using a generic placeholder");const bookProfile=typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},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,image_kind:"portrait"};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});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);updatedForm&&(updatedForm.image=d.image)}else typeof clSetImage=="function"&&await clSetImage(rec.id,d.image),rec.image=d.image;return document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''}),_syncVoicePictureFromChar(rec),d.image}const _CIJ_STEPS=[{key:"identity",label:"Identity portrait"},{key:"render",label:"Turnaround, scene & props"}];function _cijShowPanel(name){var _a2;(_a2=document.getElementById("cij-progress-panel"))==null||_a2.remove();const panel=document.createElement("div");panel.id="cij-progress-panel",panel.className="cij-progress-panel";const stepsHtml=_CIJ_STEPS.map(function(s){return'
',document.body.appendChild(panel),panel.querySelector(".cij-progress-close").addEventListener("click",function(){panel.remove()});const cancelButton=panel.querySelector(".cij-progress-cancel");return cancelButton.addEventListener("click",async function(){cancelButton.disabled=!0,cancelButton.textContent="Cancelling\u2026";try{if(!(await fetch("/api/character-generate-consistent-images/cancel/"+encodeURIComponent(panel.dataset.jobId),{method:"POST"})).ok)throw new Error("Cancellation request failed")}catch(error){cancelButton.disabled=!1,cancelButton.textContent="Cancel",toast(error.message||"Could not cancel generation","error")}}),{update:function(stage,detail,elapsed,warn){const p=document.getElementById("cij-progress-panel");if(!p)return;p.querySelector(".cij-progress-detail").textContent=detail||"Working\u2026",p.querySelector(".cij-progress-elapsed").textContent="Elapsed "+_cijFmtElapsed(elapsed)+(warn?" \u2014 no update in a while, it may be stuck":""),p.classList.toggle("cij-progress-warn",!!warn);const activeIndex=_CIJ_STEPS.findIndex(function(s){return s.key===stage});p.querySelectorAll(".cij-step").forEach(function(el,i){const icon=el.querySelector(".cij-step-icon");el.classList.remove("is-done","is-active","is-pending"),activeIndex<0||i60;if(panel.update(status2.stage,status2.detail,status2.elapsed,warn),status2.done){if(status2.error)throw panel.done(!1),new Error(status2.error);return panel.done(!0),status2.result}}}async function _charAutoGenerateConsistentImages(rec,visualStyle){const form=_libActiveCharacterForm(rec),sh=form?Object.assign({},rec.sheet||{},form):rec.sheet||{},profilePrompt=_libStr(sh.image_prompt).trim(),referencePrompt=_libStr(sh.concept_art_prompt).trim();if(!profilePrompt)throw new Error("No Character Image Prompt \u2014 generate it first");if(!referencePrompt)throw new Error("No Character Reference Prompt \u2014 generate it first");const r=await fetch("/api/character-generate-consistent-images",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({profile_prompt:profilePrompt,reference_prompt:referencePrompt,name:rec.name||"",book:rec.book||"",sheet:sh,visual_style:visualStyle||"cinematic",book_profile:typeof _getBookProfile=="function"?await _getBookProfile(rec.book):{},identity_image:"",reuse_identity:!1})});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const{job_id}=await r.json(),panel=_cijShowPanel(rec.name);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!==!1};if(form){await _libSaveCharacterVisual(rec,visualFields);const updatedForm=_libActiveCharacterForm(rec);updatedForm&&Object.assign(updatedForm,visualFields)}else 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});return document.querySelectorAll('.lib-char-avatar[data-char-id="'+CSS.escape(rec.id)+'"]').forEach(function(av){av.innerHTML=''}),d.profile}async function _charAutoGenerateConceptArt(rec,provider){const form=_libActiveCharacterForm(rec),sh=form?Object.assign({},rec.sheet||{},form):rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim();if(!prompt)throw new Error("No concept art prompt to work from yet \u2014 generate the prompt first");const body={prompt,image_kind:"concept_art"};provider&&(body.provider=provider);const r=await fetch("/api/character-generate-image",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});if(!r.ok){const e=await r.json().catch(function(){return{}});throw new Error(e.detail||r.statusText)}const d=await r.json();return await _libSaveCharacterVisual(rec,{concept_art_image:d.image}),form?form.concept_art_image=d.image:rec.sheet=Object.assign({},rec.sheet||{},{concept_art_image:d.image}),d.image}function _openConceptArtLightbox(rec,onSaved){var _a2;(_a2=document.getElementById("conceptart-lightbox"))==null||_a2.remove();const activeForm=_libActiveCharacterForm(rec),sh=activeForm?Object.assign({},rec.sheet||{},activeForm):rec.sheet||{},prompt=_libStr(sh.concept_art_prompt).trim(),ov=document.createElement("div");ov.id="conceptart-lightbox",ov.className="audiobook-overlay",ov.innerHTML='
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.
';const goBtn=document.getElementById("stu-goto-cast-menu");goBtn&&goBtn.addEventListener("click",function(){typeof window.csForReader=="function"&&window.csForReader()})}}}document.querySelectorAll("#stu-cast-inner-tabs .stu-inner-tab").forEach(function(tab){tab.addEventListener("click",function(){_stuShowCastView(tab.dataset.stuCastView)})}),(_wc=document.getElementById("stu-book-context-btn"))==null||_wc.addEventListener("click",async function(){var _a2;const bookTitle=((_a2=window.readerState)==null?void 0:_a2.title)||"";!bookTitle||typeof window.openBookContextEditor!="function"||await window.openBookContextEditor(bookTitle)}),(_xc=document.getElementById("stu-change-book-btn"))==null||_xc.addEventListener("click",function(){showStudioPhase(1)}),(_yc=document.getElementById("stu-add-book-btn"))==null||_yc.addEventListener("click",function(){showStudioPhase(1);const dz=document.getElementById("reader-dropzone");dz&&(dz.hidden=!1);const importArea=document.getElementById("reader-import-area");importArea&&(importArea.hidden=!1);const card=document.getElementById("reader-config-card");if(card){const body=card.querySelector(".card-col-body");body&&body.hidden&&(body.hidden=!1,card.classList.remove("card-col-closed")),card.scrollIntoView({behavior:"smooth",block:"start"})}});let _stuCastFootObserver=null,_stuCastFootHealthTimer=null;function _stuCastIsRunning(){return typeof _audiobook!="undefined"&&!!_audiobook.running}function _stuSyncCastFallbackControls(tabs,innerTabs,hasActionFooter){const running=_stuCastIsRunning();tabs.hidden=!1,tabs.style.display="flex",innerTabs.forEach(function(tab){const hideFallback=running||hasActionFooter;tab.hidden=hideFallback,tab.style.display=hideFallback?"none":""})}function _stuRelocateCastFoot(){_stuTryRelocateCastFoot(),!_stuCastFootObserver&&(_stuCastFootObserver=new MutationObserver(function(){_stuTryRelocateCastFoot()}),_stuCastFootObserver.observe(document.body,{childList:!0,subtree:!0}),[0,100,300,800,1500].forEach(ms=>setTimeout(_stuTryRelocateCastFoot,ms)),_stuCastFootHealthTimer=setInterval(function(){_stuIsActive&&_stuActive===2&&_stuTryRelocateCastFoot()},500))}function _stuTryRelocateCastFoot(){const slot=document.getElementById("stu-cast-slot"),tabs=document.getElementById("stu-cast-inner-tabs");if(!tabs)return;const freshFoot=slot&&slot.querySelector("#ab-cv-foot")||document.querySelector("#ab-cv-foot"),innerTabs=Array.from(tabs.querySelectorAll(":scope > .stu-inner-tab")),footerHasActions=freshFoot&&freshFoot.querySelector("#ab-cv-menu-identify, #ab-cv-menu-cast, #ab-cv-menu-viewcast");if(_stuSyncCastFallbackControls(tabs,innerTabs,!!footerHasActions),!freshFoot||!footerHasActions)return;if(freshFoot.parentElement===tabs){freshFoot.hidden=!1,freshFoot.style.display="flex";return}tabs.querySelectorAll("#ab-cv-foot").forEach(function(stale){stale.remove()}),tabs.hidden=!1,tabs.style.display="flex",freshFoot.style.borderTop="none",freshFoot.style.padding="0",freshFoot.style.justifyContent="flex-start",freshFoot.hidden=!1,freshFoot.style.display="flex",tabs.appendChild(freshFoot);const openReh=freshFoot.querySelector("#ab-cv-open-reh");openReh&&(openReh.hidden=!0)}function _stuRestoreCastFoot(){_stuCastFootObserver&&(_stuCastFootObserver.disconnect(),_stuCastFootObserver=null),_stuCastFootHealthTimer&&(clearInterval(_stuCastFootHealthTimer),_stuCastFootHealthTimer=null);const tabs=document.getElementById("stu-cast-inner-tabs"),panel=document.getElementById("reader-audiobook-panel"),foot=tabs?tabs.querySelector("#ab-cv-foot"):null;if(foot&&panel){foot.style.borderTop="",foot.style.padding="",foot.style.justifyContent="";const openReh=foot.querySelector("#ab-cv-open-reh");openReh&&(openReh.hidden=!1),panel.appendChild(foot)}document.querySelectorAll("#stu-cast-inner-tabs > .stu-inner-tab").forEach(function(t){t.hidden=!1})}function showStudioPhase(n){_stuActive=n,_stuSaveResume(n);for(let i=1;i<=4;i++){const el=document.getElementById("stu-phase-"+i);el&&(el.hidden=i!==n)}document.querySelectorAll(".stu-subtab").forEach(function(tab){tab.classList.toggle("active",parseInt(tab.dataset.stuPhase,10)===n)}),document.querySelectorAll("#nav-caststudio-tree [data-stu-phase]").forEach(function(item){item.classList.toggle("is-active",parseInt(item.dataset.stuPhase,10)===n)});const prevBtn=document.getElementById("stu-phase-prev"),nextBtn=document.getElementById("stu-phase-next");prevBtn&&(prevBtn.disabled=n<=1),nextBtn&&(nextBtn.disabled=n>=4),typeof _stuEnterPhase=="function"&&_stuEnterPhase(n)}window.showStudioPhase=showStudioPhase,document.querySelectorAll(".stu-subtab").forEach(function(tab){tab.addEventListener("click",function(){showStudioPhase(parseInt(tab.dataset.stuPhase,10))})}),(_zc=document.getElementById("stu-phase-prev"))==null||_zc.addEventListener("click",function(){_stuActive>1&&showStudioPhase(_stuActive-1)}),(_Ac=document.getElementById("stu-phase-next"))==null||_Ac.addEventListener("click",function(){_stuActive<4&&showStudioPhase(_stuActive+1)});function studioOnShow(){var _a2,_b2,_c2;_stuInstallNavGuardOnce(),_stuIsActive=!0;const resume=_stuReadResume();resume.bookId&&window._audiobook&&(window._audiobook.bookId=resume.bookId),resume.phase>=1&&resume.phase<=4&&(_stuActive=resume.phase,resume.characterId&&(window._libResumeCharacterId=resume.characterId));const resumeBookId=resume.bookId,hasCurrentBook=!!((_b2=(_a2=window.readerState)==null?void 0:_a2.sentences)!=null&&_b2.length&&((_c2=window.readerState)!=null&&_c2.savedId));if(resumeBookId&&!hasCurrentBook&&typeof window.readerOpenLibraryDoc=="function"){showStudioPhase(_stuActive),window.readerOpenLibraryDoc(resumeBookId).then(function(){showStudioPhase(_stuActive)});return}showStudioPhase(_stuActive)}window.studioOnShow=studioOnShow,function(){const root=window.TTVC=window.TTVC||{};root.audiobook=root.audiobook||{},root.rehearser=root.rehearser||{},root.jobs=root.jobs||{},[["audiobook","fetchWithTimeout","audiobookFetchWithTimeout"],["audiobook","attributeStream","audiobookAttributeStream"],["audiobook","timeoutSeconds","audiobookTimeoutSeconds"],["rehearser","renderLibrary","renderLibraryList"],["rehearser","showPhase","showPhase"]].forEach(([namespace,publicName,legacyName])=>{typeof window[legacyName]!="undefined"&&root[namespace][publicName]==null&&(root[namespace][publicName]=window[legacyName])}),typeof rehState!="undefined"&&root.rehearser.state==null&&(root.rehearser.state=rehState)}();
+`);buf=lines.pop();for(const line of lines){if(!line.startsWith("data:"))continue;let evt;try{evt=JSON.parse(line.slice(5).trim())}catch{continue}if(evt.type==="transcript"&&userBubble&&(userBubble.textContent=evt.text||"(empty)"),evt.type==="token"&&(assistantText+=evt.delta||"",assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="llm_done"&&(assistantText=evt.text||assistantText,assistantBubble&&(assistantBubble.textContent=assistantText)),evt.type==="audio"&&evt.b64){const bytes=Uint8Array.from(atob(evt.b64),c=>c.charCodeAt(0)),url=URL.createObjectURL(new Blob([bytes],{type:evt.mime||"audio/wav"})),audioEl=document.createElement("audio");audioEl.controls=!0,audioEl.src=url,audioEl.addEventListener("ended",()=>URL.revokeObjectURL(url),{once:!0}),(_j2=q("bench-turn-log"))==null||_j2.appendChild(audioEl)}if(evt.type==="stats"&&(lastStats=evt,updateTurnStats(evt)),evt.type==="error")throw new Error(`[${evt.stage||"turn"}] ${evt.message||"Unknown error"}`)}}const total=(_k2=lastStats==null?void 0:lastStats.total_ms)!=null?_k2:Date.now()-t0;addTurnHistory(total,!0),st&&(st.textContent=`Finished in ${fmtMs(total)}.`),say("Turn benchmark complete","success")}catch(e){assistantBubble&&(assistantBubble.textContent=e.message),addTurnHistory(Date.now()-t0,!1),st&&(st.textContent="Turn benchmark failed"),say("Turn benchmark failed: "+e.message,"error")}finally{btn&&(btn.disabled=!1)}}function bindBenchmarkSection(){var _a2,_b2,_c2,_d2,_e2,_f2,_g2,_h2,_i2;initialized||!q("bench-stt-run")||(initialized=!0,document.querySelectorAll(".bench-tab").forEach(btn=>btn.addEventListener("click",()=>setBenchTab(btn.dataset.benchTab))),window.BenchmarkVoicePicker&&(BenchmarkVoicePicker.upgrade("bench-stt-library-voice",{placeholder:"-- choose from voice library --",empty:"No library voices with reference transcripts found"}),BenchmarkVoicePicker.upgrade("perf-voice-select",{placeholder:"-- select after fetch --",empty:"No voices"}),BenchmarkVoicePicker.upgrade("bench-turn-voice",{placeholder:"Fetch voices",empty:"No voices"})),document.addEventListener("click",()=>closeBenchmarkModelPickers()),(_a2=q("bench-stt-refresh"))==null||_a2.addEventListener("click",loadBenchmarkSttEngines),(_b2=q("bench-stt-load-voice"))==null||_b2.addEventListener("click",useBenchmarkLibraryVoice),(_c2=q("bench-stt-audio"))==null||_c2.addEventListener("change",()=>{q("bench-stt-source-id")&&(q("bench-stt-source-id").value="")}),(_d2=q("bench-stt-run"))==null||_d2.addEventListener("click",runSttBenchmark),(_e2=q("bench-tts-run"))==null||_e2.addEventListener("click",runTtsBenchmark),(_f2=q("bench-turn-fetch-llm"))==null||_f2.addEventListener("click",fetchTurnModels),(_g2=q("bench-turn-fetch-voices"))==null||_g2.addEventListener("click",fetchTurnVoices),(_h2=q("bench-turn-run"))==null||_h2.addEventListener("click",runTurnBenchmark),(_i2=q("bench-turn-tts-backend"))==null||_i2.addEventListener("change",()=>{window.BenchmarkVoicePicker?BenchmarkVoicePicker.populate("bench-turn-voice",[],{placeholder:"Fetch voices",empty:"No voices"}):q("bench-turn-voice")&&(q("bench-turn-voice").innerHTML='')}))}function loadBenchmarkSectionData(){bindBenchmarkSection(),!(benchmarkDataLoaded||!q("bench-stt-run"))&&(benchmarkDataLoaded=!0,loadBenchmarkSttEngines(),loadBenchmarkVoiceLibrary(),initTurnControls())}window.loadBenchmarkSectionData=loadBenchmarkSectionData,bindBenchmarkSection(),!initialized&&document.body&&new MutationObserver(()=>bindBenchmarkSection()).observe(document.body,{childList:!0,subtree:!0})}();let sttTtsSourceId=null,sttTtsOutputBlob=null,_sttBackends=[],sttTtsRecorder=null,sttTtsRecordStream=null,sttTtsRecordChunks=[],sttTtsRecordTimer=null,sttTtsRecordSecs=0;function sttTtsSelectedSttBackend(){var _a2;return((_a2=$("stt-tts-stt-backend"))==null?void 0:_a2.value)||"configured"}function sttBackendOptionHtml(selected="configured"){var _a2;if(!_sttBackends.length)return'';const preferred=_sttBackends.some(b=>b.id===selected&&b.available)?selected:((_a2=_sttBackends.find(b=>b.available))==null?void 0:_a2.id)||selected;return _sttBackends.map(b=>{const suffix=b.available?"":" (unavailable)",disabled=b.available?"":" disabled";return``}).join("")}function updateSttBackendHelp(){const selected=sttTtsSelectedSttBackend(),b=_sttBackends.find(item=>item.id===selected)||_sttBackends.find(item=>item.available)||null,help=$("stt-tts-stt-help");if(help){if(!b){help.textContent="No STT engine status loaded yet.";return}help.innerHTML=sttBackendHelpHtml(b)}}async function refreshSttBackends(selected=""){try{_sttBackends=((await fetch("/api/stt-backends").then(r=>r.json())).backends||[]).filter(b=>b&&b.id)}catch{_sttBackends=[]}["stt-tts-stt-backend","clone-stt-backend"].forEach(id=>{const sel=$(id);if(!sel)return;const prev=selected||sel.value||"configured";sel.innerHTML=sttBackendOptionHtml(prev),sel.disabled=!_sttBackends.some(b=>b.available)}),updateSttBackendHelp(),typeof window.updateStatusBar=="function"&&window.updateStatusBar(),typeof window.refreshStatusBarEngines=="function"&&window.refreshStatusBarEngines()}function sttTtsSelectedBackend(){var _a2;return((_a2=$("stt-tts-backend-select"))==null?void 0:_a2.value)||""}function sttTtsDownload(blob,name){if(!blob)return;const a=document.createElement("a");a.href=URL.createObjectURL(blob),a.download=name,a.click()}async function sttTtsUploadFile(file){if(!file)return;$("stt-tts-source-status").textContent="Uploading "+file.name+"...",sttTtsSourceId=null,sttTtsOutputBlob=null,$("stt-tts-transcribe-btn").disabled=!0,$("stt-tts-copy-preview-btn").disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;const fd=new FormData;fd.append("file",file);try{const r=await fetch("/api/upload",{method:"POST",body:fd});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();sttTtsSourceId=d.id;const audio=$("stt-tts-source-audio");audio.src="/api/audio/"+encodeURIComponent(d.id),audio.style.display="",$("stt-tts-source-status").textContent=`${d.filename||file.name} loaded (${Number(d.duration||0).toFixed(1)} s).`,$("stt-tts-transcribe-btn").disabled=!1,toast("Speech audio loaded","success")}catch(e){$("stt-tts-source-status").textContent="Upload failed.",toast("STT source upload failed: "+e.message,"error")}}async function sttTtsFetchVoices(){const backend=sttTtsSelectedBackend();if(!backend)throw new Error("No available TTS backend");const rawVoices=await fetch("/api/tts-voices?backend="+encodeURIComponent(backend)).then(r=>r.json());let voices=Array.isArray(rawVoices)?rawVoices:[];if(shouldFilterBackendVoices(backend)){const activeIds=await activeLibraryVoiceIds();voices=voices.filter(v=>activeIds.has(backendVoiceId(v)))}const sel=$("stt-tts-voice-select"),prev=sel.value;return sel.innerHTML='',voices.forEach(v=>{const id=backendVoiceId(v),opt=document.createElement("option");opt.value=opt.textContent=id,sel.appendChild(opt)}),prev&&voices.some(v=>backendVoiceId(v)===prev)&&(sel.value=prev),voices.length}(_da=$("stt-tts-file"))==null||_da.addEventListener("change",async()=>{const input=$("stt-tts-file");input.files&&input.files.length&&await sttTtsUploadFile(input.files[0]),input.value=""}),(_ea=$("stt-tts-refresh-stt-btn"))==null||_ea.addEventListener("click",async()=>{const btn=$("stt-tts-refresh-stt-btn");btn.disabled=!0;try{await refreshSttBackends(sttTtsSelectedSttBackend()),toast("STT engines refreshed","success")}finally{btn.disabled=!1}}),(_fa=$("stt-tts-stt-backend"))==null||_fa.addEventListener("change",updateSttBackendHelp);function sttTtsSetRecording(on){$("stt-tts-rec-start").disabled=on,$("stt-tts-rec-stop").disabled=!on}function sttTtsStopTracks(){sttTtsRecordStream&&sttTtsRecordStream.getTracks().forEach(t=>t.stop()),sttTtsRecordStream=null}(_ga=$("stt-tts-rec-start"))==null||_ga.addEventListener("click",async()=>{try{sttTtsRecordStream=await requestMicrophoneStream(),sttTtsRecordChunks=[],sttTtsRecordSecs=0,$("stt-tts-rec-time").textContent="0:00",$("stt-tts-source-status").textContent="Recording...",sttTtsSetRecording(!0),sttTtsRecordTimer=setInterval(()=>{sttTtsRecordSecs++,$("stt-tts-rec-time").textContent=Math.floor(sttTtsRecordSecs/60)+":"+String(sttTtsRecordSecs%60).padStart(2,"0")},1e3),sttTtsRecorder=new MediaRecorder(sttTtsRecordStream,{audioBitsPerSecond:256e3}),sttTtsRecorder.ondataavailable=e=>{e.data.size&&sttTtsRecordChunks.push(e.data)},sttTtsRecorder.onstop=async()=>{clearInterval(sttTtsRecordTimer),sttTtsRecordTimer=null,sttTtsSetRecording(!1),sttTtsStopTracks();const mime=sttTtsRecorder.mimeType||"audio/webm",blob=new Blob(sttTtsRecordChunks,{type:mime}),ext=mime.includes("ogg")?".ogg":".webm";if(!blob.size){$("stt-tts-source-status").textContent="Recording was empty.",toast("Recording was empty","error");return}await sttTtsUploadFile(new File([blob],"stt-recording"+ext,{type:mime}))},sttTtsRecorder.start(100),toast("Recording started","success")}catch(e){sttTtsSetRecording(!1),sttTtsStopTracks();const message=await microphoneErrorMessage(e);$("stt-tts-source-status").textContent=message,toast(message,"error")}}),(_ha=$("stt-tts-rec-stop"))==null||_ha.addEventListener("click",()=>{sttTtsRecorder&&sttTtsRecorder.state!=="inactive"&&sttTtsRecorder.stop()}),(_ia=$("stt-tts-transcribe-btn"))==null||_ia.addEventListener("click",async()=>{if(!sttTtsSourceId){toast("Load speech audio first","error");return}const btn=$("stt-tts-transcribe-btn");btn.disabled=!0,$("stt-tts-source-status").textContent="Transcribing...";try{const r=await fetch("/api/transcribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:sttTtsSourceId,backend:sttTtsSelectedSttBackend()})});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(e.detail||r.statusText)}const d=await r.json();$("stt-tts-text").value=d.text||"",$("stt-tts-copy-preview-btn").disabled=!(d.text||"").trim();const used=d.backend?" via "+d.backend:"";$("stt-tts-source-status").textContent="Transcription ready"+used+".",typeof updateRefineButtonState=="function"&&updateRefineButtonState(),toast("Transcription ready","success")}catch(e){$("stt-tts-source-status").textContent="Transcription failed.",toast("STT failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_ja=$("stt-tts-copy-preview-btn"))==null||_ja.addEventListener("click",()=>{const text=$("stt-tts-text").value.trim();text&&($("preview-text-area").value=text,switchTab("generation"),toast("Copied transcription to TTS Generation","success"))}),(_ka=$("stt-tts-backend-select"))==null||_ka.addEventListener("change",()=>{$("stt-tts-voice-select").innerHTML='',sttTtsOutputBlob=null,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0,updateBackendHelp()}),(_la=$("stt-tts-fetch-voices-btn"))==null||_la.addEventListener("click",async()=>{const btn=$("stt-tts-fetch-voices-btn");btn.disabled=!0;try{const count=await sttTtsFetchVoices();toast("Fetched "+count+" voices","success")}catch(e){toast("Fetch failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_ma=$("stt-tts-generate-btn"))==null||_ma.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend){toast("No available TTS backend","error");return}if(!voice){toast("Select a TTS voice","error");return}if(!text){toast("Transcribe or enter text first","error");return}const btn=$("stt-tts-generate-btn");btn.disabled=!0,$("stt-tts-save-mp3-btn").disabled=!0,$("stt-tts-save-wav-btn").disabled=!0;try{const source=await createTtsAudioSource(voice,text,backend,$("stt-tts-playback-mode").value,instruct);sttTtsOutputBlob=source.blob;const audio=$("stt-tts-output-audio");audio.src=source.url,audio.style.display="",await audio.play(),$("stt-tts-save-mp3-btn").disabled=!1,$("stt-tts-save-wav-btn").disabled=source.streaming,toast(source.streaming?"Streaming synthesized speech":"Synthesized speech ready","success")}catch(e){toast("TTS failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_na=$("stt-tts-save-mp3-btn"))==null||_na.addEventListener("click",async()=>{const backend=sttTtsSelectedBackend(),voice=$("stt-tts-voice-select").value,text=$("stt-tts-text").value.trim(),instruct=$("stt-tts-style-instruction").value.trim();if(!backend||!voice||!text)return;const btn=$("stt-tts-save-mp3-btn");btn.disabled=!0;try{const blob=await fetchTtsPreviewBlob(voice,text,"mp3",instruct,backend);sttTtsDownload(blob,(voice||"stt_tts")+"_stt_tts.mp3"),toast("MP3 saved","success")}catch(e){toast("MP3 save failed: "+e.message,"error")}finally{btn.disabled=!1}}),(_oa=$("stt-tts-save-wav-btn"))==null||_oa.addEventListener("click",()=>{sttTtsOutputBlob&&sttTtsDownload(sttTtsOutputBlob,($("stt-tts-voice-select").value||"stt_tts")+"_stt_tts.wav")}),function(){const root=window.TTVC=window.TTVC||{};root.audiobook=root.audiobook||{},root.rehearser=root.rehearser||{},root.jobs=root.jobs||{},[["audiobook","fetchWithTimeout","audiobookFetchWithTimeout"],["audiobook","attributeStream","audiobookAttributeStream"],["audiobook","timeoutSeconds","audiobookTimeoutSeconds"],["rehearser","renderLibrary","renderLibraryList"],["rehearser","showPhase","showPhase"]].forEach(([namespace,publicName,legacyName])=>{typeof window[legacyName]!="undefined"&&root[namespace][publicName]==null&&(root[namespace][publicName]=window[legacyName])}),typeof rehState!="undefined"&&root.rehearser.state==null&&(root.rehearser.state=rehState)}();
diff --git a/static/loader.js b/static/loader.js
index 671001f..f157086 100644
--- a/static/loader.js
+++ b/static/loader.js
@@ -127,24 +127,51 @@
// C — feature modules: always try the bundle first (one request instead of 23).
// Falls back to individual files if the bundle is missing or fails to load.
var _featureFiles = [];
+ var _lazyFeatureGroups = {};
try {
var _manifestResponse = await fetch('/static/module-manifest.json?v=' + _appVersion, { cache: 'no-store' });
if (!_manifestResponse.ok) throw new Error('module manifest ' + _manifestResponse.status);
var _manifest = await _manifestResponse.json();
- if (!Array.isArray(_manifest) || !_manifest.length) throw new Error('empty module manifest');
- _featureFiles = _manifest.map(function (name) { return '/static/js/' + name + '.js'; });
+ if (!Array.isArray(_manifest.core) || !_manifest.core.length) throw new Error('empty core module manifest');
+ _featureFiles = _manifest.core.map(function (name) { return '/static/js/' + name + '.js'; });
+ _lazyFeatureGroups = _manifest.lazy && typeof _manifest.lazy === 'object' ? _manifest.lazy : {};
} catch (_) {
console.error('Unable to load the feature module manifest');
}
var _bundleOk = false;
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
catch (_) { _bundleOk = false; }
- if (!_bundleOk) await _loadBatch(_featureFiles);
+ var _loadedFeatureModules = new Set();
+ if (_bundleOk) _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
+ if (!_bundleOk) {
+ await _loadBatch(_featureFiles);
+ _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
+ }
+
+ // Public section loader used by nav.js. Modules are loaded in manifest order,
+ // exactly once, and a failed group leaves the existing section visible rather
+ // than turning navigation into a blank screen.
+ window.ensureFeatureModules = function (sectionId) {
+ var names = Array.isArray(_lazyFeatureGroups[sectionId]) ? _lazyFeatureGroups[sectionId] : [];
+ var pending = names.map(function (name) { return '/static/js/' + name + '.js'; })
+ .filter(function (src) { return !_loadedFeatureModules.has(src); });
+ if (!pending.length) return Promise.resolve();
+ pending.forEach(function (src) { _loadedFeatureModules.add(src); });
+ return _loadBatch(pending).catch(function (error) {
+ pending.forEach(function (src) { _loadedFeatureModules.delete(src); });
+ console.error('Unable to load section modules for ' + sectionId, error);
+ throw error;
+ });
+ };
// D — navigation (must run before init/reveal so routing is interactive before
// async settings or voice-library refreshes can occupy the backend).
try { await _load('/static/nav.js'); } catch (_) {}
+ // A deep link or restored section must have its feature code before init and
+ // before the first visible section is activated.
+ try { if (window.ensureFeatureModules) await window.ensureFeatureModules(_activeSection); } catch (_) {}
+
// E — init (needs feature modules and nav globals to be defined)
try { await _load('/static/js/init.js'); } catch (_) {}
diff --git a/static/module-manifest.json b/static/module-manifest.json
index 71c6ab8..f6b2feb 100644
--- a/static/module-manifest.json
+++ b/static/module-manifest.json
@@ -1,6 +1,12 @@
-[
- "voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
- "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt",
- "rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
- "characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio", "namespace"
-]
+{
+ "core": [
+ "voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
+ "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
+ ],
+ "lazy": {
+ "s-rehearser": ["rehearser-parse", "rehearser"],
+ "s-reader": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"],
+ "s-library": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
+ "s-caststudio": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
+ }
+}
diff --git a/static/nav.js b/static/nav.js
index 99a5ab7..f1bf872 100644
--- a/static/nav.js
+++ b/static/nav.js
@@ -216,18 +216,24 @@
// Preserve that early intent so startup cannot immediately switch back to
// the previously stored section.
if (SECTIONS.includes(sectionId)) window._navRequestedSection = sectionId;
- showSection(sectionId);
- var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
- return TAB_SECTION_MAP[k] === sectionId;
- });
- if (tabName) runSideEffects(tabName);
- // Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
- // plain sidebar navigation (not just the crumb's own buttons).
- if (typeof window.refreshWorkflowCrumbs === 'function') {
- if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
- else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source');
- else if (sectionId === 's-library' && (window._libraryView || 'books') === 'characters') window.refreshWorkflowCrumbs('chars');
+ var activate = function () {
+ showSection(sectionId);
+ var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
+ return TAB_SECTION_MAP[k] === sectionId;
+ });
+ if (tabName) runSideEffects(tabName);
+ // Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
+ // plain sidebar navigation (not just the crumb's own buttons).
+ if (typeof window.refreshWorkflowCrumbs === 'function') {
+ if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
+ else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source');
+ else if (sectionId === 's-library' && (window._libraryView || 'books') === 'characters') window.refreshWorkflowCrumbs('chars');
+ }
+ };
+ if (typeof window.ensureFeatureModules === 'function') {
+ return window.ensureFeatureModules(sectionId).then(activate, function () { activate(); });
}
+ activate();
};
// Pulsing dot on the Read Aloud nav item while casting is in progress
@@ -246,9 +252,8 @@
window.switchTab = function (name) {
var sectionId = TAB_SECTION_MAP[name];
- if (sectionId) showSection(sectionId);
- runSideEffects(name);
- return true;
+ if (!sectionId) return false;
+ return window.navTo(sectionId);
};
// ── Voice tree sub-navigation ─────────────────────────────────────────────
@@ -431,11 +436,16 @@
window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections';
window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
var _startSection = window._navRequestedSection || hashSection() || ((_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices');
- showSection(_startSection);
+ if (typeof window.ensureFeatureModules === 'function') {
+ window.ensureFeatureModules(_startSection).then(function () { showSection(_startSection); }, function () { showSection(_startSection); });
+ } else showSection(_startSection);
window.addEventListener('hashchange', function () {
var section = hashSection();
- if (section && section !== storedSection()) showSection(section);
+ if (section && section !== storedSection()) {
+ if (typeof window.ensureFeatureModules === 'function') window.ensureFeatureModules(section).then(function () { showSection(section); }, function () { showSection(section); });
+ else showSection(section);
+ }
});
var _startTab = Object.keys(TAB_SECTION_MAP).find(function (k) { return TAB_SECTION_MAP[k] === _startSection; }) || 'library';
runSideEffects(_startTab);
diff --git a/tests/characters.spec.js b/tests/characters.spec.js
index edfdb40..843bad3 100644
--- a/tests/characters.spec.js
+++ b/tests/characters.spec.js
@@ -21,13 +21,13 @@ test('Characters / Cast supports profile sorting, column visibility, and voice r
await page.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
- await page.waitForFunction(() => typeof window.navTo === 'function' && typeof window.navLibraryView === 'function', null, { timeout: 15_000 });
+ await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
// The loader injects inactive section HTML in the background; wait until
// the Library panel exists before switching its sub-view.
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
- await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
+ await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
@@ -82,6 +82,8 @@ test('consistent image generation can be cancelled from its progress panel', asy
}));
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._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
await page.evaluate(() => {
window.__cancelRun = window._charAutoGenerateConsistentImages({
@@ -110,8 +112,10 @@ test('current-version image pairs marked inconsistent show a review warning', as
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
- await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
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.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.locator('#nav-library-head').evaluate(el => el.click());
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
@@ -122,8 +126,10 @@ test('current-version image pairs marked inconsistent show a review warning', as
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.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
+ await page.evaluate(() => window.navTo('s-library'));
+ await page.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window._charDetailPage({
id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob',
image: '/human.png', sheet: {
@@ -144,6 +150,8 @@ test('character appearance forms stay in one record and switch the detail view',
test('profile completeness counter distinguishes empty, partial, and full sheets', async ({ 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._charProfileCompleteness === 'function', null, { timeout: 15_000 });
const counts = await page.evaluate(() => ({
empty: window._charProfileCompleteness({}),
diff --git a/tests/constrained.spec.js b/tests/constrained.spec.js
index 3f407a7..ee871bd 100644
--- a/tests/constrained.spec.js
+++ b/tests/constrained.spec.js
@@ -30,8 +30,10 @@ test('large character productions keep the rendered DOM paginated', async ({ pag
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
- await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
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.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library'));
await page.locator('.lib-tab[data-library-view="characters"]').click();
@@ -88,6 +90,8 @@ test('voice library cache reuses fresh data and honors explicit refresh', async
test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
+ await page.evaluate(() => window.navTo('s-rehearser'));
await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
const size = await page.evaluate(() => {
const cache = window.rehState.synthCache;
diff --git a/tests/lazy-loading.spec.js b/tests/lazy-loading.spec.js
new file mode 100644
index 0000000..97da28c
--- /dev/null
+++ b/tests/lazy-loading.spec.js
@@ -0,0 +1,25 @@
+// @ts-check
+// Section-specific feature code should not be fetched until its section is opened.
+const { test, expect } = require('@playwright/test');
+
+test('heavy feature modules load on demand', async ({ page }) => {
+ const requests = [];
+ page.on('request', request => {
+ if (request.url().includes('/static/js/')) requests.push(request.url());
+ });
+
+ await page.goto('/', { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
+ await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
+ expect(requests.some(url => url.includes('/rehearser.js'))).toBeFalsy();
+ expect(requests.some(url => url.includes('/library-characters.js'))).toBeFalsy();
+
+ await page.evaluate(() => window.navTo('s-rehearser'));
+ await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
+ expect(requests.some(url => url.includes('/rehearser.js'))).toBeTruthy();
+
+ const countBeforeRepeat = requests.filter(url => url.includes('/rehearser.js')).length;
+ await page.evaluate(() => window.navTo('s-rehearser'));
+ await page.waitForTimeout(100);
+ expect(requests.filter(url => url.includes('/rehearser.js')).length).toBe(countBeforeRepeat);
+});