Fish-Speech emotion tags were silently ignored on non-English books: per-line emotions are LLM-generated in the book's own language, but Fish-Speech only recognizes English [tag] markers, and a double-tagging bug was stacking a broken server-derived tag on top of the client's own. Added a DE->EN translation table and removed the double-tagging. Also wires the existing book-profile context and race_species field into character portrait prompts (previously only used for voice design), adds a recast-until-threshold loop for casting, and adds backend-aware emotion quick-picks to Read Aloud, Try a Voice, and Conversation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2824 lines
158 KiB
JavaScript
2824 lines
158 KiB
JavaScript
// ── Library Characters / Cast workspace ─────────────────────────────────────
|
||
// Loaded after library.js; uses its shared production helpers and exports
|
||
// libraryRenderCharacters for the Library section router.
|
||
|
||
// ── Characters / Cast ─────────────────────────────────────────────────────────
|
||
|
||
async function libraryRenderCharacters() {
|
||
const container = document.getElementById('lib-chars-list');
|
||
if (!container) return;
|
||
|
||
// Fetch BEFORE touching the DOM: a transient failure here (e.g. the
|
||
// server briefly unresponsive while a TTS container restart is in
|
||
// flight) must never blank out an already-populated list — confirmed
|
||
// live as "No characters yet" flashing up over a real, populated cast
|
||
// while a bulk voice-design run elsewhere was mid-restart. Leave whatever
|
||
// is already on screen alone and let the next real refresh fix it.
|
||
let all = [];
|
||
try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; }
|
||
catch (e) {
|
||
console.warn('[characters] load failed', e);
|
||
if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error');
|
||
return;
|
||
}
|
||
|
||
// Every action on this page (remove voice, auto-design, delete...) re-runs
|
||
// this full rebuild via `refresh()`. Collapsing the container down to a
|
||
// one-line loading placeholder mid-rebuild shrinks #main-content below the
|
||
// user's current scroll position, which the browser clamps back to fit —
|
||
// confirmed live as "the screen jumps to the top" after every single
|
||
// action. Restore it once the real content is back, unless a caller
|
||
// explicitly wants to jump to a specific book (see _libCharsScrollToBook
|
||
// below), which takes priority over just staying put.
|
||
const mainEl = document.getElementById('main-content');
|
||
const savedScrollTop = (!window._libCharsScrollToBook && mainEl) ? mainEl.scrollTop : null;
|
||
container.innerHTML = '<div class="lib-chars-loading"><span class="mdi mdi-loading mdi-spin"></span> Loading characters…</div>';
|
||
|
||
const byId = new Map(all.map(function (rec) { return [rec.id, rec]; }));
|
||
|
||
if (!all.length) {
|
||
container.innerHTML = '<div class="lib-chars-toolbar">'
|
||
+ '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
|
||
+ '</div>'
|
||
+ '<div class="lib-empty">'
|
||
+ '<span class="mdi mdi-account-box-multiple-outline"></span>'
|
||
+ '<p>No characters yet.</p>'
|
||
+ '<p class="lib-empty-hint">Open a book in <b>Read Aloud</b>, cast it as an audiobook, then click <b>Cast Characters</b> to generate character sheets — or import an existing cast from SillyTavern.</p>'
|
||
+ '</div>';
|
||
container.querySelector('#lib-chars-import').addEventListener('click', function () {
|
||
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
|
||
});
|
||
if (savedScrollTop != null) mainEl.scrollTop = savedScrollTop;
|
||
return;
|
||
}
|
||
|
||
// Group by book (production)
|
||
const byBook = {};
|
||
all.forEach(function (rec) {
|
||
const bk = rec.book || 'Unsorted';
|
||
if (!byBook[bk]) byBook[bk] = [];
|
||
byBook[bk].push(rec);
|
||
});
|
||
|
||
// The Narrator reads every non-dialogue line but was never a real Library
|
||
// record (castWriteBack deliberately never creates one, to keep narrators
|
||
// out of the cast library) — so there was nowhere in Assign Voices to give
|
||
// it a voice at all; only the Script Rehearser's own Cast tab could set
|
||
// one, in a rehearsal-local field (rehState.narratorVoice) that isn't
|
||
// shared back here. Synthesize a placeholder per production so it shows up
|
||
// and can be picked/designed exactly like any other character; the first
|
||
// actual voice pick turns it into a real saved record via clUpsert same as
|
||
// any other card, and rehApplySharedCast now reads it back like any other
|
||
// shared cast entry (see rehearser.js).
|
||
Object.keys(byBook).forEach(function (bk) {
|
||
const hasNarrator = byBook[bk].some(function (r) { return String(r.name || '').trim().toLowerCase() === 'narrator'; });
|
||
if (!hasNarrator) {
|
||
// Must also land in `byId` (built above from the real fetched records,
|
||
// before this synthesis) — _wireCharCards looks up every card's click
|
||
// target there and silently no-ops if it's missing, which is exactly
|
||
// why clicking this card did nothing at all.
|
||
const narrRec = { id: clKey(bk, 'Narrator'), book: bk, name: 'Narrator', tags: bk, voice: null, image: null, sheet: {} };
|
||
byId.set(narrRec.id, narrRec);
|
||
byBook[bk].unshift(narrRec);
|
||
}
|
||
});
|
||
|
||
container.innerHTML = '';
|
||
|
||
// Global toolbar — import a cast from SillyTavern into a new/unsorted production,
|
||
// a Cards/Table view toggle, and a Sort dropdown (all persisted, like Script
|
||
// Rehearser's cast-list controls).
|
||
const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards';
|
||
let returnToReader = false;
|
||
try { returnToReader = sessionStorage.getItem('ttsvc_cast_return') === 'reader'; } catch (_) {}
|
||
const SORT_OPTIONS = [
|
||
['tier', 'Rolle (Haupt zuerst)'],
|
||
['alpha', 'Alphabet'],
|
||
['lines', 'Anzahl Zeilen'],
|
||
['gender', 'Geschlecht'],
|
||
['voice', 'Stimme zugewiesen'],
|
||
];
|
||
const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); })
|
||
? localStorage.getItem('ttsvc_libchars_sort') : 'tier';
|
||
const bar = document.createElement('div');
|
||
bar.className = 'lib-chars-toolbar';
|
||
bar.innerHTML = (returnToReader
|
||
? '<button class="btn-secondary btn-sm" id="lib-chars-back-reader" title="Return to Read Aloud"><span class="mdi mdi-arrow-left"></span> Back to reader</button>'
|
||
: '')
|
||
+ '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
|
||
+ '<label class="lib-chars-sort"><span class="mdi mdi-sort"></span> Sort'
|
||
+ '<select id="lib-chars-sort-sel">' + SORT_OPTIONS.map(function (o) {
|
||
return '<option value="' + o[0] + '"' + (o[0] === sortMode ? ' selected' : '') + '>' + o[1] + '</option>';
|
||
}).join('') + '</select>'
|
||
+ '</label>'
|
||
+ '<div class="lib-chars-view-toggle">'
|
||
+ '<button class="btn-secondary btn-sm' + (viewMode === 'cards' ? ' is-active' : '') + '" data-view="cards"><span class="mdi mdi-view-grid-outline"></span> Cards</button>'
|
||
+ '<button class="btn-secondary btn-sm' + (viewMode === 'table' ? ' is-active' : '') + '" data-view="table"><span class="mdi mdi-table"></span> Table</button>'
|
||
+ '</div>';
|
||
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
|
||
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
|
||
});
|
||
bar.querySelector('#lib-chars-back-reader')?.addEventListener('click', function () {
|
||
try { sessionStorage.removeItem('ttsvc_cast_return'); } catch (_) {}
|
||
if (typeof navTo === 'function') navTo('s-reader');
|
||
});
|
||
bar.querySelector('#lib-chars-sort-sel').addEventListener('change', function () {
|
||
localStorage.setItem('ttsvc_libchars_sort', this.value);
|
||
libraryRenderCharacters();
|
||
});
|
||
bar.querySelectorAll('.lib-chars-view-toggle button').forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
localStorage.setItem('ttsvc_libchars_view', btn.dataset.view);
|
||
libraryRenderCharacters();
|
||
});
|
||
});
|
||
container.appendChild(bar);
|
||
|
||
const _charSortCmp = {
|
||
tier: function (a, b) {
|
||
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
||
const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||
const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||
return ta - tb || (a.name || '').localeCompare(b.name || '');
|
||
},
|
||
alpha: function (a, b) { return (a.name || '').localeCompare(b.name || ''); },
|
||
lines: function (a, b) { return (b.sheet?.line_count || 0) - (a.sheet?.line_count || 0) || (a.name || '').localeCompare(b.name || ''); },
|
||
gender: function (a, b) {
|
||
const ga = String(a.sheet?.gender || 'zzz'), gb = String(b.sheet?.gender || 'zzz');
|
||
return ga.localeCompare(gb) || (a.name || '').localeCompare(b.name || '');
|
||
},
|
||
voice: function (a, b) { return (b.voice ? 1 : 0) - (a.voice ? 1 : 0) || (a.name || '').localeCompare(b.name || ''); },
|
||
age: function (a, b) { return (_charAgeSortVal(a.sheet) - _charAgeSortVal(b.sheet)) || (a.name || '').localeCompare(b.name || ''); },
|
||
language: function (a, b) { return _charLangLabel(a).localeCompare(_charLangLabel(b)) || (a.name || '').localeCompare(b.name || ''); },
|
||
align: function (a, b) { return (b.sheet?.moral_alignment_score ?? -1) - (a.sheet?.moral_alignment_score ?? -1) || (a.name || '').localeCompare(b.name || ''); },
|
||
};
|
||
const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc';
|
||
|
||
const productions = document.createDocumentFragment();
|
||
Object.keys(byBook).sort().forEach(function (book) {
|
||
const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier);
|
||
if (sortDir === 'desc') chars.reverse();
|
||
// Narrator stays pinned first regardless of sort — same convention as
|
||
// the Rehearser's own cast list, and it's not really "cast" like the rest.
|
||
const narrIdx = chars.findIndex(function (r) { return String(r.name || '').trim().toLowerCase() === 'narrator'; });
|
||
if (narrIdx > 0) chars.unshift(chars.splice(narrIdx, 1)[0]);
|
||
|
||
const cov = libBookCover(book);
|
||
const prod = document.createElement('div');
|
||
prod.className = 'lib-chars-production';
|
||
prod.dataset.book = book;
|
||
// Collapsed state is per-book and persisted, so a manual preference
|
||
// survives re-renders — except when landing here scoped to one specific
|
||
// book (Studio's Voices phase / the old "Assign Voices" step), where the
|
||
// whole point is to focus on that book: force it open and every other
|
||
// production shut, so a 40+ character roster from an unrelated book
|
||
// doesn't bury the one actually being worked on.
|
||
const collapseKey = 'ttsvc_libchars_collapsed::' + book;
|
||
let isCollapsed = localStorage.getItem(collapseKey) === '1';
|
||
if (window._libCharsScrollToBook) isCollapsed = (book !== window._libCharsScrollToBook);
|
||
if (isCollapsed) prod.classList.add('lib-chars-production-collapsed');
|
||
prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">'
|
||
+ '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production"><span class="mdi mdi-chevron-down"></span></button>'
|
||
+ '<div class="lib-chars-prod-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
|
||
+ '<div class="lib-chars-prod-actions">'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-bookctx-btn" data-book="' + escHtml(book) + '" title="Set genre, setting, era and language for this book — gives every voice/image prompt real context instead of guessing per character"><span class="mdi mdi-book-cog-outline"></span> Context</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-casting-btn" data-book="' + escHtml(book) + '" title="Back to the casting script (speaker attribution)"><span class="mdi mdi-drama-masks"></span> Casting</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-cast-btn" data-book="' + escHtml(book) + '" title="Re-run character sheet generation"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-reh-btn" data-book="' + escHtml(book) + '" title="Open in Script Rehearsal"><span class="mdi mdi-theater"></span> Rehearse</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-read-btn" data-book="' + escHtml(book) + '" title="Open in Read Aloud"><span class="mdi mdi-book-open-page-variant-outline"></span> Read Aloud</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-imp-btn" data-book="' + escHtml(book) + '" title="Import SillyTavern cards into this production"><span class="mdi mdi-import"></span></button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-select-all-btn" data-book="' + escHtml(book) + '" title="Select or deselect every character in this production"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select all</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-voice-btn" data-book="' + escHtml(book) + '" title="Auto-assign an existing library voice to every checked character" disabled><span class="mdi mdi-account-voice"></span> Auto-assign selected (<span class="lib-chars-bulk-count">0</span>)</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-design-btn" data-book="' + escHtml(book) + '" title="Design and save a brand-new voice for every checked character, from their Voice Design Prompt" disabled><span class="mdi mdi-creation"></span> Auto-design voices (<span class="lib-chars-bulk-count-design">0</span>)</button>'
|
||
+ '<select class="lib-chars-image-provider" title="Image generation engine for this run — overrides the Active Provider in Settings just for this run">'
|
||
+ '<option value="">(Active Provider)</option>'
|
||
+ '<option value="openai">OpenAI</option>'
|
||
+ '<option value="google">Google</option>'
|
||
+ '<option value="openrouter">OpenRouter</option>'
|
||
+ '<option value="pollinations">Pollinations.ai (free)</option>'
|
||
+ '<option value="comfyui">Local ComfyUI</option>'
|
||
+ '</select>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-image-btn" data-book="' + escHtml(book) + '" title="Generate a profile picture for every checked character, from their Character Image Prompt" disabled><span class="mdi mdi-image-outline"></span> Auto-generate images (<span class="lib-chars-bulk-count-image">0</span>)</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-delete-btn" data-book="' + escHtml(book) + '" title="Delete every checked character from the library — use this to clear out stale/corrupted entries before a fresh recast" disabled><span class="mdi mdi-trash-can-outline"></span> Delete selected (<span class="lib-chars-bulk-count-delete">0</span>)</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-fix-lang-btn" data-book="' + escHtml(book) + '" title="Find every character whose current voice does not match this book\'s language, and design a properly-matching one — no selection needed, scans the whole production"><span class="mdi mdi-earth-arrow-right"></span> Fix wrong-language voices</button>'
|
||
+ '</div></div>'
|
||
+ '<div class="lib-chars-prod-body">'
|
||
+ (viewMode === 'table'
|
||
? _charsTableHtml(chars, sortMode, sortDir)
|
||
: '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
|
||
+ chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
|
||
+ '</div>')
|
||
+ '</div>';
|
||
|
||
prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
const collapsed = prod.classList.toggle('lib-chars-production-collapsed');
|
||
localStorage.setItem(collapseKey, collapsed ? '1' : '0');
|
||
});
|
||
// The whole head bar toggles too, not just the small chevron button —
|
||
// matches how the rest of the app treats a section header as the click
|
||
// target (e.g. card-collapse-toggle elsewhere), and is a much bigger
|
||
// hit area than the chevron alone.
|
||
prod.querySelector('.lib-chars-prod-head').addEventListener('click', function (e) {
|
||
if (e.target.closest('button, select, input, a')) return;
|
||
prod.querySelector('.lib-chars-prod-collapse-btn').click();
|
||
});
|
||
|
||
// Action buttons
|
||
prod.querySelector('.lib-chars-bookctx-btn').addEventListener('click', function () {
|
||
_editBookProfile(book);
|
||
});
|
||
prod.querySelector('.lib-chars-casting-btn').addEventListener('click', function () {
|
||
// Back to the casting script. If a casting session is active, navTo's
|
||
// synchronous cast-restore (nav.js) reopens it directly; otherwise this
|
||
// lands on the reader so the book can be opened and cast from there.
|
||
if (typeof navTo === 'function') navTo('s-reader');
|
||
});
|
||
prod.querySelector('.lib-chars-cast-btn').addEventListener('click', function () {
|
||
if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
|
||
toast('Open the book in Read Aloud then click Cast Characters', 'info');
|
||
});
|
||
prod.querySelector('.lib-chars-reh-btn').addEventListener('click', function () {
|
||
if (typeof productionOpenInRehearser === 'function') productionOpenInRehearser(book);
|
||
});
|
||
prod.querySelector('.lib-chars-read-btn').addEventListener('click', function () {
|
||
if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
|
||
});
|
||
prod.querySelector('.lib-chars-imp-btn').addEventListener('click', function () {
|
||
if (typeof stImportDialog === 'function') stImportDialog(book, function () { libraryRenderCharacters(); });
|
||
});
|
||
|
||
// Table column headers double as sort controls — clicking one makes it
|
||
// the active sort (reusing the same comparators the Sort dropdown uses,
|
||
// so the two stay in lockstep); clicking the already-active one flips
|
||
// direction instead of doing nothing. Wired per fresh <th> rather than
|
||
// via delegation on the persistent container, so repeated re-renders
|
||
// don't stack up duplicate listeners.
|
||
prod.querySelectorAll('[data-sort-key]').forEach(function (th) {
|
||
th.addEventListener('click', function () {
|
||
const key = th.dataset.sortKey;
|
||
const nextDir = (sortMode === key && sortDir === 'asc') ? 'desc' : 'asc';
|
||
localStorage.setItem('ttsvc_libchars_sort', key);
|
||
localStorage.setItem('ttsvc_libchars_sort_dir', nextDir);
|
||
libraryRenderCharacters();
|
||
});
|
||
});
|
||
|
||
const selectAllBtn = prod.querySelector('.lib-chars-select-all-btn');
|
||
const bulkBtn = prod.querySelector('.lib-chars-bulk-voice-btn');
|
||
const bulkCount = prod.querySelector('.lib-chars-bulk-count');
|
||
const designBtn = prod.querySelector('.lib-chars-bulk-design-btn');
|
||
const designCount = prod.querySelector('.lib-chars-bulk-count-design');
|
||
const imageBtn = prod.querySelector('.lib-chars-bulk-image-btn');
|
||
const imageCount = prod.querySelector('.lib-chars-bulk-count-image');
|
||
const imageProviderSel = prod.querySelector('.lib-chars-image-provider');
|
||
if (imageProviderSel) imageProviderSel.value = (typeof _appSettings !== 'undefined' && _appSettings.image_gen_provider) || '';
|
||
const deleteBtn = prod.querySelector('.lib-chars-bulk-delete-btn');
|
||
const deleteCount = prod.querySelector('.lib-chars-bulk-count-delete');
|
||
const tblSelectAllCb = prod.querySelector('.lib-chars-tbl-select-all-cb');
|
||
const syncTblSelectAllCb = function () {
|
||
if (!tblSelectAllCb) return;
|
||
const boxes = [...prod.querySelectorAll('.lib-char-select-cb')];
|
||
const checkedN = boxes.filter(function (cb) { return cb.checked; }).length;
|
||
tblSelectAllCb.checked = boxes.length > 0 && checkedN === boxes.length;
|
||
tblSelectAllCb.indeterminate = checkedN > 0 && checkedN < boxes.length;
|
||
};
|
||
const refreshBulkBtn = function () {
|
||
const n = prod.querySelectorAll('.lib-char-select-cb:checked').length;
|
||
bulkCount.textContent = n;
|
||
bulkBtn.disabled = n === 0;
|
||
designCount.textContent = n;
|
||
designBtn.disabled = n === 0;
|
||
imageCount.textContent = n;
|
||
imageBtn.disabled = n === 0;
|
||
deleteCount.textContent = n;
|
||
deleteBtn.disabled = n === 0;
|
||
syncTblSelectAllCb();
|
||
};
|
||
prod.querySelectorAll('.lib-char-select-cb').forEach(function (cb) {
|
||
cb.addEventListener('change', refreshBulkBtn);
|
||
});
|
||
selectAllBtn.addEventListener('click', function () {
|
||
const boxes = [...prod.querySelectorAll('.lib-char-select-cb')];
|
||
const allChecked = boxes.length > 0 && boxes.every(function (cb) { return cb.checked; });
|
||
boxes.forEach(function (cb) { cb.checked = !allChecked; });
|
||
refreshBulkBtn();
|
||
});
|
||
if (tblSelectAllCb) {
|
||
tblSelectAllCb.addEventListener('change', function () {
|
||
const boxes = [...prod.querySelectorAll('.lib-char-select-cb')];
|
||
boxes.forEach(function (cb) { cb.checked = tblSelectAllCb.checked; });
|
||
refreshBulkBtn();
|
||
});
|
||
}
|
||
syncTblSelectAllCb();
|
||
|
||
// Shared runner for all three bulk actions: sequential (not parallel) so
|
||
// each step sees the results of the ones before it — _autoAssignVoice
|
||
// needs that to avoid double-handing-out the same library voice, and it
|
||
// also keeps a single shared LLM/image-gen backend from being hit with a
|
||
// burst of N concurrent requests at once.
|
||
const runBulk = async function (btn, ids, verb, fn) {
|
||
btn.disabled = true;
|
||
const orig = btn.innerHTML;
|
||
let done = 0, failed = 0, lastErrMsg = '', repeatErrMsg = '', repeatCount = 0, aborted = false;
|
||
for (const id of ids) {
|
||
const rec = byId.get(id);
|
||
if (!rec) continue;
|
||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> ' + verb + ' ' + (done + failed + 1) + ' / ' + ids.length + '…';
|
||
try {
|
||
await fn(rec); done++; repeatCount = 0;
|
||
} catch (e) {
|
||
failed++;
|
||
lastErrMsg = (e && e.message) ? e.message : String(e);
|
||
console.error('[bulk ' + verb + ']', rec.name, e);
|
||
// The same error on 3 characters in a row almost always means a
|
||
// systemic problem (bad API key, quota/billing limit, backend
|
||
// down) rather than something wrong with those specific
|
||
// characters — stop instead of burning through the whole
|
||
// selection hitting the same wall (and, for rate limits, making
|
||
// it worse).
|
||
if (lastErrMsg === repeatErrMsg) { repeatCount++; } else { repeatErrMsg = lastErrMsg; repeatCount = 1; }
|
||
if (repeatCount >= 3) { aborted = true; break; }
|
||
}
|
||
}
|
||
btn.innerHTML = orig;
|
||
const remaining = ids.length - done - failed;
|
||
const suffix = failed ? ` (${failed} failed${aborted && remaining ? `, ${remaining} skipped` : ''}${lastErrMsg ? ': ' + lastErrMsg.slice(0, 200) : ''})` : '';
|
||
toast(`${verb} finished for ${done} character${done !== 1 ? 's' : ''}${suffix}`, failed && !done ? 'error' : 'success');
|
||
await _flushPendingTtsRestart();
|
||
// Bulk-designing voices creates brand-new entries that window._voices
|
||
// (last loaded whenever the Voice Library page itself was visited)
|
||
// doesn't know about yet — without this, _voiceExists() judges every
|
||
// just-created voice against that stale list and the character table
|
||
// re-render right below shows them all as "deleted from the Library,
|
||
// please reassign" the instant they're actually done, even though
|
||
// they're sitting right there. Confirmed live as a real bug.
|
||
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary({ refresh: true }).catch(() => {});
|
||
libraryRenderCharacters();
|
||
};
|
||
|
||
bulkBtn.addEventListener('click', function () {
|
||
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
|
||
if (!ids.length) return;
|
||
runBulk(bulkBtn, ids, 'Assigning', _autoAssignVoice);
|
||
});
|
||
designBtn.addEventListener('click', function () {
|
||
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
|
||
if (!ids.length) return;
|
||
runBulk(designBtn, ids, 'Designing', _charAutoDesignVoice);
|
||
});
|
||
imageBtn.addEventListener('click', function () {
|
||
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
|
||
if (!ids.length) return;
|
||
const provider = imageProviderSel ? imageProviderSel.value : '';
|
||
runBulk(imageBtn, ids, 'Generating images', function (rec) { return _charAutoGenerateImage(rec, provider); });
|
||
});
|
||
const fixLangBtn = prod.querySelector('.lib-chars-fix-lang-btn');
|
||
fixLangBtn?.addEventListener('click', function () {
|
||
// No selection needed — scans every character in this production for
|
||
// a voice whose id-prefix language doesn't match the book's own
|
||
// detected language (the same check _findVoiceByCharacterName/
|
||
// _findVoiceFromSameCharacterElsewhere use for NEW assignments; this
|
||
// is the cleanup pass for characters assigned BEFORE that check
|
||
// existed, or from a bulk run that predates it — confirmed live as a
|
||
// real, recurring complaint: English-designed voices left over on a
|
||
// German book, sounding wrong).
|
||
// Resolve the book's language once, by majority vote across every
|
||
// character with enough sheet text to detect from — not by checking
|
||
// each character against only its OWN sheet text, which silently
|
||
// skipped every sparse/minor character ("Bote", "Frau", "Mann"...)
|
||
// with no text to detect from at all. Confirmed live: a whole set of
|
||
// generic/crowd characters kept their wrong-language (English) voices
|
||
// forever because this check bailed out on each of them individually
|
||
// instead of using what every OTHER character in the same book
|
||
// already made obvious.
|
||
const _bookLangCounts = {};
|
||
chars.forEach(function (r) { const l = _charLang(r); if (l) _bookLangCounts[l] = (_bookLangCounts[l] || 0) + 1; });
|
||
let _bookLang = '', _bookLangBest = 0;
|
||
Object.keys(_bookLangCounts).forEach(function (l) { if (_bookLangCounts[l] > _bookLangBest) { _bookLang = l; _bookLangBest = _bookLangCounts[l]; } });
|
||
const bookCode = (_bookLang && typeof DESIGN_LANG_CODE !== 'undefined') ? DESIGN_LANG_CODE[_bookLang] : null;
|
||
const mismatched = chars.filter(function (rec) {
|
||
if (!rec.voice) return false;
|
||
if (!bookCode) return false; // can't tell the book's language confidently — don't touch it
|
||
const voiceId = typeof rec.voice === 'object' ? rec.voice.id : rec.voice;
|
||
return _voiceLangCode(voiceId) !== bookCode;
|
||
});
|
||
if (!mismatched.length) { toast('No language-mismatched voices found in this production', 'info'); return; }
|
||
runBulk(fixLangBtn, mismatched.map(function (r) { return r.id; }), 'Redesigning', function (rec) { return _charAutoDesignVoice(rec, true); });
|
||
});
|
||
deleteBtn.addEventListener('click', async function () {
|
||
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
|
||
if (!ids.length) return;
|
||
const ok = await confirmDialog(
|
||
`Delete ${ids.length} character${ids.length !== 1 ? 's' : ''} from the library? This cannot be undone — use it to clear out stale/corrupted entries before a fresh recast.`,
|
||
{ title: 'Delete characters?', okLabel: 'Delete', danger: true }
|
||
);
|
||
if (!ok) return;
|
||
runBulk(deleteBtn, ids, 'Deleting', function (rec) { return clDelete(rec.id); });
|
||
});
|
||
|
||
// Wire voice selectors and auto-assign buttons
|
||
_wireCharCards(prod, byId, chars);
|
||
|
||
productions.appendChild(prod);
|
||
});
|
||
container.appendChild(productions);
|
||
|
||
// Scroll to a specific book's production block — set by callers that land
|
||
// here from a book-scoped context (e.g. the "Assign Voices" workflow step)
|
||
// instead of dumping the whole cross-book library on screen with no focus.
|
||
if (window._libCharsScrollToBook) {
|
||
const target = window._libCharsScrollToBook;
|
||
window._libCharsScrollToBook = null;
|
||
const prodEl = [...container.querySelectorAll('.lib-chars-production')].find(function (p) { return p.dataset.book === target; });
|
||
if (prodEl) {
|
||
prodEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
prodEl.classList.add('lib-chars-production-highlight');
|
||
setTimeout(function () { prodEl.classList.remove('lib-chars-production-highlight'); }, 2200);
|
||
}
|
||
} else if (savedScrollTop != null) {
|
||
mainEl.scrollTop = savedScrollTop;
|
||
}
|
||
}
|
||
|
||
function _charHue(name) {
|
||
return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
|
||
}
|
||
|
||
function _charAlignHtml(sh) {
|
||
const score = sh.moral_alignment_score;
|
||
if (score == null) return '';
|
||
const pct = Math.max(0, Math.min(100, score));
|
||
const arc = sh.arc_direction || 'neutral';
|
||
const arrowMap = {
|
||
'good-to-bad': { ch: '↘', color: '#ff7043', tip: 'Arc: Descends toward evil' },
|
||
'bad-to-good': { ch: '↗', color: '#66bb6a', tip: 'Arc: Redeems toward good' },
|
||
'complex': { ch: '↕', color: '#ab47bc', tip: 'Arc: Complex / unpredictable' },
|
||
'stable-good': { ch: '→', color: '#66bb6a', tip: 'Arc: Stable good' },
|
||
'stable-bad': { ch: '→', color: '#888', tip: 'Arc: Stable evil' },
|
||
'neutral': { ch: '→', color: '#aaa', tip: 'Arc: Neutral' },
|
||
};
|
||
const a = arrowMap[arc] || arrowMap['neutral'];
|
||
const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Morally ambiguous';
|
||
return '<div class="lib-char-align">'
|
||
+ '<span class="lib-char-align-evil" title="Evil">●</span>'
|
||
+ '<div class="lib-char-align-bar" title="' + label + ' (' + pct + '/100)">'
|
||
+ '<div class="lib-char-align-dot" style="left:' + pct + '%"></div>'
|
||
+ '</div>'
|
||
+ '<span class="lib-char-align-good" title="Good">●</span>'
|
||
+ '<span class="lib-char-align-arrow" style="color:' + a.color + '" title="' + a.tip + '">' + a.ch + '</span>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function _libStr(v) {
|
||
if (v == null) return '';
|
||
if (typeof v === 'string') return v;
|
||
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
|
||
return JSON.stringify(v);
|
||
}
|
||
|
||
function _charAgeLabel(sh) {
|
||
return _libStr(sh?.age_estimate || '').trim();
|
||
}
|
||
|
||
// age_estimate is free text ("30-40 Jahre", "Mittleres Alter") — pull the
|
||
// first number out for a rough sort order; characters with no number
|
||
// (or no estimate at all) sort to the end rather than clumping at zero.
|
||
function _charAgeSortVal(sh) {
|
||
const m = _charAgeLabel(sh).match(/\d+/);
|
||
return m ? parseInt(m[0], 10) : 9999;
|
||
}
|
||
|
||
function _charLangLabel(rec) {
|
||
const sh = rec?.sheet || {};
|
||
const voiceLang = (rec?.voice && typeof rec.voice === 'object') ? (rec.voice.language || '') : '';
|
||
return _libStr(sh.languages || voiceLang).trim();
|
||
}
|
||
|
||
function _charGenderLabel(sh) {
|
||
const gender = String(sh?.gender || '').trim();
|
||
if (!gender) return '';
|
||
return gender.charAt(0).toUpperCase() + gender.slice(1);
|
||
}
|
||
|
||
function _charRelsHtml(rec, allChars) {
|
||
if (!allChars || allChars.length < 2) return '';
|
||
const relText = _libStr(rec.sheet?.relationships).toLowerCase();
|
||
if (!relText) return '';
|
||
const hits = allChars
|
||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||
.map(function (c) {
|
||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||
return { c: c, n: (relText.match(re) || []).length };
|
||
})
|
||
.filter(function (x) { return x.n > 0; })
|
||
.sort(function (a, b) { return b.n - a.n; })
|
||
.slice(0, 5);
|
||
if (!hits.length) return '';
|
||
return '<div class="lib-char-rels">'
|
||
+ hits.map(function (x) {
|
||
const h = _charHue(x.c.name);
|
||
return '<span class="lib-char-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||
}).join('')
|
||
+ '</div>';
|
||
}
|
||
|
||
// Single shared floating preview element, positioned near whichever avatar
|
||
// is currently hovered — created lazily on first use rather than once per
|
||
// row (there can be dozens of rows in a production).
|
||
let _avatarHoverEl = null;
|
||
function _showAvatarHoverPreview(rec, anchorEl) {
|
||
if (!rec.image) return;
|
||
if (!_avatarHoverEl) {
|
||
_avatarHoverEl = document.createElement('div');
|
||
_avatarHoverEl.className = 'lib-avatar-hover-preview';
|
||
_avatarHoverEl.innerHTML = '<img>';
|
||
document.body.appendChild(_avatarHoverEl);
|
||
}
|
||
_avatarHoverEl.querySelector('img').src = rec.image;
|
||
const rect = anchorEl.getBoundingClientRect();
|
||
const size = 512;
|
||
// Prefer opening to the right of the thumbnail; flip to the left if
|
||
// there isn't enough room, and clamp vertically so it never runs off
|
||
// the top/bottom of the viewport.
|
||
let left = rect.right + 12;
|
||
if (left + size > window.innerWidth) left = rect.left - size - 12;
|
||
let top = rect.top + rect.height / 2 - size / 2;
|
||
top = Math.max(8, Math.min(top, window.innerHeight - size - 8));
|
||
_avatarHoverEl.style.left = Math.max(8, left) + 'px';
|
||
_avatarHoverEl.style.top = top + 'px';
|
||
_avatarHoverEl.hidden = false;
|
||
}
|
||
function _hideAvatarHoverPreview() {
|
||
if (_avatarHoverEl) _avatarHoverEl.hidden = true;
|
||
}
|
||
|
||
// Interactivity for a grid of .lib-char-card elements (click → detail page,
|
||
// avatar upload, voice pick/auto/online/generate, export) — shared by the
|
||
// Library's own character grid and by anywhere else that wants the exact
|
||
// same cast-card look and behavior (e.g. the "Cast Characters" results grid)
|
||
// instead of a diverging copy.
|
||
function _wireCharCards(root, recsById, allRecs, onChange, detailOpts) {
|
||
const refresh = onChange || libraryRenderCharacters;
|
||
root.querySelectorAll('.lib-char-card').forEach(function (card) {
|
||
const charId = card.dataset.charId;
|
||
const rec = recsById.get ? recsById.get(charId) : recsById[charId];
|
||
if (!rec) return;
|
||
|
||
card.addEventListener('click', function (e) {
|
||
if (e.target.closest('button, .lib-char-avatar, .lib-voice-picker-popup')) return;
|
||
_charDetailPage(rec, allRecs, detailOpts);
|
||
});
|
||
|
||
card.querySelector('.lib-char-avatar')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_openAvatarLightbox(rec, refresh);
|
||
});
|
||
|
||
// Hovering a small thumbnail (especially in the table view, where it's
|
||
// often just 32-40px) gives no real sense of the actual portrait —
|
||
// a big floating preview near the cursor, no click needed.
|
||
if (rec.image) {
|
||
const avatarEl = card.querySelector('.lib-char-avatar');
|
||
avatarEl?.addEventListener('mouseenter', function () { _showAvatarHoverPreview(rec, avatarEl); });
|
||
avatarEl?.addEventListener('mouseleave', _hideAvatarHoverPreview);
|
||
}
|
||
|
||
card.querySelector('.lib-char-voice-pill')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_openVoicePicker(e.currentTarget, rec, function () { refresh(); });
|
||
});
|
||
|
||
card.querySelector('.lib-char-pick-voice')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
// Anchoring to the whole row/card (`card`) instead of the actual
|
||
// button clicked (`e.currentTarget`) meant _openVoicePicker's
|
||
// getBoundingClientRect() measured the ENTIRE row — which can be far
|
||
// taller than the button itself when the character has a long
|
||
// description — so the popup opened anchored to the row's own
|
||
// top/bottom instead of next to "Auswahl", confirmed live as
|
||
// appearing several rows away from the button that opened it (the
|
||
// exact table-vs-card position bug _openVoicePicker's own body
|
||
// comment already describes, just reintroduced here by passing the
|
||
// wrong element).
|
||
_openVoicePicker(e.currentTarget, rec, function () { refresh(); });
|
||
});
|
||
|
||
card.querySelector('.lib-char-auto-voice')?.addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
await _autoAssignVoice(rec);
|
||
refresh();
|
||
});
|
||
|
||
card.querySelector('.lib-char-redesign-voice')?.addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
const btn = e.currentTarget;
|
||
btn.disabled = true;
|
||
try { await _charAutoDesignVoice(rec, true); _schedulePendingTtsRestart(); refresh(); }
|
||
catch (err) { toast('Voice design failed: ' + (err.message || err), 'error'); }
|
||
finally { btn.disabled = false; }
|
||
});
|
||
|
||
card.querySelector('.lib-char-remove-voice')?.addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
await clPut(Object.assign({}, rec, { voice: '', updated: new Date() }));
|
||
rec.voice = '';
|
||
_syncVoicePictureFromChar(rec);
|
||
toast('Voice removed from ' + rec.name, 'success');
|
||
refresh();
|
||
});
|
||
|
||
card.querySelector('.lib-char-export')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (typeof stExportRecord === 'function') stExportRecord(rec);
|
||
});
|
||
|
||
card.querySelector('.lib-char-online-voice')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_charSearchOnline(rec);
|
||
});
|
||
|
||
card.querySelector('.lib-char-gen-voice')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_charDesignVoiceInline(rec);
|
||
});
|
||
|
||
card.querySelector('.lib-char-voice-play')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_libPreviewCharVoice(rec, e.currentTarget);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Play/stop a quick sample line in a character's assigned voice, straight
|
||
// from the cast list/table — mirrors the Rehearser's own "Hear a line"
|
||
// button (_rehPreviewCastLine) so casting decisions don't require opening
|
||
// the full profile or leaving the Library page just to hear the voice.
|
||
let _libVoicePreviewEl = null, _libVoicePreviewBtn = null;
|
||
function _libStopVoicePreview() {
|
||
if (_libVoicePreviewEl) { _libVoicePreviewEl.pause(); _libVoicePreviewEl.src = ''; }
|
||
if (_libVoicePreviewBtn) {
|
||
_libVoicePreviewBtn.classList.remove('playing', 'loading');
|
||
const icon = _libVoicePreviewBtn.querySelector('.mdi');
|
||
if (icon) icon.className = 'mdi mdi-play';
|
||
}
|
||
_libVoicePreviewBtn = null;
|
||
}
|
||
// Plays the voice's own stored audio file directly (its clone reference, or
|
||
// the take a design generated) — same instant, no-GPU mechanism as the "play
|
||
// original recording" button in My Voices. This used to call fetchTtsPreviewBlob
|
||
// instead, synthesizing a brand-new sample through the TTS engine on every
|
||
// click just to confirm which voice a character has — slow and needlessly
|
||
// GPU-heavy for something the exact audio already sitting on disk answers
|
||
// instantly. Confirmed live: My Voices' own reference-file button is instant;
|
||
// this one now reuses the same voiceFileUrl() rather than re-synthesizing.
|
||
async function _libPreviewCharVoice(rec, btn) {
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
if (!voiceId) { toast('No voice assigned yet', 'error'); return; }
|
||
if (_libVoicePreviewBtn === btn && _libVoicePreviewEl && !_libVoicePreviewEl.paused) { _libStopVoicePreview(); return; }
|
||
_libStopVoicePreview();
|
||
const icon = btn.querySelector('.mdi');
|
||
const v = (window._voices || []).find(x => x.id === voiceId);
|
||
if (!v || !v.path || typeof voiceFileUrl !== 'function') { toast('Voice file not found', 'error'); return; }
|
||
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
|
||
try {
|
||
if (!_libVoicePreviewEl) { _libVoicePreviewEl = new Audio(); _libVoicePreviewEl.addEventListener('ended', _libStopVoicePreview); }
|
||
_libVoicePreviewEl.src = voiceFileUrl(v);
|
||
await _libVoicePreviewEl.play();
|
||
btn.classList.remove('loading'); _libVoicePreviewBtn = btn; btn.classList.add('playing');
|
||
if (icon) icon.className = 'mdi mdi-stop';
|
||
} catch (e) {
|
||
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
|
||
toast('Preview failed: ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
|
||
// A character can keep pointing at a voice id that was since deleted
|
||
// straight from the Voice Library (confirmed live: deleting broken designed
|
||
// voices there left them still listed, un-flagged, on every character card).
|
||
// Only trusted once the voice list has actually loaded — an empty/unloaded
|
||
// `window._voices` must never be read as "nothing exists".
|
||
function _voiceExists(voiceId) {
|
||
if (!voiceId) return true;
|
||
const voices = window._voices || [];
|
||
if (!voices.length) return true;
|
||
return voices.some(function (v) { return v.id === voiceId; });
|
||
}
|
||
|
||
function _charCardHtml(rec, allChars) {
|
||
const sh = rec.sheet || {};
|
||
const hue = _charHue(rec.name);
|
||
const hue2 = (hue + 40) % 360;
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
const voiceMissing = !!voiceId && !_voiceExists(voiceId);
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
|
||
: tier === 'supporting' ? '<span class="lib-char-tier support">Neben</span>' : '';
|
||
const gender = String(sh.gender || '').toLowerCase();
|
||
const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||
const snippet = _libStr(sh.mannerisms || sh.voice_pattern || sh.motivation || sh.backstory || '').slice(0, 190);
|
||
const roleLine = _libStr(sh.profession || sh.archetype).trim();
|
||
const ageLabel = _charAgeLabel(sh);
|
||
const langLabel = _charLangLabel(rec);
|
||
const genderLabel = _charGenderLabel(sh);
|
||
const bookLabel = _libStr(rec.book || '');
|
||
const lineLabel = sh.line_count != null ? String(sh.line_count) + ' Zeilen' : '';
|
||
// Every character is auto-tagged with the book it was found in (see
|
||
// clUpsert/clMergeTags) — surfacing the chips here is how you tell a
|
||
// character recurring across several books apart from a same-named one
|
||
// that's book-local, since tags (not the single `book` field) are what
|
||
// carries multi-book membership.
|
||
const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
||
const tagsHtml = tagList.length
|
||
? '<div class="lib-char-tags">' + tagList.map(function (t) {
|
||
return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>';
|
||
}).join('') + '</div>'
|
||
: '';
|
||
|
||
// A saved portrait used to be squeezed into a small 96px circle floating
|
||
// over the same colour-gradient banner everyone without a photo gets —
|
||
// once there's a real picture, it reads much better as the banner's own
|
||
// background (full-bleed, with a bottom-fade so the name/role text
|
||
// overlaid on it stays legible) than shrunk down to icon size.
|
||
const hasPhoto = !!rec.image;
|
||
const bannerStyle = hasPhoto
|
||
? 'style="background-image:linear-gradient(180deg, rgba(0,0,0,.05) 0%, rgba(0,0,0,.72) 100%), url("' + rec.image + '"); background-size:cover; background-position:center;"'
|
||
: 'style="--ch1:hsl(' + hue + ',52%,35%);--ch2:hsl(' + hue2 + ',56%,26%)"';
|
||
// Photo already fills the banner as a background — .lib-char-avatar stays
|
||
// in the DOM either way (it's the click target _wireCharCards listens on
|
||
// to open the avatar picker), just shrunk to a small corner "change photo"
|
||
// button instead of a full avatar circle when there's nothing left for it
|
||
// to visually display.
|
||
const avatarInner = hasPhoto ? '<span class="mdi mdi-camera-outline"></span>' : escHtml((rec.name || '?')[0].toUpperCase());
|
||
|
||
// Skip a fact entirely when empty instead of rendering an empty "—" row —
|
||
// the old boxed layout showed every stat regardless so the 2-column grid
|
||
// stayed aligned; a compact list has no such constraint, and an empty row
|
||
// is just wasted space in an already-tight card.
|
||
const stat = function (label, value, icon) {
|
||
if (!value) return '';
|
||
return '<div class="lib-char-stat-row">'
|
||
+ '<span class="lib-char-stat-icon mdi ' + (icon || '') + '"></span>'
|
||
+ '<span class="lib-char-stat-label">' + escHtml(label) + '</span>'
|
||
+ '<span class="lib-char-stat-value">' + escHtml(value) + '</span>'
|
||
+ '</div>';
|
||
};
|
||
|
||
// Gender and lines already get their own stat boxes in the card body below
|
||
// (and age is joining them there too) — showing them a second time as
|
||
// pills on the banner was pure duplication. Only the book/production tag
|
||
// stays here, since that's the banner's own context, not the body's.
|
||
const metaChips = [];
|
||
if (bookLabel) metaChips.push('<span class="lib-char-meta-chip"><span class="mdi mdi-book-open-page-variant-outline"></span> ' + escHtml(bookLabel) + '</span>');
|
||
|
||
return '<div class="lib-char-card" data-char-id="' + escHtml(rec.id) + '">'
|
||
+ '<label class="lib-char-select" title="Select for bulk actions" onclick="event.stopPropagation()">'
|
||
+ '<input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '">'
|
||
+ '</label>'
|
||
+ '<div class="lib-char-card-banner' + (hasPhoto ? ' has-photo' : '') + '" ' + bannerStyle + '>'
|
||
+ '<div class="lib-char-avatar' + (hasPhoto ? ' is-photo-edit' : '') + '" data-char-id="' + escHtml(rec.id) + '" title="Bild hochladen">' + avatarInner + '</div>'
|
||
+ '<div class="lib-char-voice-top-row">'
|
||
+ (voiceId ? '<button type="button" class="lib-char-voice-play" data-char-id="' + escHtml(rec.id) + '" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span></button>' : '')
|
||
+ '<button type="button" class="lib-char-voice-pill' + (voiceId ? ' has-voice' : '') + (voiceMissing ? ' voice-missing' : '') + '" title="' + (voiceMissing ? 'Stimme "' + escHtml(voiceId) + '" wurde aus der Voice Library gelöscht — bitte neu zuweisen' : voiceId ? 'Stimme: ' + escHtml(voiceId) + ' — klicken zum Ändern' : 'Stimme auswählen oder generieren') + '">'
|
||
+ '<span class="mdi ' + (voiceMissing ? 'mdi-alert-circle-outline' : voiceId ? 'mdi-volume-high' : 'mdi-volume-off') + '"></span>'
|
||
+ '<span class="lib-char-voice-pill-text">' + (voiceId ? escHtml(voiceId) : 'Keine Stimme zugewiesen') + '</span>'
|
||
+ '</button>'
|
||
+ '</div>'
|
||
+ '<div class="lib-char-banner-copy">'
|
||
+ '<div class="lib-char-name">' + escHtml(rec.name) + '<span class="mdi ' + genderIcon + '" style="font-size:12px;opacity:.55"></span>' + tierBadge
|
||
+ '</div>'
|
||
+ (roleLine ? '<div class="lib-char-roleline">' + escHtml(roleLine) + '</div>' : '')
|
||
+ (_libStr(sh.title) ? '<div class="lib-char-subline">Titel: ' + escHtml(_libStr(sh.title)) + '</div>' : '')
|
||
+ (_libStr(sh.aliases) ? '<div class="lib-char-subline">aka ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
|
||
+ '<div class="lib-char-meta-chips">' + metaChips.join('') + '</div>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lib-char-body">'
|
||
+ '<div class="lib-char-facts">'
|
||
+ stat('Occupation', _libStr(sh.profession), 'mdi-briefcase-outline')
|
||
+ stat('Archetype', _libStr(sh.archetype), 'mdi-shape-outline')
|
||
+ stat('Gender', genderLabel, 'mdi-gender-male-female')
|
||
+ stat('Age', ageLabel, 'mdi-cake-variant')
|
||
+ stat('Lines', lineLabel, 'mdi-format-list-numbered')
|
||
+ '</div>'
|
||
+ _charAlignHtml(sh)
|
||
+ '</div></div>';
|
||
}
|
||
|
||
// Compact alternative to the card grid for scanning a large cast at once —
|
||
// one row per character with the fields that matter for casting decisions.
|
||
// Rows carry the "lib-char-card" class too so they're picked up by the same
|
||
// avatar/voice-picker/auto-voice/export wiring the card grid already uses;
|
||
// only the HTML shape differs.
|
||
function _charsTableHtml(chars, sortMode, sortDir) {
|
||
const arrow = function (key) { return sortMode === key ? ' <span class="mdi mdi-arrow-' + (sortDir === 'desc' ? 'down' : 'up') + '" style="font-size:11px"></span>' : ''; };
|
||
const th = function (key, label, title) {
|
||
return '<th class="lib-chars-tbl-sortable" data-sort-key="' + key + '"' + (title ? ' title="' + title + '"' : '') + '>' + label + arrow(key) + '</th>';
|
||
};
|
||
const rows = chars.map(function (rec) {
|
||
const sh = rec.sheet || {};
|
||
const hue = _charHue(rec.name);
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
const voiceMissing = !!voiceId && !_voiceExists(voiceId);
|
||
const voiceLang = _charLangLabel(rec);
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
|
||
: tier === 'supporting' ? '<span class="lib-char-tier support">Neben</span>' : '';
|
||
const gender = String(sh.gender || '').toLowerCase();
|
||
const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : gender ? 'mdi-gender-non-binary' : '';
|
||
const genderLabel = _charGenderLabel(sh);
|
||
const score = sh.moral_alignment_score;
|
||
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
|
||
const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
|
||
const avatarInner = rec.image
|
||
? '<img src="' + rec.image + '" alt="' + escHtml(rec.name) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%">'
|
||
: escHtml((rec.name || '?')[0].toUpperCase());
|
||
const occupation = _libStr(sh.profession);
|
||
const ageLabel = _charAgeLabel(sh);
|
||
const bookLabel = _libStr(rec.book || '');
|
||
const aliasLabel = _libStr(sh.aliases);
|
||
const archetype = _libStr(sh.archetype);
|
||
const titleLabel = _libStr(sh.title);
|
||
const tableMeta = [aliasLabel ? 'aka ' + aliasLabel : '', occupation ? 'Occupation: ' + occupation : '', titleLabel ? 'Title: ' + titleLabel : '', archetype ? 'Archetype: ' + archetype : ''].filter(Boolean).join(' · ');
|
||
return '<tr class="lib-char-card lib-chars-tbl-row" data-char-id="' + escHtml(rec.id) + '">'
|
||
+ '<td class="lib-chars-tbl-cb" onclick="event.stopPropagation()"><input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '"></td>'
|
||
+ '<td class="lib-chars-tbl-avatar-cell"><div class="lib-char-avatar lib-chars-tbl-avatar" data-char-id="' + escHtml(rec.id) + '" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + avatarInner + '</div></td>'
|
||
+ '<td class="lib-chars-tbl-name">'
|
||
+ '<div class="lib-chars-tbl-name-main">' + tierBadge + escHtml(rec.name) + '</div>'
|
||
+ (tableMeta ? '<div class="lib-chars-tbl-sub">' + escHtml(tableMeta) + '</div>' : '')
|
||
+ (bookLabel ? '<div class="lib-chars-tbl-book">' + escHtml(bookLabel) + '</div>' : '')
|
||
+ '</td>'
|
||
+ '<td>' + (genderLabel ? escHtml(genderLabel) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||
+ '<td>' + (ageLabel ? escHtml(ageLabel) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||
+ '<td>' + (sh.line_count != null ? sh.line_count : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||
+ '<td>' + (voiceLang ? escHtml(voiceLang) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||
+ '<td>' + (pct != null ? '<div class="lib-char-align-bar" title="' + pct + '/100"><div class="lib-char-align-dot" style="left:' + pct + '%"></div></div>' : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||
+ '<td class="lib-chars-tbl-voice"><div class="lib-chars-tbl-voice-wrap">'
|
||
+ '<div class="lib-chars-tbl-voice-row">' + (voiceId ? '<span class="' + (voiceMissing ? 'lib-chars-tbl-voice-missing' : '') + '"' + (voiceMissing ? ' title="Stimme wurde aus der Voice Library gelöscht — bitte neu zuweisen"' : '') + '>' + (voiceMissing ? '<span class="mdi mdi-alert-circle-outline"></span> ' : '') + escHtml(voiceId) + '</span>' : '<span class="lib-chars-tbl-dash">Keine Stimme</span>') + '</div>'
|
||
+ '<div class="lib-chars-tbl-voice-row">'
|
||
+ (voiceId ? '<button type="button" class="lib-char-voice-play" data-char-id="' + escHtml(rec.id) + '" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span></button>' : '')
|
||
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button><button class="lib-char-auto-voice btn-sm">Auto</button>'
|
||
+ '<button class="lib-char-redesign-voice btn-sm" title="Design a brand-new voice for this character, ignoring any existing match" aria-label="Design a new voice"><span class="mdi mdi-creation-outline"></span></button>'
|
||
+ '<button class="lib-char-gen-voice btn-sm" title="Show and edit the Voice Design Prompt before generating — opens the Design a Voice page pre-filled" aria-label="Edit voice design prompt"><span class="mdi mdi-text-box-edit-outline"></span></button>'
|
||
+ (voiceId ? '<button class="lib-char-remove-voice btn-sm" title="Remove this voice — leaves the character unassigned" aria-label="Remove voice"><span class="mdi mdi-close"></span></button>' : '')
|
||
+ '</div>'
|
||
+ '</div></td>'
|
||
+ '<td class="lib-chars-tbl-tags"><div class="lib-chars-tbl-tags-wrap">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</div></td>'
|
||
+ '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
|
||
+ '</tr>';
|
||
}).join('');
|
||
return '<div class="lib-chars-tbl-wrap"><table class="lib-chars-tbl">'
|
||
// table-layout:auto put a max-width'd wrapping cell (Tags) at the wrong
|
||
// physical position — its own header stayed put but the cell rendered
|
||
// stacked under the previous column instead, a Chromium auto-layout
|
||
// quirk from mixing content-based and max-width-constrained columns in
|
||
// the same row. Fixed explicit widths sidestep the whole class of bug.
|
||
+ '<colgroup><col style="width:28px"><col style="width:60px"><col style="width:300px">'
|
||
+ '<col style="width:88px"><col style="width:80px"><col style="width:72px"><col style="width:100px">'
|
||
+ '<col style="width:140px"><col style="width:230px"><col style="width:220px"><col style="width:200px">'
|
||
+ '<col style="width:36px"></colgroup>'
|
||
+ '<thead><tr>'
|
||
+ '<th class="lib-chars-tbl-cb"><input type="checkbox" class="lib-chars-tbl-select-all-cb" title="Select all"></th><th></th>' + th('alpha', 'Name') + th('gender', 'Geschlecht') + th('age', 'Alter', 'Estimated age')
|
||
+ th('lines', 'Zeilen', 'Anzahl Zeilen') + th('language', 'Sprache') + th('align', 'Gut/Böse', 'Moralische Gesinnung') + th('voice', 'Stimme')
|
||
+ '<th>Tags</th><th>Book / Script</th><th></th>'
|
||
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||
}
|
||
|
||
// ── Character detail modal ────────────────────────────────────────────────────
|
||
|
||
function _lcdSourcesHtml(sources) {
|
||
const list = Array.isArray(sources) ? sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
|
||
if (!list.length) return '';
|
||
return '<div class="lcd-sources">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text</div>'
|
||
+ '<div class="lcd-source-list">'
|
||
+ list.map(function (s) {
|
||
const page = s.page != null ? 'Seite ' + s.page : '';
|
||
const hint = _libStr(s.line_hint || s.hint || '');
|
||
return '<div class="lcd-source-item">'
|
||
+ (page || hint ? '<div class="lcd-source-page">' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
|
||
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
|
||
+ '</div>';
|
||
}).join('')
|
||
+ '</div></div>';
|
||
}
|
||
|
||
function _lcdField(label, value, multiline) {
|
||
const v = _libStr(value);
|
||
if (!v) return '';
|
||
return '<div class="lcd-field">'
|
||
+ '<div class="lcd-field-label">' + label + '</div>'
|
||
+ '<div class="lcd-field-value">' + (multiline ? escHtml(v) : escHtml(v)) + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function _lcdSection(icon, label, fields) {
|
||
const body = fields.join('');
|
||
if (!body) return '';
|
||
return '<div class="lcd-section">'
|
||
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
|
||
+ body
|
||
+ '</div>';
|
||
}
|
||
|
||
function _lcdSectionFull(icon, label, fields) {
|
||
const body = fields.join('');
|
||
if (!body) return '';
|
||
return '<div class="lcd-section-full">'
|
||
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
|
||
+ body
|
||
+ '</div>';
|
||
}
|
||
|
||
// A collapsible "ready to copy" box for one of the four external-tool prompts
|
||
// (Voice Design / Character Image / SillyTavern / Concept Art). Closed by
|
||
// default; editable in place via the same contenteditable+data-sheet-key
|
||
// convention the rest of the detail view uses, so edits autosave for free.
|
||
function _lcdPromptBox(label, value, sheetKey) {
|
||
const has = !!(value && String(value).trim());
|
||
return '<details class="lcd-prompt-box">'
|
||
+ '<summary>' + escHtml(label) + (has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
|
||
+ '<div class="lcd-prompt-body">'
|
||
+ '<div class="lcd-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(value || '') + '</div>'
|
||
+ '<div class="lcd-prompt-actions">'
|
||
+ '<button type="button" class="btn-secondary btn-sm lcd-prompt-copy" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-copy"></span> Copy</button>'
|
||
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompt" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-creation"></span> ' + (has ? 'Regenerate' : 'Generate') + '</button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '</details>';
|
||
}
|
||
|
||
// sourceIdxs (optional): indices into the character's sources list — shown
|
||
// as small clickable "[N]" links next to the label so a reader who doubts a
|
||
// fact can jump straight to the exact page + quote it was extracted from,
|
||
// instead of scanning the whole "Quellen im Text" list at the bottom.
|
||
function _lcdFieldEdit(label, value, sheetKey, sourceIdxs) {
|
||
const v = _libStr(value);
|
||
const links = (sourceIdxs || []).map(function (idx) {
|
||
return '<a href="#" class="lcd-field-src-link" data-jump-src="' + idx + '" title="Quelle im Text anzeigen">' + (sourceIdxs.indexOf(idx) + 1) + '</a>';
|
||
}).join('');
|
||
return '<div class="lcd-field">'
|
||
+ (label || links ? '<div class="lcd-field-label">' + escHtml(label) + (links ? ' <span class="lcd-field-src-links">' + links + '</span>' : '') + '</div>' : '')
|
||
+ '<div class="lcd-field-value lcd-field-editable" contenteditable="true" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(v) + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function _jumpToReaderPage(pageNum) {
|
||
// Always navigate to the reader section first
|
||
if (typeof navTo === 'function') navTo('s-reader');
|
||
setTimeout(function () {
|
||
// Try page-div scroll (PDF reader)
|
||
const pages = window.readerState?.pages;
|
||
if (pages && pages.length >= pageNum) {
|
||
const pg = pages[pageNum - 1];
|
||
if (pg?.pageDiv) { pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; }
|
||
}
|
||
// Try sentence-index jump — find first sentence on or after target page (0-indexed internally)
|
||
const sentences = window.readerState?.sentences;
|
||
if (sentences && sentences.length) {
|
||
const target0 = pageNum - 1;
|
||
const idx = sentences.findIndex(function (s) {
|
||
return (s.words || []).some(function (w) { return (w.page ?? w.para ?? 0) >= target0; });
|
||
});
|
||
if (idx >= 0 && typeof readerJumpTo === 'function') { readerJumpTo(idx); return; }
|
||
}
|
||
toast('Öffne das Buch in „Vorlesen" und klicke nochmal auf die Quelle', 'info');
|
||
}, 300);
|
||
}
|
||
|
||
// ── Character detail PAGE (full-page with inline editing, replaces the modal) ─
|
||
|
||
// Real dialogue-line count for a character, preferring the live count from
|
||
// whatever script/rehearsal is currently loaded (exact) and falling back to
|
||
// the LLM's own line_count estimate from casting time (approximate, but
|
||
// still a real line count — unlike sh.sources.length, which is a capped
|
||
// count of saved reference quotes with no relation to how much a character
|
||
// actually speaks).
|
||
function _charLineCount(c) {
|
||
if (window.rehState && rehState.lines && rehState.lines.length) {
|
||
const key = String(c.name || '').toUpperCase().trim();
|
||
const live = rehState.lines.filter(function (l) {
|
||
return l.type === 'dialog' && String(l.speaker || '').toUpperCase().trim() === key;
|
||
}).length;
|
||
if (live) return live;
|
||
}
|
||
return Number(c.sheet && c.sheet.line_count) || 0;
|
||
}
|
||
|
||
async function _charDetailPage(rec, allChars, opts) {
|
||
opts = opts || {};
|
||
// Defaults to the Library grid's own list container/back-navigation, but
|
||
// any caller showing character cards elsewhere (e.g. the fresh-recast
|
||
// results grid on the Read Aloud page) can pass its own container + onBack
|
||
// so clicking a card there opens the profile in place instead of silently
|
||
// doing nothing (there is no #lib-chars-list on that page).
|
||
const container = opts.container || document.getElementById('lib-chars-list');
|
||
if (!container) return;
|
||
const goBack = typeof opts.onBack === 'function' ? opts.onBack : libraryRenderCharacters;
|
||
window._libDetailRec = rec;
|
||
|
||
const sh = rec.sheet || {};
|
||
const hue = _charHue(rec.name);
|
||
const hue2 = (hue + 40) % 360;
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
|
||
const gender = _libStr(sh.gender);
|
||
const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female'
|
||
: gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
const score = sh.moral_alignment_score;
|
||
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
|
||
const arcMap = {
|
||
'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
|
||
'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
|
||
'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
|
||
'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
|
||
'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' },
|
||
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
|
||
};
|
||
const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral'];
|
||
|
||
const avatarHtml = '<div class="lcd-avatar-wrap">'
|
||
+ (rec.image
|
||
? '<div class="lcd-avatar lcd-avatar-upload" title="Bild hochladen"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>')
|
||
+ '<div class="lcd-avatar-actions">'
|
||
+ '<button class="lcd-avatar-upload-btn" type="button" title="Von Festplatte hochladen"><span class="mdi mdi-upload"></span></button>'
|
||
+ '<button class="lcd-avatar-online-btn" type="button" title="Im Internet suchen"><span class="mdi mdi-web"></span></button>'
|
||
+ '<button class="lcd-avatar-gen-btn" type="button" title="Mit KI generieren"><span class="mdi mdi-creation"></span></button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
|
||
// Concept art (the auto-generated NPC design-sheet image, distinct from
|
||
// rec.image/the avatar) — a full-width banner just below the header, so a
|
||
// character that already went through casting shows its generated sheet
|
||
// at a real, readable size instead of it being buried as small text-only
|
||
// in the Generation Prompts section further down, or squeezed into the
|
||
// header at thumbnail size (confirmed live: 110px was too small to read
|
||
// any actual detail in a multi-pose design sheet).
|
||
const conceptArtHtml = '<div class="lcd-section-full lcd-conceptart">'
|
||
+ '<div class="lcd-conceptart-headrow">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-image-frame"></span> Konzeptbild</div>'
|
||
+ '<button type="button" class="lcd-conceptart-gen btn-secondary btn-sm" title="Konzeptbild generieren">'
|
||
+ '<span class="mdi mdi-creation"></span> ' + (sh.concept_art_image ? 'Neu generieren' : 'Generieren')
|
||
+ '</button>'
|
||
+ '</div>'
|
||
+ (sh.concept_art_image
|
||
? '<div class="lcd-conceptart-img" title="Konzeptbild ansehen"><img src="' + sh.concept_art_image + '" alt="Concept art — ' + escHtml(rec.name) + '"></div>'
|
||
: '<div class="lcd-conceptart-empty' + (_libStr(sh.concept_art_prompt).trim() ? '' : ' is-noprompt') + '">'
|
||
+ '<span class="mdi mdi-image-frame"></span>'
|
||
+ '<span>' + (_libStr(sh.concept_art_prompt).trim() ? 'Kein Konzeptbild' : 'Kein Konzeptbild-Prompt — erst unten bei Generation Prompts erzeugen') + '</span>'
|
||
+ '</div>')
|
||
+ '</div>';
|
||
|
||
const alignHtml = pct != null ? (
|
||
'<div class="lcd-align-section">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
|
||
+ '<div class="lcd-align-bar-wrap">'
|
||
+ '<span class="lcd-align-label">Böse</span>'
|
||
+ '<input type="range" class="lcd-align-slider" min="0" max="100" value="' + pct + '">'
|
||
+ '<span class="lcd-align-label">Gut</span>'
|
||
+ '<span class="lcd-align-slider-val">' + pct + '/100</span>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label
|
||
+ (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)')
|
||
+ '</div>'
|
||
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
const promptsHtml = '<div class="lcd-section-full lcd-prompts-section">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-script-text-outline"></span> Generation Prompts</div>'
|
||
+ _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_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')
|
||
+ '</div>';
|
||
|
||
const relText = _libStr(sh.relationships).toLowerCase();
|
||
const relHits = (allChars || [])
|
||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||
.map(function (c) {
|
||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||
return { c: c, n: (relText.match(re) || []).length };
|
||
})
|
||
.filter(function (x) { return x.n > 0; })
|
||
.sort(function (a, b) { return b.n - a.n; })
|
||
.slice(0, 8);
|
||
const relDotsHtml = relHits.length ? (
|
||
'<div class="lcd-rels-dots">'
|
||
+ relHits.map(function (x) {
|
||
const h = _charHue(x.c.name);
|
||
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||
}).join('')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
const sourcesList = Array.isArray(sh.sources) ? sh.sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
|
||
// Group source indices by the sheet field they were extracted for
|
||
// (line_hint), so each detail field below can show "jump to this exact
|
||
// citation" links instead of making the reader search the full list.
|
||
const sourcesByField = {};
|
||
sourcesList.forEach(function (s, idx) {
|
||
const key = _libStr(s.line_hint || s.hint || '').trim().toLowerCase();
|
||
if (!key) return;
|
||
(sourcesByField[key] = sourcesByField[key] || []).push(idx);
|
||
});
|
||
const sourcesHtml = sourcesList.length ? (
|
||
'<div class="lcd-sources">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text'
|
||
+ '<span class="lcd-sources-hint"> · Klicken zum Springen</span></div>'
|
||
+ '<div class="lcd-source-list">'
|
||
+ sourcesList.map(function (s, idx) {
|
||
const page = s.page != null ? 'Seite ' + s.page : '';
|
||
const hint = _libStr(s.line_hint || s.hint || '');
|
||
return '<div class="lcd-source-item lcd-source-clickable" id="lcd-src-' + idx + '" data-page="' + (s.page != null ? s.page : '') + '">'
|
||
+ (page || hint ? '<div class="lcd-source-page"><span class="mdi mdi-book-open-page-variant-outline"></span> ' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
|
||
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
|
||
+ '</div>';
|
||
}).join('')
|
||
+ '</div></div>'
|
||
) : '';
|
||
|
||
// This used to show sh.sources.length — the number of saved reference
|
||
// quotes in the character's profile (capped at 12), not how much the
|
||
// character actually speaks. Confirmed live as a genuinely confusing
|
||
// number: dozens of characters showed the exact same "12" simply because
|
||
// they'd all hit that cap, with no relation to their real line count.
|
||
// Prefer the actual live count from the currently-loaded script (exact),
|
||
// falling back to the LLM's own line_count estimate from casting time
|
||
// when no script is loaded here.
|
||
const sidebarChars = (allChars || []).slice().sort(function (a, b) {
|
||
return _charLineCount(b) - _charLineCount(a);
|
||
});
|
||
const sidebarHtml = sidebarChars.map(function (c) {
|
||
const h = _charHue(c.name);
|
||
const count = _charLineCount(c);
|
||
return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">'
|
||
+ '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>'
|
||
+ '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>'
|
||
+ (count ? '<span class="lib-cpg-sidebar-count">' + count + '</span>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
|
||
container.innerHTML = '';
|
||
const pg = document.createElement('div');
|
||
pg.className = 'lib-char-page';
|
||
// The Read Aloud results grid this page can also render into (.cs-list) is
|
||
// a CSS grid with minmax(300px,1fr) card columns — inserting this page's
|
||
// own two-column flex layout as a single grid item confined it to ONE
|
||
// track's width, squeezing the main content to a sliver while the
|
||
// fixed-width sidebar overflowed out past it. Spanning every column is a
|
||
// no-op in the Library's own plain (non-grid) #lib-chars-list container, so
|
||
// this is safe in both places rather than needing two code paths.
|
||
pg.style.gridColumn = '1 / -1';
|
||
pg.innerHTML =
|
||
'<div class="lib-cpg-main">'
|
||
+ '<button class="lib-cpg-back"><span class="mdi mdi-arrow-left"></span> Alle Charaktere</button>'
|
||
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + hue2 + ',58%,25%)">'
|
||
+ avatarHtml
|
||
+ '<div class="lcd-header-body">'
|
||
+ '<div class="lcd-name" contenteditable="true" data-rec-key="name" spellcheck="false">' + escHtml(rec.name) + '</div>'
|
||
+ (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="full_name" spellcheck="false">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
|
||
+ (_libStr(sh.title) ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="title" spellcheck="false">' + escHtml(_libStr(sh.title)) + '</div>' : '')
|
||
+ '<div class="lcd-aliases" contenteditable="true" data-sheet-key="aliases" spellcheck="false">' + escHtml(_libStr(sh.aliases)) + '</div>'
|
||
+ '<div class="lcd-archetype" contenteditable="true" data-sheet-key="archetype" spellcheck="false">' + escHtml(_libStr(sh.archetype)) + '</div>'
|
||
+ '<div class="lcd-tier-gender">'
|
||
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
|
||
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-body">'
|
||
+ conceptArtHtml
|
||
+ '<div class="lcd-voice-top">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||
+ '<span class="lcd-voice-label">' + (voiceId ? (!_voiceExists(voiceId) ? '<span class="lcd-voice-missing" title="Stimme wurde aus der Voice Library gelöscht — bitte neu zuweisen"><span class="mdi mdi-alert-circle-outline"></span> ' + escHtml(voiceId) + '</span>' : escHtml(voiceId)) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||
+ '<button class="lcd-gen-voice">Design</button>'
|
||
+ '<button class="lcd-clone-voice">Klonen</button>'
|
||
+ '</div>'
|
||
+ alignHtml
|
||
+ '<div class="lcd-sections">'
|
||
+ _lcdSection('mdi-card-account-details-outline', 'Identität', [
|
||
_lcdFieldEdit('Voller Name', sh.full_name, 'full_name', sourcesByField['full_name']),
|
||
_lcdFieldEdit('Vorname', sh.first_name, 'first_name', sourcesByField['first_name']),
|
||
_lcdFieldEdit('Nachname', sh.last_name, 'last_name', sourcesByField['last_name']),
|
||
_lcdFieldEdit('Geschlecht', sh.gender, 'gender', sourcesByField['gender']),
|
||
_lcdFieldEdit('Titel', sh.title, 'title', sourcesByField['title']),
|
||
_lcdFieldEdit('Beruf / Rolle', sh.profession, 'profession', sourcesByField['profession']),
|
||
_lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases', sourcesByField['aliases']),
|
||
])
|
||
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
|
||
_lcdFieldEdit('Körperlich', sh.physical, 'physical', sourcesByField['physical']),
|
||
_lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing', sourcesByField['clothing']),
|
||
])
|
||
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
|
||
_lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms', sourcesByField['mannerisms']),
|
||
_lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern', sourcesByField['voice_pattern']),
|
||
])
|
||
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
|
||
_lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory', sourcesByField['backstory']),
|
||
_lcdFieldEdit('Motivation', sh.motivation, 'motivation', sourcesByField['motivation']),
|
||
_lcdFieldEdit('Ängste', sh.fears, 'fears', sourcesByField['fears']),
|
||
])
|
||
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
|
||
_lcdFieldEdit('Fertigkeiten', sh.skills, 'skills', sourcesByField['skills']),
|
||
_lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities', sourcesByField['capabilities']),
|
||
_lcdFieldEdit('Stärkstes Attribut', sh.attribute_high, 'attribute_high'),
|
||
_lcdFieldEdit('Schwächstes Attribut', sh.attribute_low, 'attribute_low'),
|
||
])
|
||
+ _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
|
||
_lcdFieldEdit('', sh.relationships, 'relationships', sourcesByField['relationships']),
|
||
relDotsHtml,
|
||
])
|
||
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
|
||
_lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style', sourcesByField['conflict_style']),
|
||
_lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition', sourcesByField['win_condition']),
|
||
])
|
||
+ _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
|
||
_lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'),
|
||
_lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'),
|
||
])
|
||
+ promptsHtml
|
||
+ '</div>'
|
||
+ sourcesHtml
|
||
+ (rec.analysis ? '<div class="lcd-analysis" contenteditable="true" data-rec-key="analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lib-cpg-sidebar">'
|
||
+ '<div class="lib-cpg-sidebar-title">Charaktere · ' + escHtml(rec.book || '') + '</div>'
|
||
+ sidebarHtml
|
||
+ '</div>';
|
||
|
||
container.appendChild(pg);
|
||
|
||
pg.querySelector('.lib-cpg-back').addEventListener('click', function () { goBack(); });
|
||
|
||
const _lcdUploadAvatar = function () {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.accept = 'image/*';
|
||
inp.onchange = async function () {
|
||
const file = inp.files[0]; if (!file) return;
|
||
const fr = new FileReader();
|
||
fr.onload = async function (ev) {
|
||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||
toast('Profilbild gespeichert', 'success');
|
||
rec.image = ev.target.result;
|
||
_syncVoicePictureFromChar(rec);
|
||
const av = pg.querySelector('.lcd-avatar-upload');
|
||
if (av) av.innerHTML = '<img src="' + ev.target.result + '" alt="' + escHtml(rec.name) + '">';
|
||
};
|
||
fr.readAsDataURL(file);
|
||
};
|
||
inp.click();
|
||
};
|
||
pg.querySelector('.lcd-avatar-upload').addEventListener('click', _lcdUploadAvatar);
|
||
pg.querySelector('.lcd-avatar-upload-btn')?.addEventListener('click', function (e) { e.stopPropagation(); _lcdUploadAvatar(); });
|
||
|
||
// Opens an image search in a new tab for the user to browse and save an
|
||
// image themselves (dragging it onto the avatar, or downloading and using
|
||
// the Upload button) — no scraping/auto-fetch, since that'd need to pick a
|
||
// result and copyright/likeness on a random web image is the user's call.
|
||
pg.querySelector('.lcd-avatar-online-btn')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
const q = [rec.name, rec.book, sh.archetype, 'character art'].filter(Boolean).join(' ');
|
||
window.open('https://www.google.com/search?tbm=isch&q=' + encodeURIComponent(q), '_blank', 'noopener');
|
||
});
|
||
|
||
pg.querySelector('.lcd-avatar-gen-btn')?.addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
const btn = this;
|
||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
|
||
const prompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
|
||
if (!prompt) { toast('No image prompt to work from — generate the Character Image Prompt below first', 'error'); return; }
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span>';
|
||
try {
|
||
const r = await fetch('/api/character-generate-image', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ prompt: prompt }),
|
||
});
|
||
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
||
const d = await r.json();
|
||
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.image);
|
||
rec.image = d.image;
|
||
_syncVoicePictureFromChar(rec);
|
||
toast('Profile picture generated', 'success');
|
||
_charDetailPage(rec, allChars, opts);
|
||
} catch (err) {
|
||
toast('Image generation failed: ' + (err.message || err), 'error');
|
||
btn.disabled = false;
|
||
btn.innerHTML = orig;
|
||
}
|
||
});
|
||
|
||
pg.querySelector('.lcd-conceptart-gen')?.addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
const btn = this;
|
||
if (!_libStr(sh.concept_art_prompt).trim()) {
|
||
toast('No Concept Art Prompt yet — generate that first in Generation Prompts below', 'error');
|
||
return;
|
||
}
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span>';
|
||
try {
|
||
await _charAutoGenerateConceptArt(rec);
|
||
toast('Concept art generated', 'success');
|
||
_charDetailPage(rec, allChars, opts);
|
||
} catch (err) {
|
||
toast('Concept art generation failed: ' + (err.message || err), 'error');
|
||
btn.disabled = false;
|
||
btn.innerHTML = orig;
|
||
}
|
||
});
|
||
|
||
pg.querySelector('.lcd-conceptart-img')?.addEventListener('click', function () {
|
||
document.getElementById('conceptart-lightbox')?.remove();
|
||
const ov = document.createElement('div');
|
||
ov.id = 'conceptart-lightbox';
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = '<div class="audiobook-box conceptart-lightbox-box">'
|
||
+ '<div class="audiobook-title"><span class="mdi mdi-image-frame"></span> ' + escHtml(rec.name) + ' — Konzeptbild'
|
||
+ '<span style="flex:1"></span>'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="calb-close">Close</button>'
|
||
+ '</div>'
|
||
+ '<img class="conceptart-lightbox-img" src="' + sh.concept_art_image + '" alt="Concept art — ' + escHtml(rec.name) + '">'
|
||
+ '</div>';
|
||
document.body.appendChild(ov);
|
||
const close = function () { ov.remove(); };
|
||
ov.querySelector('#calb-close').addEventListener('click', close);
|
||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||
});
|
||
|
||
pg.querySelectorAll('.lib-cpg-sidebar-item').forEach(function (item) {
|
||
item.addEventListener('click', async function () {
|
||
const target = (allChars || []).find(function (c) { return c.id === item.dataset.charId; });
|
||
if (target) _charDetailPage(target, allChars, opts);
|
||
});
|
||
});
|
||
|
||
pg.querySelectorAll('.lcd-source-clickable').forEach(function (item) {
|
||
item.addEventListener('click', function () {
|
||
const n = parseInt(item.dataset.page, 10);
|
||
if (!isNaN(n)) _jumpToReaderPage(n);
|
||
});
|
||
});
|
||
|
||
pg.querySelector('.lcd-pick-voice')?.addEventListener('click', async function () {
|
||
_openVoicePicker(pg.querySelector('.lcd-voice-top'), rec, async function () {
|
||
const all = await clGetAll().catch(() => allChars);
|
||
const up = all.find(function (r) { return r.id === rec.id; }) || rec;
|
||
_charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }), opts);
|
||
});
|
||
});
|
||
pg.querySelector('.lcd-auto-voice')?.addEventListener('click', async function () {
|
||
await _autoAssignVoice(rec);
|
||
const all = await clGetAll().catch(() => allChars);
|
||
const up = all.find(function (r) { return r.id === rec.id; }) || rec;
|
||
_charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }), opts);
|
||
});
|
||
pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
|
||
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoiceInline(rec); });
|
||
pg.querySelector('.lcd-clone-voice')?.addEventListener('click', function () { _charCloneVoice(rec); });
|
||
|
||
// Generation Prompts section: copy buttons + one-call generation of all four
|
||
// external-tool prompts (Voice Design / Image / SillyTavern / Concept Art)
|
||
// from the character's full profile.
|
||
pg.querySelectorAll('.lcd-prompt-copy').forEach(function (btn) {
|
||
btn.addEventListener('click', async function () {
|
||
const box = btn.closest('.lcd-prompt-body');
|
||
const text = box?.querySelector('.lcd-prompt-text')?.textContent.trim() || '';
|
||
if (!text) { toast('Nothing to copy yet — click Generate first', 'error'); return; }
|
||
if (typeof copyText === 'function') await copyText(text);
|
||
toast('Prompt copied', 'success');
|
||
});
|
||
});
|
||
// One Generate button per prompt box — the server accepts a `fields` filter
|
||
// so clicking just one no longer burns tokens (re)generating all four.
|
||
pg.querySelectorAll('.lcd-gen-prompt').forEach(function (btn) {
|
||
btn.addEventListener('click', async function (e) {
|
||
e.preventDefault();
|
||
const key = btn.dataset.sheetKey;
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
|
||
try {
|
||
const sh2 = rec.sheet || {};
|
||
const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' ');
|
||
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
|
||
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
|
||
const r = await fetch('/api/character-generate-prompts', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model, fields: [key] }),
|
||
});
|
||
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
||
const d = await r.json();
|
||
if (!d[key]) throw new Error('Empty response — try again');
|
||
if (!rec.sheet) rec.sheet = {};
|
||
rec.sheet[key] = d[key];
|
||
rec.updated = new Date();
|
||
if (typeof clPut === 'function') await clPut(rec);
|
||
toast('Prompt generated', 'success');
|
||
_charDetailPage(rec, allChars, opts); // re-render so the box shows the new content
|
||
} catch (err) {
|
||
toast('Prompt generation failed: ' + (err.message || err), 'error');
|
||
btn.disabled = false;
|
||
btn.innerHTML = orig;
|
||
}
|
||
});
|
||
});
|
||
|
||
const slider = pg.querySelector('.lcd-align-slider');
|
||
const sliderVal = pg.querySelector('.lcd-align-slider-val');
|
||
const arcEl = pg.querySelector('.lcd-align-arc');
|
||
if (slider) {
|
||
slider.addEventListener('input', async function () {
|
||
const val = parseInt(slider.value, 10);
|
||
if (sliderVal) sliderVal.textContent = val + '/100';
|
||
if (arcEl) arcEl.textContent = arcInfo.ch + ' ' + arcInfo.label
|
||
+ (val >= 70 ? ' · Rechtschaffen (' + val + '/100)' : val <= 30 ? ' · Böse (' + val + '/100)' : ' · Moralisch ambivalent (' + val + '/100)');
|
||
arcEl && (arcEl.style.color = arcInfo.color);
|
||
rec.sheet.moral_alignment_score = val;
|
||
rec.updated = new Date();
|
||
if (typeof clPut === 'function') await clPut(rec);
|
||
});
|
||
}
|
||
|
||
// One timer PER FIELD, not one shared timer for the whole page — a single
|
||
// shared timer meant editing field A, then switching to field B within
|
||
// 900ms, cleared A's still-pending save and scheduled only B's; A's edit
|
||
// sat correctly in the DOM but was never written to `rec` or persisted,
|
||
// with no error shown. Any quick multi-field edit could silently lose the
|
||
// first field touched.
|
||
const _saveTimers = new Map();
|
||
function _schedSave(key, value, isRecKey) {
|
||
clearTimeout(_saveTimers.get(key));
|
||
_saveTimers.set(key, setTimeout(async function () {
|
||
if (isRecKey) { rec[key] = value; }
|
||
else { if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = value; }
|
||
rec.updated = new Date();
|
||
if (typeof clPut === 'function') await clPut(rec);
|
||
}, 900));
|
||
}
|
||
pg.querySelectorAll('[contenteditable][data-sheet-key]').forEach(function (el) {
|
||
el.addEventListener('input', function () { _schedSave(el.dataset.sheetKey, el.textContent.trim(), false); });
|
||
});
|
||
pg.querySelectorAll('[contenteditable][data-rec-key]').forEach(function (el) {
|
||
el.addEventListener('input', function () { _schedSave(el.dataset.recKey, el.textContent.trim(), true); });
|
||
});
|
||
}
|
||
|
||
window._charDetailPage = _charDetailPage;
|
||
|
||
function _charDetailModal(rec, allChars) {
|
||
const sh = rec.sheet || {};
|
||
const cov = libBookCover(rec.name);
|
||
const hue = _charHue(rec.name);
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
|
||
const gender = _libStr(sh.gender);
|
||
const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female' : gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||
const score = sh.moral_alignment_score;
|
||
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
|
||
const arc = sh.arc_direction || 'neutral';
|
||
const arcMap = {
|
||
'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
|
||
'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
|
||
'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
|
||
'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
|
||
'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' },
|
||
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
|
||
};
|
||
const arcInfo = arcMap[arc] || arcMap['neutral'];
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
|
||
const avatarHtml = rec.image
|
||
? '<div class="lcd-avatar"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||
: '<div class="lcd-avatar" style="background:hsl(' + hue + ',55%,38%)">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';
|
||
|
||
const alignHtml = pct != null ? (
|
||
'<div class="lcd-align-section">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
|
||
+ '<div class="lcd-align-bar-wrap">'
|
||
+ '<span class="lcd-align-label">Böse</span>'
|
||
+ '<div class="lcd-align-bar"><div class="lcd-align-dot" style="left:' + pct + '%"></div></div>'
|
||
+ '<span class="lcd-align-label">Gut</span>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label + (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)') + '</div>'
|
||
+ (_libStr(sh.arc_note) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.arc_note)) + '</div>' : '')
|
||
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note" style="margin-top:6px">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
// Relationship dots (same logic as card)
|
||
const relText = _libStr(sh.relationships).toLowerCase();
|
||
const relHits = (allChars || [])
|
||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||
.map(function (c) {
|
||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||
return { c: c, n: (relText.match(re) || []).length };
|
||
})
|
||
.filter(function (x) { return x.n > 0; })
|
||
.sort(function (a, b) { return b.n - a.n; })
|
||
.slice(0, 8);
|
||
|
||
const relDotsHtml = relHits.length ? (
|
||
'<div class="lcd-rels-dots">'
|
||
+ relHits.map(function (x) {
|
||
const h = _charHue(x.c.name);
|
||
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||
}).join('')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
const ov = document.createElement('div');
|
||
ov.className = 'lib-char-detail-ov';
|
||
ov.innerHTML = '<div class="lib-char-detail-box">'
|
||
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + ((hue+40)%360) + ',58%,25%)">'
|
||
+ avatarHtml
|
||
+ '<div class="lcd-header-body">'
|
||
+ '<div class="lcd-name">' + escHtml(rec.name) + '</div>'
|
||
+ (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
|
||
+ (_libStr(sh.title) ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.title)) + '</div>' : '')
|
||
+ (_libStr(sh.aliases) ? '<div class="lcd-aliases">auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
|
||
+ (_libStr(sh.archetype) ? '<div class="lcd-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
|
||
+ '<div class="lcd-tier-gender">'
|
||
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
|
||
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-header-btns">'
|
||
+ '<button class="lcd-edit-btn"><span class="mdi mdi-pencil-outline"></span> Bearbeiten</button>'
|
||
+ '<button class="lcd-close-btn"><span class="mdi mdi-close"></span></button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-body">'
|
||
+ '<div class="lcd-voice-top">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||
+ '<span class="lcd-voice-label">' + (voiceId ? (!_voiceExists(voiceId) ? '<span class="lcd-voice-missing" title="Stimme wurde aus der Voice Library gelöscht — bitte neu zuweisen"><span class="mdi mdi-alert-circle-outline"></span> ' + escHtml(voiceId) + '</span>' : escHtml(voiceId)) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||
+ '<button class="lcd-gen-voice">Generieren</button>'
|
||
+ '</div>'
|
||
+ alignHtml
|
||
+ '<div class="lcd-sections">'
|
||
+ _lcdSection('mdi-card-account-details-outline', 'Identität', [
|
||
_lcdField('Voller Name', sh.full_name, true),
|
||
_lcdField('Vorname', sh.first_name, true),
|
||
_lcdField('Nachname', sh.last_name, true),
|
||
_lcdField('Geschlecht', sh.gender, true),
|
||
_lcdField('Titel', sh.title, true),
|
||
_lcdField('Beruf / Rolle', sh.profession, true),
|
||
_lcdField('Auch bekannt als', sh.aliases, true),
|
||
])
|
||
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
|
||
_lcdField('Körperlich', sh.physical, true),
|
||
_lcdField('Kleidung & Aussehen', sh.clothing, true),
|
||
])
|
||
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
|
||
_lcdField('Eigenheiten & Verhalten', sh.mannerisms, true),
|
||
_lcdField('Stimme & Sprache', sh.voice_pattern, true),
|
||
])
|
||
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
|
||
_lcdField('Hintergrund & Herkunft', sh.backstory, true),
|
||
_lcdField('Motivation', sh.motivation, true),
|
||
_lcdField('Ängste', sh.fears, true),
|
||
])
|
||
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
|
||
_lcdField('Fertigkeiten', sh.skills, true),
|
||
_lcdField('Besondere Fähigkeiten', sh.capabilities, true),
|
||
_lcdField('Stärkstes Attribut', sh.attribute_high, false),
|
||
_lcdField('Schwächstes Attribut', sh.attribute_low, false),
|
||
])
|
||
+ _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
|
||
_lcdField('', sh.relationships, true),
|
||
relDotsHtml,
|
||
])
|
||
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
|
||
_lcdField('Konfliktstil', sh.conflict_style, true),
|
||
_lcdField('Siegbedingung', sh.win_condition, true),
|
||
])
|
||
+ _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
|
||
_lcdField('Dunkles Geheimnis / fataler Fehler', sh.secret, true),
|
||
_lcdField('Charakterentwicklung', sh.arc_note, true),
|
||
])
|
||
+ '</div>'
|
||
+ _lcdSourcesHtml(sh.sources)
|
||
+ (rec.analysis ? '<div class="lcd-analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
||
+ '</div>'
|
||
+ '</div>';
|
||
|
||
document.body.appendChild(ov);
|
||
|
||
const close = function () { ov.remove(); };
|
||
ov.querySelector('.lcd-close-btn').addEventListener('click', close);
|
||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||
|
||
ov.querySelector('.lcd-edit-btn').addEventListener('click', function () {
|
||
close();
|
||
if (typeof clEdit === 'function') clEdit(rec.id);
|
||
});
|
||
|
||
// Voice buttons inside detail modal
|
||
ov.querySelector('.lcd-pick-voice').addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
// Anchor to the button actually clicked, not the whole modal box — see
|
||
// the identical fix on the table's own "Auswahl" button for why: the
|
||
// popup's position comes from getBoundingClientRect() on whatever
|
||
// element is passed here, and a big container's box can put that
|
||
// anchor point far from the button that opened it.
|
||
_openVoicePicker(e.currentTarget, rec, function () { close(); libraryRenderCharacters(); });
|
||
});
|
||
ov.querySelector('.lcd-auto-voice').addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
await _autoAssignVoice(rec);
|
||
close();
|
||
libraryRenderCharacters();
|
||
});
|
||
ov.querySelector('.lcd-online-voice').addEventListener('click', function (e) {
|
||
e.stopPropagation(); _charSearchOnline(rec);
|
||
});
|
||
ov.querySelector('.lcd-gen-voice').addEventListener('click', function (e) {
|
||
e.stopPropagation(); _charDesignVoiceInline(rec);
|
||
});
|
||
|
||
// Avatar click to upload image
|
||
ov.querySelector('.lcd-avatar').addEventListener('click', function () {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.accept = 'image/*';
|
||
inp.onchange = async function () {
|
||
const file = inp.files[0]; if (!file) return;
|
||
const fr = new FileReader();
|
||
fr.onload = async function (ev) {
|
||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||
toast('Profile picture saved', 'success');
|
||
close();
|
||
libraryRenderCharacters();
|
||
};
|
||
fr.readAsDataURL(file);
|
||
};
|
||
inp.click();
|
||
});
|
||
}
|
||
|
||
window._charDetailModal = _charDetailModal;
|
||
|
||
// Clicking an avatar anywhere (Library grid/table) opens this instead of
|
||
// jumping straight to a file picker — lets you actually see the current
|
||
// image at a useful size and choose upload / URL / AI-regenerate, with the
|
||
// prompt editable right there instead of only from the full profile page.
|
||
function _openAvatarLightbox(rec, onSaved) {
|
||
document.getElementById('avatar-lightbox')?.remove();
|
||
const sh = rec.sheet || {};
|
||
const currentPrompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, _getBookProfileSync(rec.book)) : '');
|
||
const ov = document.createElement('div');
|
||
ov.id = 'avatar-lightbox';
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = '<div class="audiobook-box avatar-lightbox-box">'
|
||
+ '<div class="audiobook-title"><span class="mdi mdi-image-outline"></span> ' + escHtml(rec.name) + ' — Profilbild'
|
||
+ '<span style="flex:1"></span>'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="alb-close">Close</button>'
|
||
+ '</div>'
|
||
+ '<div class="alb-body">'
|
||
+ '<div class="alb-preview">'
|
||
+ (rec.image
|
||
? '<img id="alb-preview-img" src="' + rec.image + '" alt="' + escHtml(rec.name) + '">'
|
||
: '<div class="alb-preview-empty"><span class="mdi mdi-account-outline"></span></div>')
|
||
+ '</div>'
|
||
+ '<div class="alb-actions">'
|
||
+ '<div class="alb-section">'
|
||
+ '<div class="alb-section-label"><span class="mdi mdi-upload"></span> Von Festplatte hochladen</div>'
|
||
+ '<input type="file" id="alb-file-input" accept="image/*">'
|
||
+ '</div>'
|
||
+ '<div class="alb-section">'
|
||
+ '<div class="alb-section-label"><span class="mdi mdi-link-variant"></span> Bild-URL</div>'
|
||
+ '<div class="alb-url-row">'
|
||
+ '<input type="text" id="alb-url-input" placeholder="https://…">'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="alb-url-btn">Herunterladen</button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="alb-section">'
|
||
+ '<div class="alb-section-label"><span class="mdi mdi-creation"></span> Mit KI (neu) generieren</div>'
|
||
+ '<textarea id="alb-prompt" rows="4" spellcheck="false" placeholder="Character image prompt…">' + escHtml(currentPrompt) + '</textarea>'
|
||
+ '<div class="alb-gen-row">'
|
||
+ '<select id="alb-provider">'
|
||
+ '<option value="">(Active Provider)</option>'
|
||
+ '<option value="openai">OpenAI</option>'
|
||
+ '<option value="google">Google</option>'
|
||
+ '<option value="openrouter">OpenRouter</option>'
|
||
+ '<option value="pollinations">Pollinations.ai (free)</option>'
|
||
+ '<option value="comfyui">Local ComfyUI</option>'
|
||
+ '</select>'
|
||
+ '<button type="button" class="btn-primary btn-sm" id="alb-gen-btn"><span class="mdi mdi-creation"></span> Generieren</button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div id="alb-status" class="llm-active-status"></div>'
|
||
+ '</div>';
|
||
document.body.appendChild(ov);
|
||
ov.addEventListener('click', function (e) { if (e.target === ov) ov.remove(); });
|
||
ov.querySelector('#alb-close').addEventListener('click', function () { ov.remove(); });
|
||
|
||
const setPreview = function (src) {
|
||
ov.querySelector('.alb-preview').innerHTML = '<img id="alb-preview-img" src="' + src + '" alt="' + escHtml(rec.name) + '">';
|
||
};
|
||
const setStatus = function (msg, cls) {
|
||
const el = ov.querySelector('#alb-status');
|
||
el.textContent = msg || '';
|
||
el.className = 'llm-active-status' + (cls ? ' ' + cls : '');
|
||
};
|
||
const commitImage = async function (dataUri) {
|
||
if (typeof clSetImage === 'function') await clSetImage(rec.id, dataUri);
|
||
rec.image = dataUri;
|
||
setPreview(dataUri);
|
||
document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) {
|
||
av.innerHTML = '<img src="' + dataUri + '" alt="' + escHtml(rec.name) + '">';
|
||
});
|
||
toast('Profilbild gespeichert', 'success');
|
||
_syncVoicePictureFromChar(rec);
|
||
if (typeof onSaved === 'function') onSaved();
|
||
};
|
||
|
||
ov.querySelector('#alb-file-input').addEventListener('change', function () {
|
||
const file = this.files[0]; if (!file) return;
|
||
const fr = new FileReader();
|
||
fr.onload = function (ev) { commitImage(ev.target.result); };
|
||
fr.readAsDataURL(file);
|
||
});
|
||
|
||
ov.querySelector('#alb-url-btn').addEventListener('click', async function () {
|
||
const url = ov.querySelector('#alb-url-input').value.trim();
|
||
if (!url) { toast('Bild-URL eingeben', 'error'); return; }
|
||
const btn = this;
|
||
btn.disabled = true;
|
||
setStatus('Wird heruntergeladen…');
|
||
try {
|
||
const r = await fetch('/api/character-image-from-url', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: url }),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail || r.statusText);
|
||
await commitImage(d.image);
|
||
setStatus('✓ Heruntergeladen', 'ok');
|
||
} catch (e) {
|
||
setStatus('Fehlgeschlagen', 'err');
|
||
toast('Download fehlgeschlagen: ' + e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
ov.querySelector('#alb-gen-btn').addEventListener('click', async function () {
|
||
const prompt = ov.querySelector('#alb-prompt').value.trim();
|
||
if (!prompt) { toast('Prompt eingeben', 'error'); return; }
|
||
const provider = ov.querySelector('#alb-provider').value;
|
||
const btn = this;
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generiere…';
|
||
setStatus('Generiere… (kann bei lokalen Modellen etwas dauern)');
|
||
try {
|
||
const body = { prompt: prompt };
|
||
if (provider) body.provider = provider;
|
||
const r = await fetch('/api/character-generate-image', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail || r.statusText);
|
||
await commitImage(d.image);
|
||
// The edited prompt sticks for next time, same as the per-field
|
||
// Generate button on the full profile page.
|
||
if (!rec.sheet) rec.sheet = {};
|
||
if (rec.sheet.image_prompt !== prompt) {
|
||
rec.sheet.image_prompt = prompt;
|
||
if (typeof clUpsert === 'function') await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name }), rec.id);
|
||
}
|
||
setStatus('✓ Generiert', 'ok');
|
||
} catch (e) {
|
||
setStatus('Fehlgeschlagen', 'err');
|
||
toast('Generierung fehlgeschlagen: ' + e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = orig;
|
||
}
|
||
});
|
||
}
|
||
window._openAvatarLightbox = _openAvatarLightbox;
|
||
|
||
// Pushes a character's profile picture onto their currently-assigned
|
||
// library voice's own picture slot (the Voices Library already has full
|
||
// upload/display support for this — /api/voice/picture — it just never had
|
||
// anything feeding it from the character side). Called both when a
|
||
// character's image changes (if they already have a voice) and when a
|
||
// voice gets (re)assigned (if they already have an image), so whichever
|
||
// happens second is the one that actually triggers the sync. Best-effort:
|
||
// never blocks or surfaces its own errors, since this is a convenience
|
||
// mirror, not the primary action the user asked for.
|
||
//
|
||
// ONLY when the voice has no picture of its own. This used to overwrite
|
||
// unconditionally — confirmed live as serious data loss: a shared library
|
||
// voice cloned from a real person (an actual audiobook narrator's own
|
||
// reference photo) got silently overwritten with a fictional character's
|
||
// AI-generated portrait the moment that character was assigned this voice,
|
||
// across the WHOLE voice library (voices are global, not book-scoped), with
|
||
// no backup and no warning. A voice's own real reference photo always wins;
|
||
// this only fills in a picture for a voice that never had one.
|
||
async function _syncVoicePictureFromChar(rec) {
|
||
if (!rec || !rec.image || !rec.voice) return;
|
||
const voiceId = typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice || '');
|
||
if (!voiceId) return;
|
||
try {
|
||
const existing = (window._voices || []).find(function (v) { return v.id === voiceId; });
|
||
if (existing && existing.has_picture) return;
|
||
const blob = await (await fetch(rec.image)).blob();
|
||
const fd = new FormData();
|
||
fd.append('voice_id', voiceId);
|
||
fd.append('file', blob, 'character.jpg');
|
||
await fetch('/api/voice/picture', { method: 'POST', body: fd });
|
||
} catch (e) { console.warn('[voice picture sync]', e); }
|
||
}
|
||
window._syncVoicePictureFromChar = _syncVoicePictureFromChar;
|
||
|
||
function _openVoicePicker(cardEl, rec, onDone) {
|
||
// Remove any existing picker
|
||
document.querySelectorAll('.lib-voice-picker-popup').forEach(function (p) { p.remove(); });
|
||
|
||
// A `let`, not `const` — if the picker opens before the voice library has
|
||
// finished its first load (e.g. reaching this card via Studio's own
|
||
// navigation, which doesn't itself trigger a voice-library fetch), the
|
||
// list below used to snapshot an empty array once and show "No voices
|
||
// found" forever, even after window._voices populated moments later.
|
||
let voices = window._voices || [];
|
||
const gender = String(rec.sheet?.gender || '').toLowerCase();
|
||
const genderMatch = gender.startsWith('f') ? 'f' : gender.startsWith('m') ? 'm' : '';
|
||
|
||
const popup = document.createElement('div');
|
||
popup.className = 'lib-voice-picker-popup';
|
||
popup.innerHTML = '<div class="lib-vp-search"><input type="search" placeholder="Search voices…" class="lib-vp-input" autocomplete="off"></div>'
|
||
+ '<div class="lib-vp-quick"><button type="button" class="lib-vp-design-btn"><span class="mdi mdi-creation"></span> Neue Stimme designen</button></div>'
|
||
+ '<div class="lib-vp-list"></div>';
|
||
popup.querySelector('.lib-vp-design-btn').addEventListener('click', function () {
|
||
popup.remove();
|
||
if (typeof _charDesignVoiceInline === 'function') _charDesignVoiceInline(rec);
|
||
});
|
||
|
||
function renderList(filter) {
|
||
let list = voices.filter(function (v) { return v.enabled !== false; });
|
||
if (filter) {
|
||
const f = filter.toLowerCase();
|
||
list = list.filter(function (v) { return (v.id || '').toLowerCase().includes(f) || (v.name || '').toLowerCase().includes(f); });
|
||
} else if (genderMatch) {
|
||
list = list.filter(function (v) {
|
||
const vg = String(v.gender || '').toLowerCase();
|
||
return vg.startsWith(genderMatch) || !vg;
|
||
}).concat(list.filter(function (v) {
|
||
const vg = String(v.gender || '').toLowerCase();
|
||
return vg && !vg.startsWith(genderMatch);
|
||
}));
|
||
}
|
||
// Was capped at 60 — with a library of ~150+ voices that's most of them
|
||
// never shown at all unless you already know to search by name first,
|
||
// confirmed live as a real "where are the rest of my voices" complaint.
|
||
// The list scrolls fine; 500 is just a sane upper bound against a truly
|
||
// enormous library, not a real-world limit.
|
||
const ul = popup.querySelector('.lib-vp-list');
|
||
ul.innerHTML = list.slice(0, 500).map(function (v) {
|
||
const sel = v.id === rec.voice;
|
||
return '<div class="lib-vp-item' + (sel ? ' selected' : '') + '" data-vid="' + escHtml(v.id) + '">'
|
||
+ escHtml(v.id || v.name || '') + (v.gender ? ' <span style="opacity:.5;font-size:10px">· ' + escHtml(v.gender) + '</span>' : '')
|
||
+ '</div>';
|
||
}).join('') + (list.length === 0 ? '<div style="padding:12px;opacity:.5;font-size:12px">No voices found</div>' : '');
|
||
|
||
ul.querySelectorAll('.lib-vp-item').forEach(function (item) {
|
||
item.addEventListener('click', async function () {
|
||
const vid = item.dataset.vid;
|
||
// Write directly to the exact record the user clicked, via its own
|
||
// id — NOT clUpsert's identity-scan (clSameIdentity), which treats
|
||
// two records as the same character whenever one's `aliases` field
|
||
// lists the other's name and silently redirects the write there
|
||
// instead. That's the right behavior for automated casting passes
|
||
// avoiding duplicate creation, but wrong here: clicking a SPECIFIC
|
||
// card is already an unambiguous choice of which record is meant,
|
||
// even if it's a leftover alias-duplicate of another one (confirmed
|
||
// live: "Kolon Tunneltreiber" — an alias-duplicate of "Kolon" — could
|
||
// never get its own voice any other way).
|
||
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); });
|
||
|
||
if (voices.length === 0 && typeof loadVoiceLibrary === 'function') {
|
||
loadVoiceLibrary().then(function () {
|
||
if (!popup.isConnected) return; // closed before the load finished
|
||
voices = window._voices || [];
|
||
renderList(popup.querySelector('.lib-vp-input').value || '');
|
||
}).catch(function () {});
|
||
}
|
||
|
||
// Appended to <body> as a fixed-position popup, anchored to the trigger
|
||
// via getBoundingClientRect — appending it as a CHILD of cardEl with
|
||
// position:absolute (the old approach) works fine for a card-grid div,
|
||
// but cardEl is a table <tr> in the table view, and a <div> can't legally
|
||
// live inside a <tr>. Browsers silently relocate invalid table content
|
||
// out of the table structure, which rendered the popup floating several
|
||
// rows away from the button that opened it (confirmed live).
|
||
document.body.appendChild(popup);
|
||
const rect = cardEl.getBoundingClientRect();
|
||
const 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;
|
||
if (spaceBelow > 300 || spaceBelow > rect.top) popup.style.top = (rect.bottom + 4) + 'px';
|
||
else popup.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
|
||
|
||
// Close on outside click
|
||
setTimeout(function () {
|
||
function close(e) { if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', close); } }
|
||
document.addEventListener('click', close);
|
||
}, 0);
|
||
|
||
popup.querySelector('.lib-vp-input').focus();
|
||
}
|
||
|
||
// Same character across a multi-book series (e.g. a 3-episode novel) gets a
|
||
// SEPARATE Library record per book — clKey is book::name — so nothing
|
||
// automatically links "Nyrilla" in book 2 back to her already-voiced record
|
||
// in book 1. Recurring characters should sound like the same actor across
|
||
// every episode, not get freshly (re-)cast per book.
|
||
//
|
||
// EXACT NAME MATCH ONLY, deliberately — an earlier version also matched via
|
||
// the `aliases` field (rec's aliases against another record's name, and vice
|
||
// versa), and it silently mismatched live: Lysandra's own sheet lists
|
||
// "Kriegerin" (a descriptive epithet, "the warrior woman") as one of HER
|
||
// aliases, and a completely unrelated, separately-cast placeholder character
|
||
// in book 1 happened to be literally named "Kriegerin" — the alias check
|
||
// treated that coincidence as "same person" and handed Lysandra a stranger's
|
||
// voice. A proper name repeating exactly across books is a strong, safe
|
||
// signal; a descriptive epithet coinciding with someone else's literal name
|
||
// is not — the same class of false positive this session already found and
|
||
// fixed once for clUpsert's identity-merge logic (see clSameIdentity commits).
|
||
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);
|
||
const bookCode = (bookLang && typeof DESIGN_LANG_CODE !== 'undefined') ? DESIGN_LANG_CODE[bookLang] : null;
|
||
const match = all.find(function (r) {
|
||
if (r.id === rec.id || !r.voice) return false;
|
||
if (String(r.name || '').trim().toLowerCase() !== nameKey) return false;
|
||
const vId = typeof r.voice === 'object' ? r.voice.id : r.voice;
|
||
// A voice deleted from the Library (e.g. the user removed it for a bad
|
||
// accent) can still be sitting on some OTHER character record that was
|
||
// never explicitly cleared — reusing it here just re-assigns the exact
|
||
// same now-missing voice, confirmed live as the reported bug: clicking
|
||
// "Auto-design voices" after deleting a batch of voices immediately
|
||
// reassigned those same dead ids instead of generating anything new.
|
||
if (!_voiceExists(vId)) return false;
|
||
// A series is almost always one language throughout, but guard anyway —
|
||
// same reasoning as _findVoiceByCharacterName below: a same-name hit in
|
||
// the wrong language is worse than no match at all.
|
||
if (bookCode && _voiceLangCode(vId) !== bookCode) return false;
|
||
return true;
|
||
});
|
||
return match ? { voiceId: typeof match.voice === 'object' ? match.voice.id : match.voice, book: match.book } : null;
|
||
}
|
||
|
||
// Voice records carry no real per-voice language field anywhere in the
|
||
// backend — the only place language is ever recorded is this LANG_ prefix
|
||
// convention, baked into the id string at design time (_charAutoDesignVoice
|
||
// below). Parsing it back out is the only available signal for "does this
|
||
// voice actually fit the book's language."
|
||
function _voiceLangCode(voiceId) {
|
||
const m = /^([A-Za-z]{2,3})_/.exec(String(voiceId || ''));
|
||
return m ? m[1].toUpperCase() : null;
|
||
}
|
||
|
||
// A voice whose own id/name already contains this character's name (e.g.
|
||
// "DE_M_Zerwas" for a character named "Zerwas") is a deliberately-made,
|
||
// specific match — not a generic gender pick — so it counts as "this
|
||
// character already has a voice" just as much as the series-reuse check
|
||
// above. Longest name match wins in the unlikely case of ambiguity (e.g. a
|
||
// short name that's a substring of another character's voice id).
|
||
//
|
||
// Language-checked against the book: confirmed live that a name match can
|
||
// exist in the WRONG language (an English-designed voice happening to share
|
||
// a German character's name), producing an audible accent mismatch. When
|
||
// the book's language is confidently detectable, only accept a match whose
|
||
// id's language prefix agrees — a same-name-wrong-language hit is treated
|
||
// as no match at all (falls through to designing a fresh voice) rather than
|
||
// silently handing out a mismatched accent.
|
||
async function _findVoiceByCharacterName(rec) {
|
||
const nameKey = String(rec.name || '').trim().toLowerCase();
|
||
if (!nameKey || nameKey.length < 3) return null;
|
||
const voices = (window._voices || []).filter(function (v) { return v.enabled !== false; });
|
||
const hits = voices.filter(function (v) {
|
||
return String(v.id || v.name || '').toLowerCase().includes(nameKey);
|
||
});
|
||
if (!hits.length) return null;
|
||
const bookLang = await _resolveBookLang(rec);
|
||
const 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; });
|
||
if (!langHits.length) return null;
|
||
return langHits.sort(function (a, b) { return String(b.id).length - String(a.id).length; })[0];
|
||
}
|
||
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 + ' → ' + rec.name + ' (reused from "' + reuse.book + '" for series consistency)', 'success');
|
||
return;
|
||
}
|
||
const named = await _findVoiceByCharacterName(rec);
|
||
if (named) {
|
||
// Direct write by id — see _openVoicePicker for why clUpsert's identity-scan
|
||
// (which can silently redirect this onto an alias-duplicate record instead)
|
||
// is wrong for an explicit per-card action like this one.
|
||
await clPut(Object.assign({}, rec, { voice: named.id, updated: new Date() }));
|
||
rec.voice = named.id;
|
||
_syncVoicePictureFromChar(rec);
|
||
toast(named.id + ' → ' + rec.name + ' (matching voice already in the library)', 'success');
|
||
return;
|
||
}
|
||
// No voice already made for this specific character — design one from
|
||
// their own profile instead of handing out an arbitrary same-gender pick
|
||
// that has nothing to do with who they actually are.
|
||
if (typeof _charAutoDesignVoice === 'function') { await _charAutoDesignVoice(rec); return; }
|
||
toast('No matching voice found', 'error');
|
||
}
|
||
|
||
// Detect the production language from a character's own (book-language) text.
|
||
function _charLang(rec) {
|
||
const sh = rec.sheet || {};
|
||
const text = [sh.backstory, sh.voice_pattern, sh.mannerisms, sh.relationships, sh.motivation, sh.archetype]
|
||
.filter(Boolean).join(' ');
|
||
return (typeof detectLang === 'function') ? detectLang(text) : '';
|
||
}
|
||
|
||
// A one-time-per-book note (genre, setting, era, language — "German,
|
||
// fantasy like Lord of the Rings, medieval times") so every LLM prompt this
|
||
// book generates carries real setting context instead of guessing from one
|
||
// character's own sparse sheet. Confirmed live as a recurring problem
|
||
// before this existed: a fantasy book's "generals" got 1920s-general
|
||
// portraits, and sparse/minor characters ("Bote", "Frau", "Mann"...) with
|
||
// no descriptive text of their own got voices designed in English by
|
||
// default even for an all-German book. Cached per book — this is called
|
||
// once per character in a bulk run and the profile never changes mid-run.
|
||
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));
|
||
if (r.ok) profile = await r.json();
|
||
} catch (e) { console.warn('[book profile]', e); }
|
||
_bookProfileCache.set(key, profile);
|
||
return profile;
|
||
}
|
||
// Best-effort sync read for callers that build a display prompt outside an async
|
||
// handler (e.g. a lightbox opened from a plain click listener) — returns {} on a
|
||
// cache miss rather than blocking; the async path above is authoritative.
|
||
function _getBookProfileSync(book) {
|
||
return _bookProfileCache.get(String(book || '').trim()) || {};
|
||
}
|
||
async function _saveBookProfile(book, profile) {
|
||
const key = String(book || '').trim();
|
||
const r = await fetch('/api/book-profile', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(Object.assign({ book: key }, profile)),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
_bookProfileCache.set(key, d.profile || profile);
|
||
return d.profile;
|
||
}
|
||
|
||
// The explicit book profile's language always wins (it's a deliberate user
|
||
// choice); otherwise fall back to detecting from this character's own
|
||
// sheet text, then to a majority vote across its book siblings (who
|
||
// usually do have enough text) instead of a bare hardcoded default.
|
||
const _bookLangCache = new Map();
|
||
async function _resolveBookLang(rec) {
|
||
const book = rec.book || '';
|
||
const 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) : [];
|
||
const counts = {};
|
||
siblings.forEach(function (s) { const l = _charLang(s); if (l) counts[l] = (counts[l] || 0) + 1; });
|
||
let best = '', bestN = 0;
|
||
Object.keys(counts).forEach(function (l) { if (counts[l] > bestN) { best = l; bestN = counts[l]; } });
|
||
lang = best;
|
||
} catch (e) { console.warn('[book lang]', e); }
|
||
_bookLangCache.set(book, lang);
|
||
return lang;
|
||
}
|
||
|
||
// Build a natural-language voice-design prompt from a character sheet.
|
||
// A generic category ("young female voice, energetic tone") describes a
|
||
// whole demographic, not a person — different characters sharing an age/
|
||
// gender ended up sounding like the same person, confirmed live as a real
|
||
// problem for this fallback specifically (only used when the LLM-generated
|
||
// voice_design_prompt isn't available yet, and degenerates hardest for a
|
||
// sparse profile with none of the richer sheet fields filled in). A
|
||
// deterministic per-character hash pick from a small pool of distinctive
|
||
// textures/paces means even the sparsest profile still gets SOMETHING that
|
||
// differs from every other same-gender character, instead of the identical
|
||
// generic sentence for all of them.
|
||
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'];
|
||
const _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 < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||
return pool[h % pool.length];
|
||
}
|
||
function _buildVoicePrompt(rec, profile, langName) {
|
||
const sh = rec.sheet || {};
|
||
const g = String(sh.gender || '').toLowerCase();
|
||
const genderWord = g.startsWith('f') ? 'female' : g.startsWith('m') ? 'male' : '';
|
||
const bits = [];
|
||
// The `language` field passed to /api/voice-design controls what the
|
||
// engine is told to SYNTHESIZE in, but the instruct text itself is what
|
||
// actually steers delivery style — and left unstated, the underlying
|
||
// model's default accent leans American-English regardless of target
|
||
// language, confirmed live as a recurring complaint even on non-English
|
||
// books. Naming the accent explicitly (native for the book's language,
|
||
// or a specifically non-American English variant when the book itself
|
||
// is English) is the one lever available to push back on that default.
|
||
const lang = String(langName || '').trim();
|
||
if (lang && lang.toLowerCase() !== 'english') {
|
||
bits.push('Speak with an authentic native ' + lang + ' accent — not American-accented, not an English speaker doing ' + lang + '.');
|
||
} else if (lang) {
|
||
bits.push('English with a neutral British or international accent, explicitly not American/US-accented.');
|
||
}
|
||
// Without this, per-character prompts had no idea the book was even a
|
||
// fantasy story, let alone which era — confirmed live as "generals"
|
||
// rendered as 1920s generals in a medieval-fantasy book. A one-time book
|
||
// profile (genre/setting/era) grounds every character's prompt in the
|
||
// same world instead of guessing per character.
|
||
const settingBits = [profile && profile.genre, profile && profile.setting, profile && profile.era].filter(Boolean);
|
||
if (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) + '.');
|
||
if (sh.voice_pattern) bits.push(sh.voice_pattern);
|
||
if (sh.mannerisms) bits.push('Mannerisms: ' + sh.mannerisms);
|
||
if (sh.physical) bits.push(sh.physical);
|
||
if (sh.alignment) bits.push('Disposition: ' + sh.alignment);
|
||
return bits.join(' ').slice(0, 600);
|
||
}
|
||
|
||
function _selectLoose(sel, val) {
|
||
if (!sel || !val) return;
|
||
const v = String(val).toLowerCase();
|
||
const 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);
|
||
});
|
||
if (opt) { sel.value = opt.value; sel.dispatchEvent(new Event('change')); }
|
||
}
|
||
|
||
// Search a matching voice online — opens Get a Voice Online on the Fish.audio
|
||
// tab, pre-filled with the character name + detected language.
|
||
function _charSearchOnline(rec) {
|
||
if (typeof navTo === 'function') navTo('s-studio');
|
||
const lang = _charLang(rec);
|
||
setTimeout(function () {
|
||
const fishTab = document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');
|
||
if (fishTab) fishTab.click();
|
||
setTimeout(function () {
|
||
const langSel = document.getElementById('fa-lang');
|
||
if (langSel) _selectLoose(langSel, lang);
|
||
const search = document.getElementById('fa-search');
|
||
if (search) {
|
||
search.value = rec.name;
|
||
search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||
}
|
||
}, 120);
|
||
}, 120);
|
||
toast('Searching online voices for ' + rec.name + (lang ? ' (' + lang + ')' : ''), 'info');
|
||
}
|
||
|
||
// Small modal to set/edit a book's genre/setting/era/language once, instead
|
||
// of every character's prompt guessing its own — see _getBookProfile.
|
||
function _editBookProfile(book) {
|
||
_getBookProfile(book).then(function (profile) {
|
||
const ov = document.createElement('div');
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = '<div class="audiobook-box" style="max-width:440px;">'
|
||
+ '<div class="audiobook-title"><span class="mdi mdi-book-cog-outline"></span> Book context — ' + escHtml(book) + '</div>'
|
||
+ '<p class="card-subtitle" style="margin:8px 0 16px;">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.</p>'
|
||
+ '<div style="display:flex; flex-direction:column; gap:10px; margin-bottom:16px;">'
|
||
+ '<label>Genre<input type="text" id="bctx-genre" placeholder="e.g. High fantasy" value="' + escHtml(profile.genre || '') + '"></label>'
|
||
+ '<label>Setting<input type="text" id="bctx-setting" placeholder="e.g. Like Lord of the Rings" value="' + escHtml(profile.setting || '') + '"></label>'
|
||
+ '<label>Era<input type="text" id="bctx-era" placeholder="e.g. Medieval times" value="' + escHtml(profile.era || '') + '"></label>'
|
||
+ '<label>Language<input type="text" id="bctx-lang" placeholder="e.g. German" value="' + escHtml(profile.language || '') + '"></label>'
|
||
+ '</div>'
|
||
+ '<div style="display:flex; gap:8px; justify-content:flex-end;">'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="bctx-cancel">Cancel</button>'
|
||
+ '<button type="button" class="btn-primary btn-sm" id="bctx-save">Save</button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
document.body.appendChild(ov);
|
||
const close = function () { ov.remove(); };
|
||
ov.querySelector('#bctx-cancel').addEventListener('click', close);
|
||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||
ov.querySelector('#bctx-save').addEventListener('click', async function () {
|
||
const btn = this;
|
||
btn.disabled = true;
|
||
try {
|
||
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();
|
||
} catch (e) { toast('Failed to save: ' + (e.message || e), 'error'); btn.disabled = false; }
|
||
});
|
||
});
|
||
}
|
||
|
||
// Confirmation before silently reusing a series voice, for this DELIBERATE,
|
||
// single-character "design a voice" action — confirmed live as a genuinely
|
||
// confusing experience: clicking "design" expecting a brand-new voice, but
|
||
// getting an unannounced silent reuse instead, with only a toast explaining
|
||
// after the fact. Gives the user an actual choice, with a way to hear the
|
||
// existing voice first. Deliberately NOT used by bulk actions
|
||
// (_autoAssignVoice / the "Auto-design voices" toolbar button / plain
|
||
// _charAutoDesignVoice without force) — a blocking dialog per character
|
||
// would make a 40-character bulk run unusable; those keep the fast, silent
|
||
// reuse-first behavior.
|
||
function _confirmVoiceReuse(rec, reuse) {
|
||
return new Promise(function (resolve) {
|
||
const ov = document.createElement('div');
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = '<div class="audiobook-box" style="max-width:440px;">'
|
||
+ '<div class="audiobook-title"><span class="mdi mdi-account-voice"></span> Existing voice found for ' + escHtml(rec.name) + '</div>'
|
||
+ '<p class="card-subtitle" style="margin:8px 0 16px;">"' + 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?</p>'
|
||
+ '<div style="margin-bottom:16px;">'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="cvr-play"><span class="mdi mdi-play"></span> Play sample</button>'
|
||
+ '</div>'
|
||
+ '<div style="display:flex; gap:8px; justify-content:flex-end;">'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="cvr-cancel">Cancel</button>'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="cvr-use">Use this voice</button>'
|
||
+ '<button type="button" class="btn-primary btn-sm" id="cvr-new">Design a new one</button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
document.body.appendChild(ov);
|
||
let audioEl = null;
|
||
ov.querySelector('#cvr-play').addEventListener('click', async function (e) {
|
||
const btn = e.currentTarget;
|
||
const icon = btn.querySelector('.mdi');
|
||
if (audioEl && !audioEl.paused) { audioEl.pause(); icon.className = 'mdi mdi-play'; return; }
|
||
btn.disabled = true; icon.className = 'mdi mdi-loading mdi-spin';
|
||
try {
|
||
const langHint = (typeof _resolveBookLang === 'function') ? await _resolveBookLang(rec).catch(function () { return ''; }) : '';
|
||
const text = (typeof _charSampleTextFor === 'function' && _charSampleTextFor(rec, langHint)) || ('Hallo, ich bin ' + rec.name + '.');
|
||
// A voice with no reference clip can't play through voice_clone at
|
||
// all; once it has one (even if it started life as a designed
|
||
// voice) it can and should, for the same reproducibility reasons as
|
||
// _ttsBackendForVoice.
|
||
const rv = (window._voices || []).find(x => x.id === reuse.voiceId);
|
||
const rBackend = (rv && !rv.has_ref) ? 'voice_design' : 'voice_clone';
|
||
const blob = await fetchTtsPreviewBlob(reuse.voiceId, text, 'wav', '', rBackend);
|
||
if (!audioEl) { audioEl = new Audio(); audioEl.addEventListener('ended', function () { icon.className = 'mdi mdi-play'; }); }
|
||
audioEl.src = URL.createObjectURL(blob);
|
||
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 = false; }
|
||
});
|
||
const cleanup = function (result) {
|
||
if (audioEl) audioEl.pause();
|
||
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) { if (e.target === ov) cleanup('cancel'); });
|
||
});
|
||
}
|
||
|
||
// Generate a new voice — opens Design a Voice, pre-filled with gender, the
|
||
// detected language, a description built from the sheet, and the character name.
|
||
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 + ' → ' + rec.name + ' (reused from "' + reuse.book + '" for series consistency)', 'success');
|
||
return;
|
||
}
|
||
// choice === 'new' — fall through to the normal Design page flow below,
|
||
// ignoring the reuse candidate entirely.
|
||
}
|
||
if (typeof navTo === 'function') navTo('s-design');
|
||
const sh = rec.sheet || {};
|
||
const lang = _charLang(rec);
|
||
// The character-sheet pass already produces a properly-crafted Voice
|
||
// Design Prompt (shown in its own box on the card) — this used to ignore
|
||
// it completely and build a fresh, cruder one from raw sheet fields every
|
||
// time instead. Prefer the saved one; only fall back to the ad-hoc
|
||
// builder when nothing's been generated yet.
|
||
const 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');
|
||
if (instruct) instruct.value = savedPrompt || _buildVoicePrompt(rec, null, lang);
|
||
const nm = document.getElementById('design-preset-name');
|
||
if (nm) nm.value = rec.name;
|
||
}, 140);
|
||
toast('Voice design prepared for ' + rec.name + (lang ? ' · ' + lang : ''), 'info');
|
||
}
|
||
|
||
// Same "design a voice" entry point as _charDesignVoice, but without leaving
|
||
// the current screen: navigating to the full Design a Voice page (and having
|
||
// no one-click way back) was reported as a real friction point for a
|
||
// workflow that's otherwise "review a character, fix its voice, move on".
|
||
// Shows the same saved/built prompt in an editable textarea right here, with
|
||
// a single Generate button that calls _charAutoDesignVoice with the edited
|
||
// text as an override (skipping the cross-book reuse suggestion, since
|
||
// editing the prompt already signals the user wants a specific new voice).
|
||
function _charDesignVoiceInline(rec) {
|
||
const sh = rec.sheet || {};
|
||
const lang = _charLang(rec);
|
||
const savedPrompt = _libStr(sh.voice_design_prompt).trim();
|
||
const instruct = savedPrompt || _buildVoicePrompt(rec, null, lang);
|
||
const ov = document.createElement('div');
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = '<div class="audiobook-box" style="max-width:560px;">'
|
||
+ '<div class="audiobook-title"><span class="mdi mdi-creation"></span> Voice design prompt — ' + escHtml(rec.name) + '</div>'
|
||
+ '<p class="card-subtitle" style="margin:8px 0 12px;">Edit the description, then generate a new voice from it. This replaces ' + (rec.voice ? 'the current voice' : 'this character’s voice') + '.</p>'
|
||
+ '<textarea id="cdi-instruct" class="input" style="width:100%; min-height:160px; resize:vertical; font-family:inherit;">' + escHtml(instruct) + '</textarea>'
|
||
+ '<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">'
|
||
+ '<button type="button" class="btn-secondary btn-sm" id="cdi-cancel">Cancel</button>'
|
||
+ '<button type="button" class="btn-primary btn-sm" id="cdi-generate"><span class="mdi mdi-creation"></span> Generate voice</button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
document.body.appendChild(ov);
|
||
const close = function () { ov.remove(); };
|
||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||
ov.querySelector('#cdi-cancel').addEventListener('click', close);
|
||
ov.querySelector('#cdi-generate').addEventListener('click', async function (e) {
|
||
const btn = e.currentTarget;
|
||
const text = ov.querySelector('#cdi-instruct').value.trim();
|
||
if (!text) { toast('Prompt is empty', 'error'); return; }
|
||
btn.disabled = true;
|
||
const icon = btn.querySelector('.mdi');
|
||
if (icon) icon.className = 'mdi mdi-loading mdi-spin';
|
||
try {
|
||
await _charAutoDesignVoice(rec, true, text);
|
||
_schedulePendingTtsRestart();
|
||
close();
|
||
toast('New voice designed for ' + rec.name, 'success');
|
||
if (typeof libraryRenderCharacters === 'function') libraryRenderCharacters();
|
||
if (typeof _libRefreshDetailModal === 'function') _libRefreshDetailModal(rec);
|
||
} catch (err) {
|
||
toast('Voice design failed: ' + (err.message || err), 'error');
|
||
btn.disabled = false;
|
||
if (icon) icon.className = 'mdi mdi-creation';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Clone a voice from a real recording — opens Clone a Voice, pre-filled with
|
||
// the character name. Unlike Design/Online, cloning needs an actual audio
|
||
// source (mic take, file, or YouTube URL) only the user can pick, so this
|
||
// just gets them to the right screen with the name ready instead of
|
||
// automating a step that has no reasonable default.
|
||
function _charCloneVoice(rec) {
|
||
if (typeof navTo === 'function') navTo('s-clone');
|
||
setTimeout(function () {
|
||
const nm = document.getElementById('clone-your-name');
|
||
if (nm) nm.value = rec.name;
|
||
}, 140);
|
||
toast('Clone a Voice prepared for ' + rec.name + ' — pick a mic take, file, or YouTube URL', 'info');
|
||
}
|
||
|
||
// A neutral fallback line per language for voice-design generation, used
|
||
// only when the character has no long-enough attributed dialogue line in
|
||
// the current cast to read out instead (which is preferred — an actual line
|
||
// they'd say is a better fit than a generic sentence).
|
||
const _DESIGN_SAMPLE_FALLBACK = {
|
||
German: 'Ich habe lange auf diesen Moment gewartet, und jetzt, da er da ist, weiß 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.',
|
||
};
|
||
// Finds an actual line this character speaks/said — the current audiobook's
|
||
// attributed dialogue first (a real cast line, best fit), then the
|
||
// character sheet's own quoted sources (next best, from outside the
|
||
// audiobook context e.g. bulk-designing from the Library). Shared by both
|
||
// the quick voice-preview button and real voice-design generation, so
|
||
// either way you get an actual line instead of a bare/generic sample.
|
||
function _charRealLine(rec) {
|
||
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
|
||
const nameLower = String(rec.name || '').trim().toLowerCase();
|
||
const segs = (ab && ab.segments) || [];
|
||
const line = segs.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();
|
||
// Prefer a quoted-speech excerpt (looks like something the character
|
||
// actually said) over a plain descriptive excerpt.
|
||
const sources = Array.isArray(rec.sheet && rec.sheet.sources) ? rec.sheet.sources : [];
|
||
const quotes = sources.map(function (s) { return s && s.quote ? String(s.quote).trim() : ''; })
|
||
.filter(function (q) { return q.length >= 20 && q.length <= 240; });
|
||
const spoken = quotes.find(function (q) { return /[""„"]/.test(q); });
|
||
if (spoken) return spoken;
|
||
if (quotes.length) return quotes[0];
|
||
return null;
|
||
}
|
||
// A minor/generic entry with a sparse sheet (no backstory/mannerisms text
|
||
// for _charLang to detect from) used to fall through to the English
|
||
// fallback sentence below even in an all-German book — confirmed live as
|
||
// the reported "voices that start with 'I have waited a long time...'" on
|
||
// exactly the sparsest cast entries. `langNameHint` is the book's own
|
||
// already-resolved language (from _resolveBookLang, which checks the book
|
||
// profile and sibling characters before ever giving up) — pass it whenever
|
||
// the caller already has it so a sparse sheet still reads its fallback line
|
||
// in the book's real language instead of defaulting to English.
|
||
function _charSampleTextFor(rec, langNameHint) {
|
||
const lang = _charLang(rec) || langNameHint || 'English';
|
||
const greeting = lang === 'German' ? `Hallo, ich bin ${rec.name}.` : `Hello, I am ${rec.name}.`;
|
||
const line = _charRealLine(rec);
|
||
if (line) return `${greeting} ${line}`;
|
||
return _DESIGN_SAMPLE_FALLBACK[lang] || _DESIGN_SAMPLE_FALLBACK.English;
|
||
}
|
||
|
||
// Covers only unambiguous, common cross-book nouns used as placeholder
|
||
// speaker labels ("die Frau", "der Mann") — deliberately small and
|
||
// deliberately not trying to guess gender from an arbitrary proper name.
|
||
const _GENERIC_NAME_GENDER = {
|
||
frau: 'female', dame: 'female', junge_frau: 'female', mädchen: '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] || '';
|
||
}
|
||
|
||
// Overwriting an existing voice's audio (a redesign) leaves the running TTS
|
||
// backend possibly serving the old, now-stale reference audio from its own
|
||
// in-process cache until it's restarted — confirmed live via the app's own
|
||
// "changed since backend refresh" warning. A full container restart (the
|
||
// only invalidation path that exists) is too slow to do per-character in a
|
||
// bulk loop, so callers accumulate this flag and flush it once, after the
|
||
// whole action (single redesign or full bulk batch) has finished.
|
||
let _voiceRestartPending = false;
|
||
async function _flushPendingTtsRestart() {
|
||
if (!_voiceRestartPending) return;
|
||
_voiceRestartPending = false;
|
||
try {
|
||
const r = await fetch('/api/tts/restart', { method: 'POST' });
|
||
if (r.ok) toast('TTS backend restarted to pick up the newly designed voice(s)', 'success');
|
||
else console.warn('[tts restart] failed:', r.status);
|
||
} catch (e) { console.warn('[tts restart]', e); }
|
||
}
|
||
|
||
// Debounced counterpart for one-character-at-a-time actions (the redesign
|
||
// sparkle, the edit-prompt popup) — confirmed live: clicking "design" on
|
||
// several characters back-to-back fires one restart per click, and the very
|
||
// NEXT character's design/benchmark call can land while that restart is
|
||
// still bouncing the TTS containers (10-30s), failing outright with "Failed
|
||
// to fetch". Coalescing rapid successive designs into a single restart after
|
||
// the burst settles avoids that window entirely; bulk actions already only
|
||
// flush once at the end of their own loop and don't need this.
|
||
let _voiceRestartDebounceTimer = null;
|
||
function _schedulePendingTtsRestart() {
|
||
clearTimeout(_voiceRestartDebounceTimer);
|
||
_voiceRestartDebounceTimer = setTimeout(_flushPendingTtsRestart, 4000);
|
||
}
|
||
|
||
// Real speech sits roughly in the 80-400 wpm range regardless of language;
|
||
// outside that a benchmark's own audio_sec/text ratio signals corrupted
|
||
// output (near-silent/truncated -> wpm far too high, excess trailing
|
||
// silence/padding -> wpm far too low) even when the backend itself reports
|
||
// ok:true — confirmed live on both ends (6 wpm and 5625 wpm on designed
|
||
// voices in the same book).
|
||
// fetch() rejects with a plain TypeError ("Failed to fetch"/"Load failed")
|
||
// when the connection never completes at all — confirmed live as the actual
|
||
// cause behind "Voice design failed: Failed to fetch": the voice-design and
|
||
// save calls below can land in the ~10-30s window where a PRIOR redesign's
|
||
// TTS container restart (see _flushPendingTtsRestart) is still bouncing the
|
||
// backend. That's transient, not a real failure, so retry a couple of times
|
||
// with a short backoff before giving up — a non-ok HTTP response (a real
|
||
// error with a status/detail) is returned as-is, not retried.
|
||
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 false;
|
||
const words = String(b.text).trim().split(/\s+/).length;
|
||
const wpm = words / (b.audio_sec / 60);
|
||
return wpm < 80 || wpm > 400;
|
||
}
|
||
|
||
// Fully headless voice design: generate + save + assign, no navigation to
|
||
// the Design a Voice screen. Same two backend calls _charDesignVoice's
|
||
// manual flow ends up making (voice-clone.js runVoiceDesign / design-save-btn),
|
||
// just orchestrated directly so this can run in a bulk loop.
|
||
async function _charAutoDesignVoice(rec, force, instructOverride) {
|
||
// Same series-consistency check as _autoAssignVoice/_charDesignVoice —
|
||
// this is the actual generation call behind bulk "Auto-design voices", so
|
||
// without this a whole-book bulk run would burn a fresh voice-design call
|
||
// (and a new, differently-sounding voice) for every recurring character
|
||
// instead of reusing what an earlier episode already established.
|
||
//
|
||
// Skipped when it would just re-apply the voice this character ALREADY
|
||
// has: confirmed live as a real no-op bug — clicking "Auto-design voices"
|
||
// on an already-assigned character (whose own voice is naturally what
|
||
// _findVoiceFromSameCharacterElsewhere finds first, since it's the most
|
||
// recent record with that name) silently re-saved the identical voice id
|
||
// and returned, without ever actually generating anything new. A user
|
||
// clicking design on an already-voiced character clearly wants a
|
||
// DIFFERENT voice, not confirmation of the one they're trying to replace
|
||
// — genuine cross-book/first-assignment reuse (a distinct voice this
|
||
// record doesn't have yet) is still honored below.
|
||
//
|
||
// `force` (the dedicated "design a new voice" button — for when the user
|
||
// just doesn't like the current voice) skips this reuse check entirely,
|
||
// guaranteeing a genuinely fresh generation rather than any kind of match.
|
||
// An explicit edited-prompt override (from the inline "edit prompt &
|
||
// redesign" popup) means the user has already made the design decision —
|
||
// skip the cross-book reuse suggestion entirely and use their text as-is.
|
||
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 || {};
|
||
const langName = (await _resolveBookLang(rec)) || 'English';
|
||
const instruct = instructOverride || _buildVoicePrompt(rec, await _getBookProfile(rec.book), langName);
|
||
if (!instruct.trim()) throw new Error('No character description to design a voice from yet');
|
||
const langCode = (typeof DESIGN_LANG_CODE !== 'undefined' && DESIGN_LANG_CODE[langName]) || 'EN';
|
||
// A generic/minor entry the casting pass extracted from a plain noun in
|
||
// the text ("die Frau", "der Mann", "ein Bote") rather than a real named
|
||
// character never gets a `gender` field filled in at all (confirmed live:
|
||
// empty string, not just an unrecognized value) — but several of these
|
||
// nouns unambiguously ARE gendered, and defaulting them to Neutral was
|
||
// reported as flatly wrong, not just "unspecified", for exactly the cases
|
||
// where the name itself already answers the question.
|
||
const genderWord = String(sh.gender || '').toLowerCase() || _genderFromGenericName(rec.name);
|
||
const genderLetter = genderWord.startsWith('f') ? 'F' : genderWord.startsWith('m') ? 'M' : 'N';
|
||
const sampleText = _charSampleTextFor(rec, langName);
|
||
const dialogue = (typeof isDialogueDesign === 'function') ? isDialogueDesign(instruct, sampleText, null) : false;
|
||
|
||
const baseName = (typeof designSafeName === 'function') ? designSafeName(rec.name) : (typeof _umlautSafe === 'function' ? _umlautSafe(rec.name || 'VoiceDesign') : String(rec.name || 'VoiceDesign')).replace(/[^A-Za-z0-9]+/g, '_');
|
||
const voiceId = (langCode + '_' + genderLetter + '_' + baseName).slice(0, 96);
|
||
|
||
// Some TTS backends occasionally produce corrupted output on a design call
|
||
// (near-silent/truncated audio -> implausible wpm, or a near-hung render ->
|
||
// very low realtime factor) — confirmed live across several designed
|
||
// voices in one book. Benchmark each attempt under a throwaway id (never
|
||
// the character's real voice_id) before committing it: some backends cache
|
||
// reference audio by path and won't see an overwrite until restarted,
|
||
// which would corrupt a benchmark run if we reused the real id on a retry.
|
||
const 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: instruct, sample_text: sampleText, language: langName, gender: genderLetter, dialogue: dialogue }),
|
||
});
|
||
if (!r1.ok) { const e = await r1.json().catch(function () { return {}; }); throw new Error(e.detail || r1.statusText); }
|
||
const designed = await r1.json();
|
||
|
||
const tryId = (voiceId + '__try' + attempt).slice(0, 96);
|
||
const 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 = false;
|
||
if (typeof runVoiceBenchmark === 'function') {
|
||
try {
|
||
const d = await runVoiceBenchmark(tryId, { text: sampleText });
|
||
const hit = (d && d.voices || []).find(function (x) { return x.voice_id === tryId; });
|
||
const b = hit && hit.benchmark;
|
||
// A benchmark error can mean two very different things: the audio
|
||
// really is corrupted (which does often surface as ok:false), or
|
||
// the benchmark backend itself was simply unreachable (confirmed
|
||
// live: "Connection refused" / "Connection reset by peer" against
|
||
// the voice-clone container while it was stuck in a GPU-OOM restart
|
||
// loop — completely unrelated to the design engine or the audio it
|
||
// just produced). Treating the second case as "bad" burns all 3
|
||
// attempts rejecting voices that were never actually checked, and
|
||
// leaves an important character with no voice at all just because
|
||
// an unrelated backend was briefly down. Only reject on a
|
||
// connectivity error if the SAME error repeats on every attempt —
|
||
// a transient one-off is retried instead of trusted.
|
||
const netErr = !b || (!b.ok && /connection (refused|reset|aborted)|max retries exceeded|newconnectionerror|econnrefused|timed? ?out/i.test(String(b.error || '')));
|
||
if (netErr) {
|
||
console.warn('[voice design] benchmark unreachable, accepting unverified:', b && b.error);
|
||
} else {
|
||
// NOT b.realtime_ok — that flag also goes false whenever the
|
||
// designed voice's OWN reference audio (built from this character's
|
||
// sample_text, e.g. a long book quote) simply runs past 25s, a
|
||
// length/performance advisory totally unrelated to whether the
|
||
// audio is actually corrupted. Confirmed live: that made retries
|
||
// deterministically fail 3/3 for any character with a long sample
|
||
// line, since the sample length barely changes between attempts —
|
||
// wasting 3 generations and then refusing to assign ANY voice to a
|
||
// perfectly fine character. Only true defects count as "bad" here.
|
||
bad = !!(b.clipped || _designBenchmarkWpmBad(b));
|
||
}
|
||
} catch (e) { console.warn('[voice benchmark]', e); }
|
||
}
|
||
// Duration/wpm alone can't tell "read the line correctly" from "repeated
|
||
// it twice" or "said something unrelated" — both can pass every check
|
||
// above. Transcribe the actual attempt back with Whisper and compare to
|
||
// what it was supposed to say; a low match rejects this attempt exactly
|
||
// like a wpm/clipping failure, so it gets the same retry treatment.
|
||
if (!bad && typeof _voiceRoundtripCheck === 'function') {
|
||
try {
|
||
const rt = await _voiceRoundtripCheck(tryId, sampleText, 'voice_design');
|
||
if (rt.score < 0.5) {
|
||
bad = true;
|
||
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();
|
||
if (saved.needs_tts_restart) _voiceRestartPending = true;
|
||
await fetch('/api/voice/' + encodeURIComponent(tryId), { method: 'DELETE' }).catch(function () {});
|
||
break;
|
||
}
|
||
await fetch('/api/voice/' + encodeURIComponent(tryId), { method: 'DELETE' }).catch(function () {});
|
||
// Confirmed live: committing the last (still-broken) attempt anyway just
|
||
// produced more confirmed-broken voices with a toast nobody read. Better
|
||
// to fail the character outright, leaving whatever voice it had before
|
||
// (or none) untouched, than to silently swap in a voice already proven
|
||
// corrupted/near-hung by 3 straight failed attempts.
|
||
if (attempt === maxAttempts) {
|
||
throw new Error('Voice design for "' + voiceId + '" produced broken audio after ' + maxAttempts + ' attempts — left the previous voice in place, try again later');
|
||
}
|
||
}
|
||
|
||
if (typeof saveMeta === 'function') {
|
||
await saveMeta(saved.voice_id, {
|
||
gender: genderLetter,
|
||
flag: (typeof LANG_FLAG_DEFAULT !== 'undefined') ? LANG_FLAG_DEFAULT[langCode] : undefined,
|
||
origin: 'designed',
|
||
group: rec.book || undefined,
|
||
tag: rec.book || undefined,
|
||
transcript: sampleText,
|
||
note: 'Voice Design: ' + instruct.slice(0, 240),
|
||
// For a designed voice the instruct IS the voice's identity — the TTS
|
||
// engine reproduces it from this text alone. `note` is a short display
|
||
// summary and is deliberately clipped, so it cannot be the source of
|
||
// truth; store the prompt in full here.
|
||
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);
|
||
return saved.voice_id;
|
||
}
|
||
|
||
// Fully headless image generation: build the prompt, call the configured
|
||
// provider (Settings > Engines > Image Generation), save the result as the
|
||
// character's profile picture. Same backend route as the single "Generate"
|
||
// button on the avatar in _charDetailPage.
|
||
async function _charAutoGenerateImage(rec, provider) {
|
||
const sh = rec.sheet || {};
|
||
const hasExplicitPrompt = !!_libStr(sh.image_prompt).trim();
|
||
if (!hasExplicitPrompt) {
|
||
// Background/crowd characters with only a couple of lines (e.g. "Ruf aus
|
||
// der Menge", "Zwei Gestalten") usually have no physical/clothing/
|
||
// archetype detail at all — the fallback prompt then degenerates to
|
||
// "an original character named X, ambiguous expression", which is
|
||
// close enough to identical across dozens of characters that several
|
||
// providers return near-identical generic results for all of them.
|
||
// Better to skip and leave the neutral initial-letter placeholder than
|
||
// to burn a generation on a prompt with nothing character-specific in it.
|
||
const substance = [sh.archetype, sh.physical, sh.clothing].filter(Boolean).join(' ').trim();
|
||
if (substance.length < 20) {
|
||
throw new Error('Not enough character detail to generate a meaningful portrait — skipped instead of using a generic placeholder');
|
||
}
|
||
}
|
||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
|
||
const 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: prompt };
|
||
if (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 (typeof clSetImage === 'function') await clSetImage(rec.id, d.image);
|
||
rec.image = d.image;
|
||
// Bulk runs only re-render the whole grid once, at the very end — with a
|
||
// ComfyUI workflow that can take a minute or more per character, that
|
||
// left the toolbar's progress counter as the only sign anything was
|
||
// happening. Painting each avatar in place, the moment its own image is
|
||
// ready, makes the run's actual progress visible without waiting.
|
||
document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) {
|
||
av.innerHTML = '<img src="' + d.image + '" alt="' + escHtml(rec.name) + '">';
|
||
});
|
||
_syncVoicePictureFromChar(rec);
|
||
return d.image;
|
||
}
|
||
|
||
// Generate an actual concept-art REFERENCE SHEET image from the character's
|
||
// own concept_art_prompt — this field previously had no image action at
|
||
// all (only "Copy"/"Regenerate" for the text itself), so having a real,
|
||
// detailed prompt sitting in the box never actually produced a picture.
|
||
// Stored inside sheet.concept_art_image (a sheet field, not the top-level
|
||
// `image` column) so it never overwrites the character's main portrait.
|
||
async function _charAutoGenerateConceptArt(rec, provider) {
|
||
const sh = rec.sheet || {};
|
||
const prompt = _libStr(sh.concept_art_prompt).trim();
|
||
if (!prompt) throw new Error('No concept art prompt to work from yet — generate the prompt first');
|
||
const body = { prompt: prompt };
|
||
if (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();
|
||
// Direct write by id, like clSetImage — clUpsert's alias-matching identity
|
||
// scan can silently redirect this onto a different (alias-linked)
|
||
// character's record instead of this one, confirmed live as a real bug
|
||
// elsewhere this session (a voice update landing on the wrong character).
|
||
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null;
|
||
const target = current || rec;
|
||
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.image });
|
||
target.updated = new Date();
|
||
if (typeof clPut === 'function') await clPut(target);
|
||
sh.concept_art_image = d.image;
|
||
rec.sheet = sh;
|
||
return d.image;
|
||
}
|
||
|
||
window._charSearchOnline = _charSearchOnline;
|
||
window._charDesignVoice = _charDesignVoice;
|
||
window._charAutoDesignVoice = _charAutoDesignVoice;
|
||
window._charAutoGenerateImage = _charAutoGenerateImage;
|
||
window._charAutoGenerateConceptArt = _charAutoGenerateConceptArt;
|
||
window.libraryRenderCharacters = libraryRenderCharacters;
|