tts-voice-creator-clone-and.../static/js/characters-library.js
mARTin-B78 9d80ec27e7 Merge Reader/Casting/Rehearser pipeline into one guided workflow (v1.12.84)
- Add a 6-stage pipeline stepper (Source -> Cast Audiobook -> Cast Characters
  -> Script Rehearser -> Generate MP3s -> Audiobook) with direct, non-destructive
  jumps between stages and a prominent guided-tour look
- Split PDF import into an explicit "load" then "Extract Text" step, with
  in-browser OCR (Tesseract.js, vendored) to recover chapter headlines baked
  into a PDF as images instead of real text
- Fix casting feed silently merging pages after leaving/returning: segments
  now carry their own page number instead of re-guessing it from text
- Fix excessive "Unknown" speaker attribution: restore the attribution LLM's
  output token budget, which had been cut roughly in half and was truncating
  dialogue-dense passages
- Fix Theater Play library cards failing to open (dead pre-migration
  IndexedDB API calls, missing section navigation)
- Fix bulk "Set tag" wiping a voice's existing tags instead of adding to them
- Start merging Casting's feed with Script Rehearser's Stage UI: collapsible
  character sidebar, shared "paper" page styling, inline text editing
- Fix a performance regression from that merge (per-row listeners on every
  redraw) by moving to event delegation
- Various layout/clutter fixes: hide reader chrome until a document is
  loaded, collapse secondary settings by default, fix overlapping toolbar
  icons, fix duplicate "opening" notifications

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 17:45:38 +02:00

487 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Character library ─────────────────────────────────────────────────────────
//
// Persistent, book-scoped character sheets stored in SQLite via /api/characters.
// All function signatures are unchanged from the IndexedDB version so callers
// (audiobook.js, character-sheets.js, etc.) need no edits.
// Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics.
const CL_EDIT_FIELDS = [
['name', 'Name'], ['aliases', 'Aliases / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['full_name', 'Full name'], ['title', 'Title / role'],
['archetype', 'Archetype'],
['physical', 'Physical'], ['clothing', 'Clothing & Appearance'],
['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'],
['skills', 'Trained Skills'], ['capabilities', 'Capabilities'],
['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'],
['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'],
['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'], ['secret', 'Dark Secret / Fatal Flaw'],
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
];
// ── Server API ────────────────────────────────────────────────────────────────
async function clGetAll() {
const r = await fetch('/api/characters');
if (!r.ok) throw new Error('clGetAll failed: ' + r.status);
const d = await r.json();
return d.characters || [];
}
async function clGet(id) {
const r = await fetch('/api/characters/' + encodeURIComponent(id));
if (r.status === 404) return undefined;
if (!r.ok) throw new Error('clGet failed: ' + r.status);
return r.json();
}
async function clPut(rec) {
if (rec?.color) {
rec.sheet = rec.sheet || {};
rec.sheet.color = clNormalizeColor(rec.color, rec.name);
}
const r = await fetch('/api/characters/' + encodeURIComponent(rec.id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rec),
});
if (!r.ok) throw new Error('clPut failed: ' + r.status);
return r.json();
}
async function clDelete(id) {
const r = await fetch('/api/characters/' + encodeURIComponent(id), { method: 'DELETE' });
if (!r.ok) throw new Error('clDelete failed: ' + r.status);
}
function clKey(book, name) {
return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase();
}
function clHslToHex(h, s, l) {
s /= 100; l /= 100;
const k = n => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return '#' + [f(0), f(8), f(4)].map(x => Math.round(255 * x).toString(16).padStart(2, '0')).join('');
}
function clNameHue(name) {
return Math.abs((name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0));
}
function clNormalizeColor(color, name) {
const c = String(color || '').trim();
if (/^#[0-9a-f]{6}$/i.test(c)) return c;
if (/^#[0-9a-f]{3}$/i.test(c)) return '#' + c.slice(1).split('').map(ch => ch + ch).join('');
return clHslToHex(clNameHue(name), 58, 43);
}
const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title'];
const CL_ALIAS_MAX_TOKENS = 12;
const CL_ALIAS_MAX_CHARS = 500;
function clSplitIdentityTokens(v, opts = {}) {
const raw = _clStr(v);
const parts = raw
.split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i)
.map(x => x.trim())
.filter(Boolean)
.filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x));
if (opts.aliases && (raw.length > CL_ALIAS_MAX_CHARS || parts.length > CL_ALIAS_MAX_TOKENS)) return [];
return parts.slice(0, opts.aliases ? CL_ALIAS_MAX_TOKENS : undefined);
}
function clIdentityNames(recOrSheet) {
const s = recOrSheet?.sheet || recOrSheet || {};
const out = new Set();
const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x.toLowerCase()));
add(recOrSheet?.name || s.name);
CL_IDENTITY_FIELDS.filter(k => k !== 'name').forEach(k => add(s[k], { aliases: k === 'aliases' }));
return out;
}
function clMergeAliases(existing, incoming) {
const names = new Map();
const add = (v) => clSplitIdentityTokens(v, { aliases: true }).forEach(x => names.set(x.toLowerCase(), x));
add(existing.aliases);
add(incoming.aliases);
if (incoming.name && incoming.name !== existing.name) add(incoming.name);
return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', ');
}
function clSameIdentity(rec, book, sheet) {
if (String(rec?.book || '').trim().toLowerCase() !== String(book || '').trim().toLowerCase()) return false;
const a = clIdentityNames(rec);
const b = clIdentityNames(sheet);
for (const n of b) if (a.has(n)) return true;
return false;
}
// Coerce a sheet field value to string, matching _csStr in character-sheets.js.
function _clStr(v) {
if (v == null) return '';
if (typeof v === 'string') return v;
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
return JSON.stringify(v);
}
// Sanitize all scalar string fields on a raw sheet object before storing.
function _clSanitize(sheet) {
const out = { ...sheet };
const fields = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
fields.forEach(f => { if (out[f] != null) out[f] = _clStr(out[f]); });
return out;
}
// Merge an incoming sheet into an existing one without blanking solid facts.
function clMergeSheet(existing, incoming) {
const e = { ...existing };
const inc = _clSanitize(incoming);
const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
const oldAliases = e.aliases;
scalars.forEach(f => { if ((inc[f] || '').length > (e[f] || '').length) e[f] = inc[f]; });
e.aliases = clMergeAliases({ ...e, aliases: oldAliases }, incoming);
if (incoming.tier === 'main') e.tier = 'main';
if (incoming.moral_alignment_score != null) {
e.moral_alignment_score = e.moral_alignment_score != null
? Math.round((e.moral_alignment_score + incoming.moral_alignment_score) / 2)
: incoming.moral_alignment_score;
}
if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction;
e.inventory = [...(existing.inventory || [])];
(incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
e.sources = [...(existing.sources || [])];
(incoming.sources || []).forEach(src => {
if (src && src.quote && e.sources.length < 12 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
});
return e;
}
// Union a character's tag strings into one clean comma-separated list.
function clMergeTags(...parts) {
const set = new Set();
parts.forEach(p => String(p || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t)));
return [...set].join(', ');
}
// Upsert one sheet into the library under a book. Returns the stored record.
async function clUpsert(book, sheet) {
const name = (sheet.name || '').trim();
if (!name) return null;
const bk = (book || '').trim() || 'Unsorted';
const all = await clGetAll().catch(() => []);
const aliasPrev = all.find(r => clSameIdentity(r, bk, sheet));
const id = aliasPrev?.id || clKey(bk, name);
const now = new Date();
const prev = aliasPrev || await clGet(id).catch(() => null);
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name };
const canonicalName = prev?.name || name;
merged.name = canonicalName;
const tags = clMergeTags(prev?.tags, sheet.tags, bk);
const color = clNormalizeColor(prev?.color || prev?.sheet?.color || sheet.color, canonicalName);
merged.color = color;
const rec = {
id, book: bk, name: canonicalName, tags,
sheet: merged,
color,
analysis: prev?.analysis || null,
voice: prev?.voice || sheet.voice || null,
image: prev?.image || sheet.image || null,
created: prev?.created || now,
updated: now,
};
await clPut(rec);
return rec;
}
// Save (or clear) a profile image for a character by its record id.
async function clSetImage(id, dataUrl) {
const rec = await clGet(id).catch(() => null);
if (!rec) return;
rec.image = dataUrl || null;
rec.updated = new Date();
await clPut(rec);
return rec;
}
window.clSetImage = clSetImage;
// All characters belonging to a production, by origin book OR by tag membership.
async function clGetAllByTagOrBook(title) {
const key = String(title || '').trim().toLowerCase();
if (!key) return [];
const all = await clGetAll().catch(() => []);
return all.filter(r => {
if (String(r.book || '').trim().toLowerCase() === key) return true;
return String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === key);
});
}
// Bulk upsert a list of sheets for one book.
async function clUpsertMany(book, sheets) {
let n = 0;
for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; }
return n;
}
// ── One-time IndexedDB → server migration ────────────────────────────────────
// Runs once after a successful clGetAll() that returns an empty server. Reads
// the old IndexedDB store (if present) and POSTs all records to /api/characters/migrate.
(async function _clMigrateIfNeeded() {
try {
const serverRecs = await clGetAll();
if (serverRecs.length > 0) return; // already migrated
const idbRecs = await _clIdbGetAll().catch(() => []);
if (!idbRecs.length) return;
const r = await fetch('/api/characters/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(idbRecs),
});
if (r.ok) {
const d = await r.json();
console.log(`[characters-library] migrated ${d.imported} records from IndexedDB → SQLite`);
}
} catch (e) {
console.warn('[characters-library] migration skipped:', e);
}
})();
function _clIdbGetAll() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('character-library', 1);
req.onerror = () => resolve([]);
req.onsuccess = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains('characters')) { db.close(); resolve([]); return; }
const tx = db.transaction('characters', 'readonly');
const all = tx.objectStore('characters').getAll();
all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); };
all.onerror = () => { db.close(); resolve([]); };
};
});
}
// ── Rendering ─────────────────────────────────────────────────────────────────
let _clRecords = [];
async function clRender() {
const grid = document.getElementById('cl-grid');
if (!grid) return;
const filterEl = document.getElementById('cl-book-filter');
const searchEl = document.getElementById('cl-search');
if (filterEl && !filterEl.dataset.bound) { filterEl.dataset.bound = '1'; filterEl.addEventListener('change', clApplyFilter); }
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
const filter = document.getElementById('cl-book-filter');
const prods = clAllProductions();
if (filter) {
const cur = filter.value;
filter.innerHTML = `<option value="">All productions (${prods.length})</option>` +
prods.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).join('');
if (cur && prods.includes(cur)) filter.value = cur;
}
clApplyFilter();
}
function clAllProductions() {
const set = new Set();
_clRecords.forEach(r => {
if (r.book) set.add(r.book);
String(r.tags || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t));
});
return [...set].sort((a, b) => a.localeCompare(b));
}
function clApplyFilter() {
const grid = document.getElementById('cl-grid');
if (!grid) return;
const book = document.getElementById('cl-book-filter')?.value || '';
const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase();
let recs = _clRecords.slice();
if (book) {
const bk = book.toLowerCase();
recs = recs.filter(r =>
(r.book || '').toLowerCase() === bk ||
String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === bk));
}
if (q) recs = recs.filter(r =>
(r.name || '').toLowerCase().includes(q) ||
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
(r.sheet?.first_name || '').toLowerCase().includes(q) ||
(r.sheet?.last_name || '').toLowerCase().includes(q) ||
(r.sheet?.full_name || '').toLowerCase().includes(q) ||
(r.sheet?.title || '').toLowerCase().includes(q) ||
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
(r.tags || '').toLowerCase().includes(q) ||
(r.book || '').toLowerCase().includes(q));
if (!recs.length) {
grid.innerHTML = `<div class="cl-empty">
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
<p>${_clRecords.length ? 'No characters match your filter.' : 'No characters yet.'}</p>
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your library.</p>
</div>`;
return;
}
const byBook = new Map();
for (const r of recs) { if (!byBook.has(r.book)) byBook.set(r.book, []); byBook.get(r.book).push(r); }
const groups = [...byBook.keys()].sort((a, b) => a.localeCompare(b));
grid.innerHTML = groups.map(bk => {
const list = byBook.get(bk).sort((a, b) => {
const t = (a.sheet?.tier === 'main' ? 0 : 1) - (b.sheet?.tier === 'main' ? 0 : 1);
return t || (a.name || '').localeCompare(b.name || '');
});
return `<div class="cl-book-group">
<div class="cl-book-head"><span class="mdi mdi-book-open-page-variant-outline"></span> ${escHtml(bk)}
<span class="cl-book-count">${list.length}</span></div>
<div class="cl-book-cards">${list.map(clCardHtml).join('')}</div>
</div>`;
}).join('');
}
function clCardHtml(rec) {
const tags = String(rec.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const chips = tags.length
? `<div class="cl-card-tags">${tags.map(t => `<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>${escHtml(t)}</span>`).join('')}</div>`
: '';
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
<div class="cl-card-tools">
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
</div>
${csCardHtml(rec.sheet)}
${chips}
</div>`;
}
// ── Edit modal ──────────────────────────────────────────────────────────────
function clEdit(id) {
const rec = _clRecords.find(r => r.id === id);
if (!rec) return;
const s = rec.sheet || {};
const modal = document.createElement('div');
modal.className = 'audiobook-overlay';
const rows = CL_EDIT_FIELDS.map(([k, label]) => {
const multiline = !['name', 'aliases', 'archetype'].includes(k);
const val = escHtml(String(s[k] || ''));
return `<label class="cl-edit-row"><span>${label}</span>${
multiline
? `<textarea data-field="${k}" rows="2">${val}</textarea>`
: `<input type="text" data-field="${k}" value="${val}">`
}</label>`;
}).join('');
modal.innerHTML = `<div class="audiobook-box cl-edit-box">
<div class="cs-titlebar">
<span class="audiobook-title"><span class="mdi mdi-pencil-outline"></span> Edit — ${escHtml(rec.name)}</span>
<span style="flex:1"></span>
<button class="btn-secondary btn-sm" id="cl-edit-cancel">Cancel</button>
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
</div>
<div class="cl-edit-body">
<label class="cl-edit-row"><span>Tags / productions (comma-separated — reuse across books &amp; scripts)</span>
<input type="text" data-rec-field="tags" value="${escHtml(String(rec.tags || rec.book || ''))}" placeholder="e.g. Das Jahr des Greifen, Star-Trek"></label>
<label class="cl-edit-row cl-edit-color"><span>Character color</span>
<input type="color" data-rec-field="color" value="${escHtml(clNormalizeColor(rec.color || s.color, rec.name))}"></label>
<label class="cl-edit-row"><span>Moral alignment (0 evil 100 good)</span>
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
<label class="cl-edit-row"><span>Arc direction</span>
<select data-field="arc_direction">${
['stable-good', 'stable-bad', 'neutral', 'good-to-bad', 'bad-to-good', 'complex']
.map(o => `<option value="${o}"${(s.arc_direction || 'neutral') === o ? ' selected' : ''}>${o}</option>`).join('')
}</select></label>
${rows}
</div>
</div>`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.querySelector('#cl-edit-cancel').addEventListener('click', close);
modal.addEventListener('click', e => { if (e.target === modal) close(); });
modal.querySelector('#cl-edit-save').addEventListener('click', async () => {
const patch = {};
modal.querySelectorAll('[data-field]').forEach(el => { patch[el.dataset.field] = el.value; });
const newName = (patch.name || '').trim() || rec.name;
let mas = parseInt(patch.moral_alignment_score, 10);
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas };
const tagsEl = modal.querySelector('[data-rec-field="tags"]');
const newTags = tagsEl ? clMergeTags(tagsEl.value, rec.book) : (rec.tags || rec.book);
const colorEl = modal.querySelector('[data-rec-field="color"]');
const color = clNormalizeColor(colorEl?.value || rec.color || s.color, newName);
const newId = clKey(rec.book, newName);
newSheet.color = color;
rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.color = color; rec.updated = new Date();
try {
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
await clPut(rec);
toast('Character saved', 'success');
close();
clRender();
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
});
}
async function clDeleteRec(id) {
const rec = _clRecords.find(r => r.id === id);
if (!rec) return;
if (!confirm(`Delete "${rec.name}" from ${rec.book}?`)) return;
try { await clDelete(id); toast('Deleted', 'success'); clRender(); }
catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
}
// ── Wiring ──────────────────────────────────────────────────────────────────
document.addEventListener('click', e => {
const edit = e.target.closest?.('.cl-edit');
if (edit) { e.stopPropagation(); clEdit(edit.dataset.id); return; }
const del = e.target.closest?.('.cl-delete');
if (del) { e.stopPropagation(); clDeleteRec(del.dataset.id); return; }
const deep = e.target.closest?.('.cl-card-wrap .cs-deep-btn');
if (deep) {
e.stopPropagation();
const rec = _clRecords.find(r => r.id === deep.closest('.cl-card-wrap')?.dataset.id);
if (rec && typeof csDeepAnalysis === 'function') csDeepAnalysis(rec.sheet, '');
}
const img = e.target.closest?.('.cl-card-wrap .cs-img-btn');
if (img) {
e.stopPropagation();
const rec = _clRecords.find(r => r.id === img.closest('.cl-card-wrap')?.dataset.id);
if (rec && typeof csBuildImagePrompt === 'function') {
navigator.clipboard?.writeText(csBuildImagePrompt(rec.sheet))
.then(() => toast('Image prompt copied', 'success'), () => toast('Copy failed', 'error'));
}
return;
}
const voice = e.target.closest?.('.cl-card-wrap .cs-voice-btn');
if (voice) {
e.stopPropagation();
const rec = _clRecords.find(r => r.id === voice.closest('.cl-card-wrap')?.dataset.id);
if (rec && typeof csBuildVoicePrompt === 'function') {
navigator.clipboard?.writeText(csBuildVoicePrompt(rec.sheet))
.then(() => toast('Voice prompt copied', 'success'), () => toast('Copy failed', 'error'));
}
return;
}
const src = e.target.closest?.('.cl-card-wrap .cs-source-link, .cl-card-wrap .cs-source-mark');
if (src) {
e.stopPropagation();
const pg = parseInt(src.dataset.page, 10);
if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg);
else if (pg) toast(`Source: page ${pg}`, 'info');
}
});
window.clRender = clRender;
window.clUpsertMany = clUpsertMany;
window.clUpsert = clUpsert;
window.clGetAll = clGetAll;
window.clPut = clPut;
window.clGetAllByTagOrBook = clGetAllByTagOrBook;