Compare commits

...

3 Commits

Author SHA1 Message Date
b90c9ce51a chore(release): v1.59.0
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 00:42:01 +02:00
a8177711f4 feat(characters): global cast view in the sidebar and per-book characters inside Studio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 22:29:42 +02:00
e1aca0ba34 feat(magazine): exclude entries from the untitled-page cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 22:06:28 +02:00
13 changed files with 388 additions and 47 deletions

View File

@ -9,6 +9,12 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.59.0] — 2026-10-04
### Changed
- **Characters: global cast view in the sidebar, per-book characters inside Studio.** *Library > Characters / Cast* is now titled "All characters (all books)": one collapsible band per book (heading, per-book count, `aria-expanded`), first book open and the rest closed and not rendered until opened (DOM budget), and a *Search all books* field. Studio > Voices lists only the book picked in *Source*, under a "Book: <title>" chip with **Show characters of all books**; the Characters step shows the same chip; with no book picked Voices shows a prompt (Pick a book / show all books). Per-book actions stay in the band headers. Tests: `tests/characters-scope.spec.js`.
---
## [1.58.0] — 2026-10-03
### Added
- **Magazine UI shows page labels, display titles and merge suggestions.** The article index (list, playlist, driving-mode Index sheet and driving header) shows `display_title` (falls back to `title`) with the `page_label` ("Seite 33") in smaller muted text, plus small kind badges (advert, photo, contents, imprint, continuation) and an "Audio is out of date: re-render" badge when `audio_stale` is set. Review shows a suggestion box: the proposed merge target with confidence and a collapsible plain-language "Why do we think so?" list, **Merge into previous article** (`POST .../merge-into-previous {into}`; a 409 "proposal changed" refreshes the suggestion; undoable) and **Use suggested title** (`POST .../accept-title`; undoable). Controls are labelled, keyboard operable and 44 px high. Tests: `tests/magazine.spec.js` ("untitled entries", driving index).
@ -4702,7 +4708,8 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.59.0...HEAD
[1.59.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...v1.59.0
[1.58.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.57.0...v1.58.0
[1.57.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.3...v1.57.0
[1.56.3]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.2...v1.56.3

View File

@ -199,6 +199,15 @@ locally against your own database; nothing is sent anywhere.
---
### Characters: all books or one book
**Purpose:** two views of the same character library, so you always know which book you are working in.
- **All characters (all books)** (sidebar *Library > Characters / Cast*, or the Library *Cast* tab): every character of every book, one band per book with a heading and a count. The first book is open, the others are closed and only drawn when opened. *Search all books* (name, alias, archetype, book) opens every band with matches and shows "2 of 9". Cards/Table, Sort and every per-book action stay in each band header.
- **Studio > Voices** (step 3): after you pick a book in *Source*, only that book's characters are shown, under a **Book: <title>** chip. **Show characters of all books** leads to the global view. The Characters step (2) shows the same chip. With no book picked, Voices offers **Pick a book** and the link to the global view instead of listing every book.
**Errors:** if the book title cannot be read, the prompt to pick a book is shown. **Mobile/accessibility:** bands are headings with a show/hide button (`aria-expanded`); the search field is labelled; layouts fit 320 px. **Offline/privacy:** nothing new is sent; the view reads the local library.
## Magazines
### Purpose

View File

@ -6,10 +6,11 @@ Update in the same commit as the change it describes.
## Current phase
Magazine projects, **P0 and P1 complete** (frontend reachable at Studio → Magazines). Audiobook/Rehearser/voice-library
features are mature and in daily use. Latest release v1.58.0: driving-mode menu with switches and theme selector, auto-advance, text fit and text
amount, article index with page labels/display titles, continuation and untitled-page detection with title and merge
suggestions, distinct face profiles and English prompt translation for characters, vision-response cache, character
record hygiene fixes and the review Approve fix. v1.57.0 added driving mode with sentence/word-level sync via `tts-aligner`.
features are mature and in daily use. Latest release v1.59.0: global "All characters" view in the sidebar and per-book
characters inside Studio, untitled-page cleanup with an exclude option, duplicate/clean tools. v1.58.0 added the
driving-mode menu, auto-advance, article index with page labels, merge suggestions, distinct face profiles, English
prompt translation, vision-response cache and the review Approve fix. v1.57.0 added driving mode with sentence/word-level
sync via `tts-aligner`.
## Deployment
@ -33,7 +34,7 @@ Two self-hosted Docker instances, both port 7890:
- **`tts-aligner`** (forced-aligner sidecar, `aligner/`) exists only in the DGX `docker-compose.yml`. The NUC compose
file deliberately omits it: with `ALIGNER_URL` unset the app quietly falls back to estimated word timing, so the NUC
works unchanged. Known limitation: no measured word times on the NUC.
- **Rollback** (either host): check out the previous tag (`git checkout v1.57.0`; on the NUC re-rsync that tree), then
- **Rollback** (either host): check out the previous tag (`git checkout v1.58.0`; on the NUC re-rsync that tree), then
`docker compose up -d --force-recreate` (rebuild only if `Dockerfile`/`requirements.txt` changed). Stop the aligner
with `docker compose stop tts-aligner`; `audio/<id>.timings.json` version 2 files stay readable by v1 readers.
@ -97,8 +98,10 @@ which is the writable volume.
**unresolved** — the compose files currently point at ghcr.
## Known limitations
- Live untitled-page cleanup is not yet applied: `scripts/magazine_untitled.py` needs the owner's `--apply`.
- `scripts/clean_character_records.py` has only been run as a dry run; 33 duplicate character pairs need owner decisions.
- Live data changes made on the DGX only (not on the NUC, which has its own DB/config): book KONTROLLE removed, 5 duplicate
characters resolved, untitled-page cleanup applied (backups under `config/backups/`).
- 18 re-rendered/stale audio articles are pending the owner's voice choice.
- `scripts/clean_character_records.py` was applied for 5 pairs; the 17 remaining duplicate-sheet pairs need owner decisions.
- Audiobook attribution (span mode) is measured, not perfect: on three hand-corrected novels about
90-92 % of all words end up with the right voice and ~75-80 % of spoken words with the right
speaker. The references themselves contain label noise (1-2 points is not meaningful). Open, real

View File

@ -1 +1 @@
1.58.0
1.59.0

View File

@ -552,9 +552,13 @@ def merge_entries(layout: dict[str, Any], articles: list[dict[str, Any]], source
def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_confidence: float = HIGH_AT,
cache: dict[str, Any] | None = None,
llm_check: Callable[[str, str], bool | None] | None = None,
titler: Callable[[str, str], str | None] | None = None) -> dict[str, Any]:
titler: Callable[[str, str], str | None] | None = None,
exclude: Sequence[str] = ()) -> dict[str, Any]:
"""Simulate merges (page order, chains resolve) and titles on a copy. Returns
{articles, merges, titles, before}. Nothing is written."""
{articles, merges, titles, before}. Nothing is written. Entries in ``exclude`` are
left as they are: never merged away into another entry and never retitled (they may still
receive merge-ins from the pages after them)."""
skip = {str(x) for x in exclude}
work = json.loads(json.dumps(list(articles)))
language = doc_language(layout)
before = report(layout, articles, cache)
@ -564,7 +568,7 @@ def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_conf
order = [r["id"] for r in report(layout, work, cache)["entries"]]
for aid in order:
a = next((x for x in work if x["id"] == aid), None)
if a is None:
if a is None or aid in skip:
continue
s = suggestion_for(_Index(layout, work), a, llm_check)
if not s or s["confidence"] < min_confidence:
@ -581,7 +585,7 @@ def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_conf
ann = annotate(layout, work, cache, language)
for a in work:
info = ann[a["id"]]
if info.get("title_source") != "suggested":
if a["id"] in skip or info.get("title_source") != "suggested":
continue
title, source = info["suggested_title"], info["suggested_title_source"]
if titler is not None and source == "sentence":

View File

@ -7,7 +7,7 @@ suggestion at or above ``--min-confidence`` (page order, so chains of continuati
into their article), marks merged articles ``audio_stale`` (audio files are kept), and writes
suggested titles into placeholder titles only. Titles someone set are never touched.
python3 scripts/magazine_untitled.py <doc-id | path/to/doc_dir> [--apply] [--no-llm] [--json out.json]
python3 scripts/magazine_untitled.py <doc-id | path/to/doc_dir> [--apply] [--no-llm] [--exclude ID,ID] [--json out.json]
See architecture/magazine-ingestion.md, "Untitled entries".
"""
@ -76,7 +76,7 @@ def make_chat(timeout: float = 30.0):
return chat
def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None) -> dict:
def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None, exclude=()) -> dict:
layout = mag.load_layout(doc_dir)
articles = mag.load_articles(doc_dir)
if not layout or not articles:
@ -92,7 +92,7 @@ def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None) -> dict
return mu.llm_title(chat, text, language)
result = mu.plan(layout, articles, min_confidence=min_confidence, cache=cache,
llm_check=llm_check, titler=titler if chat else None)
llm_check=llm_check, titler=titler if chat else None, exclude=exclude)
index = mu._Index(layout, result["articles"])
for t in result["titles"]:
if t["source"] == "llm":
@ -151,12 +151,15 @@ def main(argv: list[str] | None = None) -> int:
mode.add_argument("--apply", action="store_true", help="write changes (backup first)")
p.add_argument("--min-confidence", type=float, default=mu.HIGH_AT,
help=f"merge threshold (default {mu.HIGH_AT})")
p.add_argument("--exclude", action="append", default=[], metavar="ID[,ID...]",
help="leave these article ids as they are (not merged into another entry, no new title); repeatable")
p.add_argument("--no-llm", action="store_true", help="heuristics and deterministic titles only")
p.add_argument("--json", help="also write the full result as JSON to this path")
args = p.parse_args(argv)
doc_dir = resolve_doc_dir(args.doc)
chat = None if args.no_llm else make_chat()
summary = run(doc_dir, apply=args.apply, min_confidence=args.min_confidence, chat=chat)
summary = run(doc_dir, apply=args.apply, min_confidence=args.min_confidence, chat=chat,
exclude=[i.strip() for v in args.exclude for i in v.split(",") if i.strip()])
print_summary(summary)
if args.json:
Path(args.json).write_text(json.dumps(summary, ensure_ascii=False, indent=1), "utf-8")

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.58.0">
<meta name="app-version" content="1.59.0">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.58.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.58.0">
<link rel="stylesheet" href="/static/style.css?v=1.59.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.59.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.59.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.59.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.59.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.59.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.58.0"></script>
<script src="/static/loader.js?v=1.59.0"></script>
</body>
</html>

View File

@ -42,15 +42,37 @@ async function _libReviewVisualPairs(button, container) {
}
}
async function libraryRenderCharacters() {
let _libCharsCache = null;
// Studio hands this view a single book (Voices step); the sidebar/Library entry
// shows every book. The view is "scoped" exactly while #lib-chars-list is
// borrowed into Studio's Voices slot, so leaving Studio always restores the
// global view without any flag to reset.
function _libShowAllBooks() {
if (typeof window.studioShowAllCharacters === 'function') window.studioShowAllCharacters();
else if (typeof window.navLibraryView === 'function') window.navLibraryView('characters');
}
function _libCharHaystack(rec) {
const sh = rec.sheet || {};
const parts = [rec.name, rec.form_name, rec.book, sh.archetype, sh.species, sh.real_name, sh.profession];
['aliases', 'alias', 'also_known_as'].forEach(function (k) {
const v = sh[k];
if (Array.isArray(v)) parts.push(v.join(' ')); else if (v) parts.push(v);
});
return parts.filter(function (x) { return typeof x === 'string' || typeof x === 'number'; }).join(' ').toLowerCase();
}
async function libraryRenderCharacters(opts) {
const container = document.getElementById('lib-chars-list');
if (!container) return;
const useCache = !!(opts && opts.cached === true && _libCharsCache);
const renderSeq = ++_libCharsRenderSeq;
// This page can open before the shared voice library has finished loading.
// Ratings live in window._voices, so rendering during that gap makes every
// assigned voice look unrated even when its .meta.json still has a rating.
if ((!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
if (!useCache && (!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); }
if (renderSeq !== _libCharsRenderSeq) return;
}
@ -62,13 +84,15 @@ async function libraryRenderCharacters() {
// 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() : []; }
if (useCache) all = _libCharsCache;
else 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;
}
if (renderSeq !== _libCharsRenderSeq) return;
_libCharsCache = all;
// Every action on this page (remove voice, auto-design, delete...) re-runs
// this full rebuild via `refresh()`. Collapsing the container down to a
@ -84,7 +108,24 @@ async function libraryRenderCharacters() {
const byId = new Map(all.map(function (rec) { return [rec.id, rec]; }));
if (!all.length) {
const inStudio = !!container.closest('#stu-voices-slot');
const scopeBook = inStudio ? String(window._libCharsStudioBook || '') : '';
if (inStudio && !scopeBook) {
container.innerHTML = '<div class="lib-chars-scope-prompt card" role="status">'
+ '<h3><span class="mdi mdi-book-open-page-variant-outline"></span> Pick a book first</h3>'
+ '<p>Voices are assigned per book. Choose one of your uploaded books in the Source step, then come back here to see only its characters.</p>'
+ '<div class="lib-chars-scope-actions">'
+ '<button type="button" class="btn-primary btn-sm" id="lib-chars-pick-book"><span class="mdi mdi-file-document-outline"></span> Pick a book</button>'
+ '<button type="button" class="btn-secondary btn-sm" id="lib-chars-show-all"><span class="mdi mdi-bookshelf"></span> Show characters of all books</button>'
+ '</div></div>';
container.querySelector('#lib-chars-pick-book').addEventListener('click', function () {
if (typeof window.showStudioPhase === 'function') window.showStudioPhase(1);
});
container.querySelector('#lib-chars-show-all').addEventListener('click', _libShowAllBooks);
return;
}
if (!all.length && !inStudio) {
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>'
@ -108,6 +149,13 @@ async function libraryRenderCharacters() {
byBook[bk].push(rec);
});
if (inStudio) {
// Studio is working inside ONE book: drop every other production.
const only = byBook[scopeBook] || [];
Object.keys(byBook).forEach(function (k) { delete byBook[k]; });
byBook[scopeBook] = only;
}
// 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
@ -188,6 +236,32 @@ async function libraryRenderCharacters() {
});
container.appendChild(bar);
const searchQuery = inStudio ? '' : String(window._libCharsQuery || '').trim().toLowerCase();
const bookNames = Object.keys(byBook).sort();
const head = document.createElement('div');
head.className = 'lib-chars-scope';
if (inStudio) {
head.innerHTML = '<span class="lib-chars-book-chip" id="lib-chars-book-chip"><span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> Book: <b>' + escHtml(scopeBook) + '</b></span>'
+ '<button type="button" class="btn-secondary btn-sm" id="lib-chars-show-all"><span class="mdi mdi-bookshelf"></span> Show characters of all books</button>';
head.querySelector('#lib-chars-show-all').addEventListener('click', _libShowAllBooks);
} else {
head.innerHTML = '<h3 class="lib-chars-global-title" id="lib-chars-global-title"><span class="mdi mdi-account-box-multiple-outline" aria-hidden="true"></span> All characters (all books)</h3>'
+ '<span class="lib-chars-global-count" id="lib-chars-global-count">' + all.length + ' characters in ' + bookNames.length + ' book' + (bookNames.length === 1 ? '' : 's') + '</span>'
+ '<label class="lib-chars-search"><span class="lib-chars-search-label">Search all books</span>'
+ '<input type="search" id="lib-chars-search" placeholder="Name, alias, archetype, book…" spellcheck="false" autocomplete="off" value="' + escHtml(String(window._libCharsQuery || '')) + '"></label>';
const searchEl = head.querySelector('#lib-chars-search');
let searchTimer = null;
searchEl.addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
window._libCharsQuery = searchEl.value;
window._libCharsFocusSearch = true;
libraryRenderCharacters({ cached: true });
}, 180);
});
}
container.appendChild(head);
const _charSortCmp = {
tier: function (a, b) {
const tierOrder = { main: 0, supporting: 1, minor: 2 };
@ -219,7 +293,8 @@ async function libraryRenderCharacters() {
const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc';
const productions = document.createDocumentFragment();
Object.keys(byBook).sort().forEach(function (book) {
let shownBands = 0;
Object.keys(byBook).sort().forEach(function (book, bandIndex) {
const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier);
if (sortDir === 'desc') chars.reverse();
// Narrator stays pinned first regardless of sort — same convention as
@ -230,13 +305,18 @@ async function libraryRenderCharacters() {
// Keep large productions responsive: the complete record set remains in
// memory for sorting, relationship lookups and bulk operations, while
// only one 50-row page is put into the DOM at a time.
const matched = searchQuery
? chars.filter(function (r) { return _libCharHaystack(r).indexOf(searchQuery) !== -1; })
: chars;
if (searchQuery && !matched.length) return;
shownBands++;
const pageKey = 'ttsvc_libchars_page::' + book;
// Keep every page comfortably below the DOM budget on large casts. The
// complete record set remains available for sorting and bulk operations.
const pageSize = 25;
const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1);
const visibleCount = Math.min(chars.length, requestedPage * pageSize);
const visibleChars = chars.slice(0, visibleCount);
const visibleCount = Math.min(matched.length, requestedPage * pageSize);
const visibleChars = matched.slice(0, visibleCount);
const cov = libBookCover(book);
const prod = document.createElement('div');
@ -249,12 +329,22 @@ async function libraryRenderCharacters() {
// 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';
// Global view: collapsed unless the user opened it (the first book starts
// open); a search opens every band that has matches. Collapsed bands are
// not rendered at all (lazy) to keep the DOM small; opening one re-renders.
const storedCollapse = localStorage.getItem(collapseKey);
let isCollapsed = storedCollapse === null ? bandIndex > 0 : storedCollapse === '1';
if (window._libCharsScrollToBook) isCollapsed = (book !== window._libCharsScrollToBook);
if (searchQuery) isCollapsed = false;
if (inStudio) isCollapsed = false;
const lazy = isCollapsed;
const bodyId = 'lib-chars-body-' + bandIndex;
if (inStudio) prod.classList.add('lib-chars-prod-scoped');
prod.dataset.lazy = lazy ? '1' : '0';
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>'
+ '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '" aria-controls="' + bodyId + '" aria-label="' + escHtml(book) + ' (' + chars.length + ' characters): show or hide"><span class="mdi mdi-chevron-down" aria-hidden="true"></span></button>'
+ '<h3 class="lib-chars-prod-title"><span class="mdi mdi-bookshelf" aria-hidden="true"></span> <span class="lib-chars-prod-name">' + escHtml(book) + '</span> <span class="lib-chars-prod-count" title="Characters in this book">' + (searchQuery ? matched.length + ' of ' + chars.length : chars.length) + '</span></h3>'
+ '<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>'
@ -280,14 +370,14 @@ async function libraryRenderCharacters() {
+ '<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'
+ '<div class="lib-chars-prod-body" id="' + bodyId + '" role="region" aria-label="' + escHtml(book) + '">'
+ (lazy ? '' : viewMode === 'table'
? _charsTableHtml(visibleChars, sortMode, sortDir)
: '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
+ visibleChars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
+ '</div>')
+ (visibleCount < chars.length
? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + chars.length + ')</button>'
+ (!lazy && visibleCount < matched.length
? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + matched.length + ')</button>'
: '')
+ '</div>';
@ -298,7 +388,17 @@ async function libraryRenderCharacters() {
prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) {
e.stopPropagation();
if (inStudio) return;
if (prod.dataset.lazy === '1') {
// Not rendered yet: remember the choice and rebuild with this band open.
localStorage.setItem(collapseKey, '0');
window._libCharsFocusBand = book;
libraryRenderCharacters({ cached: true });
return;
}
const collapsed = prod.classList.toggle('lib-chars-production-collapsed');
prod.querySelector('.lib-chars-prod-collapse-btn').setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (collapsed) prod.dataset.lazy = '0';
localStorage.setItem(collapseKey, collapsed ? '1' : '0');
});
// The whole head bar toggles too, not just the small chevron button —
@ -307,6 +407,7 @@ async function libraryRenderCharacters() {
// 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;
if (inStudio) return;
prod.querySelector('.lib-chars-prod-collapse-btn').click();
});
@ -594,6 +695,22 @@ async function libraryRenderCharacters() {
productions.appendChild(prod);
});
container.appendChild(productions);
if (searchQuery && !shownBands) {
const none = document.createElement('p');
none.className = 'lib-chars-search-empty';
none.setAttribute('role', 'status');
none.textContent = 'No character matches "' + String(window._libCharsQuery || '').trim() + '" in any book.';
container.appendChild(none);
}
if (window._libCharsFocusSearch) {
window._libCharsFocusSearch = false;
const se = container.querySelector('#lib-chars-search');
if (se) { se.focus(); try { se.setSelectionRange(se.value.length, se.value.length); } catch (_) {} }
} else if (window._libCharsFocusBand) {
const fb = window._libCharsFocusBand; window._libCharsFocusBand = null;
const fp = [...container.querySelectorAll('.lib-chars-production')].find(function (p) { return p.dataset.book === fb; });
fp?.querySelector('.lib-chars-prod-collapse-btn')?.focus();
}
if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(container);
// Scroll to a specific book's production block — set by callers that land

View File

@ -188,6 +188,51 @@ function _stuInstallNavGuardOnce() {
// the function.
function _stuCallSuppressingNav(fn) { return fn(); }
// Title of the book Studio is working on ('' when none is picked).
async function _stuResolveBookTitle() {
let title = null;
if (window._audiobook && window._audiobook.bookId) {
try {
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
} catch (_) {}
}
return title || (window.readerState && window.readerState.title) || '';
}
// Header chip on the Characters step: which book this step works on.
async function _stuUpdateBookChip() {
const row = document.getElementById('stu-book-chip-row');
if (!row) return;
const title = await _stuResolveBookTitle();
row.textContent = '';
const chip = document.createElement('span');
chip.className = 'lib-chars-book-chip';
chip.id = 'stu-book-chip';
const icon = document.createElement('span');
icon.className = 'mdi mdi-book-open-page-variant-outline';
icon.setAttribute('aria-hidden', 'true');
chip.appendChild(icon);
chip.appendChild(document.createTextNode(title ? ' Book: ' : ' No book picked yet '));
if (title) { const b = document.createElement('b'); b.textContent = title; chip.appendChild(b); }
row.appendChild(chip);
const mk = function (id, label, fn) {
const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'btn-secondary btn-sm'; btn.id = id; btn.textContent = label;
btn.addEventListener('click', fn); row.appendChild(btn);
};
if (!title) mk('stu-pick-book-btn', 'Pick a book', function () { showStudioPhase(1); });
mk('stu-show-all-chars-btn', 'Show characters of all books', function () { window.studioShowAllCharacters(); });
}
// Leave the per-book Studio view for the global cast view (Library > Characters / Cast).
// Studio's nav guard drops internal navTo('s-library') calls, so allow this one.
window.studioShowAllCharacters = function () {
_stuAllowNextNav = true;
if (typeof window.navLibraryView === 'function') window.navLibraryView('characters');
else if (typeof window.navTo === 'function') window.navTo('s-library');
};
// Per-phase borrow wiring — called by showStudioPhase whenever a phase
// becomes active. Each case borrows the real subtree that section already
// renders into/wires listeners on; no logic is duplicated here.
@ -200,6 +245,7 @@ function _stuEnterPhase(n) {
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
});
} else if (n === 2) {
_stuUpdateBookChip();
_stuShowCastView(_stuCastView);
} else if (n === 3) {
_stuBorrow('lib-chars-list', 'stu-voices-slot');
@ -214,14 +260,12 @@ function _stuEnterPhase(n) {
// whichever book's cast session is active, so resolve the title from
// that via the server instead of trusting readerState.title first.
(async () => {
let title = null;
if (window._audiobook && window._audiobook.bookId) {
try {
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
} catch (_) {}
}
window._libCharsScrollToBook = title || (window.readerState && readerState.title) || null;
// Studio works inside ONE book: the Voices step lists only that book's
// characters (library-characters.js reads _libCharsStudioBook while the
// list is borrowed into this slot; '' shows the "pick a book" prompt).
const title = await _stuResolveBookTitle();
window._libCharsStudioBook = title || '';
window._libCharsScrollToBook = null;
_stuCallSuppressingNav(function () {
if (typeof window.libraryRender === 'function') window.libraryRender('characters');
});

View File

@ -56,6 +56,7 @@
<!-- ── Phase 2: Characters ─────────────────────────────────────── -->
<div class="stu-phase" id="stu-phase-2" hidden>
<div class="stu-book-chip-row" id="stu-book-chip-row" aria-live="polite"></div>
<div class="stu-inner-tabs" id="stu-cast-inner-tabs">
<button type="button" class="stu-book-context-btn" id="stu-change-book-btn" title="Choose a different uploaded book"><span class="mdi mdi-book-open-page-variant-outline"></span> Change book</button>
<button type="button" class="stu-book-context-btn" id="stu-book-context-btn" title="Edit genre, setting, era and language before identifying characters"><span class="mdi mdi-book-cog-outline"></span> Edit book context</button>

View File

@ -5989,3 +5989,29 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night
.mag-suggest summary:focus-visible, .mag-suggest .mag-suggest-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
@media (max-width: 480px) { .mag-suggest .mag-suggest-btn { width: 100%; justify-content: center; } }
.mag-stale-badge.mag-pill { color: var(--text); background: rgba(217,119,6,.16); border: 1px solid var(--yellow); }
/* ── Global cast view / per-book Studio scope ───────────────────────────────── */
.lib-chars-scope, .stu-book-chip-row { display:flex; flex-wrap:wrap; align-items:center; gap:10px 14px; margin:0 0 14px; min-width:0; }
.stu-book-chip-row:empty { display:none; }
.lib-chars-book-chip {
display:inline-flex; align-items:center; gap:6px; max-width:100%; padding:6px 12px; border-radius:999px;
border:1px solid color-mix(in srgb, var(--accent) 55%, var(--border));
background:color-mix(in srgb, var(--accent) 12%, var(--surface)); color:var(--text); font-size:14px; overflow-wrap:anywhere;
}
.lib-chars-global-title { margin:0; font-size:17px; font-weight:700; color:var(--text); display:flex; align-items:center; gap:8px; }
.lib-chars-global-count { color:var(--subtext); font-size:13px; }
.lib-chars-search { display:flex; flex-direction:column; gap:2px; flex:1 1 220px; min-width:0; max-width:420px; margin-left:auto; }
.lib-chars-search-label { font-size:12px; font-weight:600; color:var(--subtext); }
.lib-chars-search input { width:100%; box-sizing:border-box; min-height:36px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:14px; }
.lib-chars-search input:focus-visible, .lib-chars-prod-collapse-btn:focus-visible { outline:3px solid var(--accent); outline-offset:2px; }
.lib-chars-search-empty { color:var(--subtext); padding:12px 0; }
.lib-chars-prod-title { margin:0; color:inherit; min-width:0; flex:1 1 auto; overflow-wrap:anywhere; }
.lib-chars-prod-name { min-width:0; }
.lib-chars-prod-count { display:inline-block; padding:1px 8px; border-radius:999px; background:rgba(0,0,0,.35); color:#fff; font-size:12px; font-weight:700; }
.lib-chars-prod-scoped .lib-chars-prod-collapse-btn { display:none; }
.lib-chars-prod-scoped .lib-chars-prod-head { cursor:default; }
.lib-chars-scope-prompt { padding:20px; max-width:560px; }
.lib-chars-scope-prompt h3 { margin:0 0 8px; font-size:17px; }
.lib-chars-scope-prompt p { margin:0 0 14px; color:var(--subtext); line-height:1.5; }
.lib-chars-scope-actions { display:flex; flex-wrap:wrap; gap:8px; }
#lib-chars-list { min-width:0; max-width:100%; }

View File

@ -0,0 +1,115 @@
// @ts-check
// Global cast view (sidebar) vs. per-book characters inside Studio.
const { test, expect } = require('@playwright/test');
const mk = (book, n, prefix) => Array.from({ length: n }, (_, i) => ({
id: `${prefix}-${i}`, book, name: `${prefix}Hero${i}`, tags: '', voice: null,
sheet: { gender: i % 2 ? 'F' : 'M', archetype: i === 0 ? 'Rogue' : '' },
}));
const BOOK_A = 'Alpha Saga 01';
const BOOK_B = 'Beta Saga 02';
const BOOK_C = 'Gamma Saga 03';
const CHARACTERS = [...mk(BOOK_A, 3, 'Al'), ...mk(BOOK_B, 4, 'Be'), ...mk(BOOK_C, 2, 'Ga')];
async function setup(page, { resume } = {}) {
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } }));
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.route('**/api/reader/docs/book-b', route => route.fulfill({ json: { id: 'book-b', title: BOOK_B, sentences: [] } }));
await page.addInitScript(r => {
try { localStorage.clear(); if (r) localStorage.setItem('ttsvc_studio_resume', JSON.stringify(r)); } catch (_) { /* ignore */ }
}, resume || null);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
}
async function openGlobal(page) {
await page.evaluate(() => window.navTo('s-library'));
await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await expect(page.locator('#lib-chars-global-title')).toHaveText(/All characters \(all books\)/, { timeout: 15_000 });
}
test('global cast view groups all books in collapsible, lazily rendered bands', async ({ page }) => {
await setup(page);
await openGlobal(page);
const bands = page.locator('#lib-chars-list .lib-chars-production');
await expect(bands).toHaveCount(3);
await expect(page.locator('#lib-chars-global-count')).toContainText('9 characters in 3 books');
await expect(page.locator('#lib-chars-list h3.lib-chars-prod-title')).toHaveCount(3);
// First band open, the others collapsed and not rendered at all.
const first = bands.nth(0);
const second = bands.nth(1);
await expect(first.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true');
await expect(second.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false');
await expect(first.locator('.lib-char-card')).toHaveCount(4); // 3 + synthetic Narrator
await expect(second.locator('.lib-char-card')).toHaveCount(0);
await expect(first.locator('.lib-chars-prod-count')).toHaveText('4');
// Opening a collapsed band renders its characters.
await second.locator('.lib-chars-prod-collapse-btn').click();
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-char-card')).toHaveCount(5);
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true');
// Collapsing again toggles without a rebuild.
await page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn').click();
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
});
test('global cast view searches across all books', async ({ page }) => {
await setup(page);
await openGlobal(page);
const search = page.locator('#lib-chars-search');
await search.fill('GaHero1');
const bands = page.locator('#lib-chars-list .lib-chars-production');
await expect(bands).toHaveCount(1);
await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_C);
await expect(bands.first().locator('.lib-char-card')).toHaveCount(1);
await expect(search).toBeFocused();
await expect(bands.first().locator('.lib-chars-prod-count')).toHaveText('1 of 3');
await search.fill('rogue'); // archetype of the first character of every book
await expect(bands).toHaveCount(3);
await search.fill('nobody-here');
await expect(page.locator('.lib-chars-search-empty')).toBeVisible();
await search.fill('');
await expect(bands).toHaveCount(3);
});
test('Studio Voices shows only the picked book and links to the global view', async ({ page }) => {
await setup(page, { resume: { phase: 3, bookId: 'book-b', bookTitle: BOOK_B } });
await page.evaluate(() => window.navTo('s-caststudio'));
await expect(page.locator('#stu-voices-slot #lib-chars-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 });
const bands = page.locator('#stu-voices-slot .lib-chars-production');
await expect(bands).toHaveCount(1);
await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_B);
await expect(page.locator('#stu-voices-slot')).not.toContainText(BOOK_A);
await expect(page.locator('#stu-voices-slot .lib-char-card')).toHaveCount(5);
// Per-book actions stay in the band header.
await expect(bands.first().locator('.lib-chars-bulk-voice-btn')).toBeVisible();
await expect(bands.first().locator('.lib-chars-select-all-btn')).toBeVisible();
await page.locator('#lib-chars-show-all').click();
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
await expect(page.locator('#lib-chars-global-title')).toBeVisible();
await expect(page.locator('#lib-chars-list .lib-chars-production')).toHaveCount(3);
});
test('Studio Characters step shows the book chip', async ({ page }) => {
await setup(page, { resume: { phase: 2, bookId: 'book-b', bookTitle: BOOK_B } });
await page.evaluate(() => window.navTo('s-caststudio'));
await expect(page.locator('#stu-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 });
await expect(page.locator('#stu-show-all-chars-btn')).toBeVisible();
});
test('Studio Voices without a picked book offers to pick one instead of listing all books', async ({ page }) => {
await setup(page);
await page.evaluate(() => { window.navTo('s-caststudio'); });
await page.waitForFunction(() => typeof window.showStudioPhase === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.showStudioPhase(3));
await expect(page.locator('#stu-voices-slot .lib-chars-scope-prompt')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('#stu-voices-slot .lib-chars-production')).toHaveCount(0);
await expect(page.locator('#stu-voices-slot #lib-chars-show-all')).toBeVisible();
await page.locator('#stu-voices-slot #lib-chars-pick-book').click();
await expect(page.locator('#stu-phase-1')).toBeVisible();
});

View File

@ -316,6 +316,18 @@ class PlanTests(unittest.TestCase):
layout, articles = sample()
self.assertEqual(mu.plan(layout, articles, min_confidence=1.01)["merges"], [])
def test_excluded_entries_are_left_untouched(self):
layout, articles = sample()
base = mu.plan(layout, articles)
self.assertTrue(base["merges"])
src = base["merges"][0]["source"]
result = mu.plan(layout, articles, exclude=[src])
self.assertTrue(all(m["source"] != src for m in result["merges"]))
self.assertNotIn(src, {t["id"] for t in result["titles"]})
kept = next(a for a in result["articles"] if a["id"] == src)
orig = next(a for a in articles if a["id"] == src)
self.assertEqual(kept["title"], orig["title"])
def test_apply_titles_only_touches_placeholders(self):
_, articles = sample()
n = mu.apply_titles(articles, [{"id": "article-001", "after": "X"}, {"id": "article-004", "after": "Anzeige",