Compare commits
No commits in common. "b90c9ce51a651f44abd6d1e2a505d85adde80881" and "a60a5a92cab32d6232dc42acae36d24391224ec5" have entirely different histories.
b90c9ce51a
...
a60a5a92ca
@ -9,12 +9,6 @@ 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
|
## [1.58.0] — 2026-10-03
|
||||||
### Added
|
### 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).
|
- **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).
|
||||||
@ -4708,8 +4702,7 @@ Initial feature-complete release.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.59.0...HEAD
|
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.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.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.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
|
[1.56.3]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.2...v1.56.3
|
||||||
|
|||||||
@ -199,15 +199,6 @@ 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
|
## Magazines
|
||||||
|
|
||||||
### Purpose
|
### Purpose
|
||||||
|
|||||||
@ -6,11 +6,10 @@ Update in the same commit as the change it describes.
|
|||||||
## Current phase
|
## Current phase
|
||||||
|
|
||||||
Magazine projects, **P0 and P1 complete** (frontend reachable at Studio → Magazines). Audiobook/Rehearser/voice-library
|
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.59.0: global "All characters" view in the sidebar and per-book
|
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
|
||||||
characters inside Studio, untitled-page cleanup with an exclude option, duplicate/clean tools. v1.58.0 added the
|
amount, article index with page labels/display titles, continuation and untitled-page detection with title and merge
|
||||||
driving-mode menu, auto-advance, article index with page labels, merge suggestions, distinct face profiles, English
|
suggestions, distinct face profiles and English prompt translation for characters, vision-response cache, character
|
||||||
prompt translation, vision-response cache and the review Approve fix. v1.57.0 added driving mode with sentence/word-level
|
record hygiene fixes and the review Approve fix. v1.57.0 added driving mode with sentence/word-level sync via `tts-aligner`.
|
||||||
sync via `tts-aligner`.
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@ -34,7 +33,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
|
- **`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
|
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.
|
works unchanged. Known limitation: no measured word times on the NUC.
|
||||||
- **Rollback** (either host): check out the previous tag (`git checkout v1.58.0`; on the NUC re-rsync that tree), then
|
- **Rollback** (either host): check out the previous tag (`git checkout v1.57.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
|
`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.
|
with `docker compose stop tts-aligner`; `audio/<id>.timings.json` version 2 files stay readable by v1 readers.
|
||||||
|
|
||||||
@ -98,10 +97,8 @@ which is the writable volume.
|
|||||||
**unresolved** — the compose files currently point at ghcr.
|
**unresolved** — the compose files currently point at ghcr.
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Live data changes made on the DGX only (not on the NUC, which has its own DB/config): book KONTROLLE removed, 5 duplicate
|
- Live untitled-page cleanup is not yet applied: `scripts/magazine_untitled.py` needs the owner's `--apply`.
|
||||||
characters resolved, untitled-page cleanup applied (backups under `config/backups/`).
|
- `scripts/clean_character_records.py` has only been run as a dry run; 33 duplicate character pairs need owner decisions.
|
||||||
- 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
|
- 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
|
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
|
speaker. The references themselves contain label noise (1-2 points is not meaningful). Open, real
|
||||||
|
|||||||
@ -552,13 +552,9 @@ 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,
|
def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_confidence: float = HIGH_AT,
|
||||||
cache: dict[str, Any] | None = None,
|
cache: dict[str, Any] | None = None,
|
||||||
llm_check: Callable[[str, str], bool | None] | None = None,
|
llm_check: Callable[[str, str], bool | None] | None = None,
|
||||||
titler: Callable[[str, str], str | None] | None = None,
|
titler: Callable[[str, str], str | None] | None = None) -> dict[str, Any]:
|
||||||
exclude: Sequence[str] = ()) -> dict[str, Any]:
|
|
||||||
"""Simulate merges (page order, chains resolve) and titles on a copy. Returns
|
"""Simulate merges (page order, chains resolve) and titles on a copy. Returns
|
||||||
{articles, merges, titles, before}. Nothing is written. Entries in ``exclude`` are
|
{articles, merges, titles, before}. Nothing is written."""
|
||||||
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)))
|
work = json.loads(json.dumps(list(articles)))
|
||||||
language = doc_language(layout)
|
language = doc_language(layout)
|
||||||
before = report(layout, articles, cache)
|
before = report(layout, articles, cache)
|
||||||
@ -568,7 +564,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"]]
|
order = [r["id"] for r in report(layout, work, cache)["entries"]]
|
||||||
for aid in order:
|
for aid in order:
|
||||||
a = next((x for x in work if x["id"] == aid), None)
|
a = next((x for x in work if x["id"] == aid), None)
|
||||||
if a is None or aid in skip:
|
if a is None:
|
||||||
continue
|
continue
|
||||||
s = suggestion_for(_Index(layout, work), a, llm_check)
|
s = suggestion_for(_Index(layout, work), a, llm_check)
|
||||||
if not s or s["confidence"] < min_confidence:
|
if not s or s["confidence"] < min_confidence:
|
||||||
@ -585,7 +581,7 @@ def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_conf
|
|||||||
ann = annotate(layout, work, cache, language)
|
ann = annotate(layout, work, cache, language)
|
||||||
for a in work:
|
for a in work:
|
||||||
info = ann[a["id"]]
|
info = ann[a["id"]]
|
||||||
if a["id"] in skip or info.get("title_source") != "suggested":
|
if info.get("title_source") != "suggested":
|
||||||
continue
|
continue
|
||||||
title, source = info["suggested_title"], info["suggested_title_source"]
|
title, source = info["suggested_title"], info["suggested_title_source"]
|
||||||
if titler is not None and source == "sentence":
|
if titler is not None and source == "sentence":
|
||||||
|
|||||||
@ -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
|
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.
|
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] [--exclude ID,ID] [--json out.json]
|
python3 scripts/magazine_untitled.py <doc-id | path/to/doc_dir> [--apply] [--no-llm] [--json out.json]
|
||||||
|
|
||||||
See architecture/magazine-ingestion.md, "Untitled entries".
|
See architecture/magazine-ingestion.md, "Untitled entries".
|
||||||
"""
|
"""
|
||||||
@ -76,7 +76,7 @@ def make_chat(timeout: float = 30.0):
|
|||||||
return chat
|
return chat
|
||||||
|
|
||||||
|
|
||||||
def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None, exclude=()) -> dict:
|
def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None) -> dict:
|
||||||
layout = mag.load_layout(doc_dir)
|
layout = mag.load_layout(doc_dir)
|
||||||
articles = mag.load_articles(doc_dir)
|
articles = mag.load_articles(doc_dir)
|
||||||
if not layout or not articles:
|
if not layout or not articles:
|
||||||
@ -92,7 +92,7 @@ def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None, exclude
|
|||||||
return mu.llm_title(chat, text, language)
|
return mu.llm_title(chat, text, language)
|
||||||
|
|
||||||
result = mu.plan(layout, articles, min_confidence=min_confidence, cache=cache,
|
result = mu.plan(layout, articles, min_confidence=min_confidence, cache=cache,
|
||||||
llm_check=llm_check, titler=titler if chat else None, exclude=exclude)
|
llm_check=llm_check, titler=titler if chat else None)
|
||||||
index = mu._Index(layout, result["articles"])
|
index = mu._Index(layout, result["articles"])
|
||||||
for t in result["titles"]:
|
for t in result["titles"]:
|
||||||
if t["source"] == "llm":
|
if t["source"] == "llm":
|
||||||
@ -151,15 +151,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
mode.add_argument("--apply", action="store_true", help="write changes (backup first)")
|
mode.add_argument("--apply", action="store_true", help="write changes (backup first)")
|
||||||
p.add_argument("--min-confidence", type=float, default=mu.HIGH_AT,
|
p.add_argument("--min-confidence", type=float, default=mu.HIGH_AT,
|
||||||
help=f"merge threshold (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("--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")
|
p.add_argument("--json", help="also write the full result as JSON to this path")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
doc_dir = resolve_doc_dir(args.doc)
|
doc_dir = resolve_doc_dir(args.doc)
|
||||||
chat = None if args.no_llm else make_chat()
|
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)
|
print_summary(summary)
|
||||||
if args.json:
|
if args.json:
|
||||||
Path(args.json).write_text(json.dumps(summary, ensure_ascii=False, indent=1), "utf-8")
|
Path(args.json).write_text(json.dumps(summary, ensure_ascii=False, indent=1), "utf-8")
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.59.0">
|
<meta name="app-version" content="1.58.0">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,12 +27,12 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.59.0">
|
<link rel="stylesheet" href="/static/style.css?v=1.58.0">
|
||||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.59.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.59.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.59.0">
|
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.58.0">
|
||||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.59.0">
|
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.58.0">
|
||||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.59.0">
|
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.58.0">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.59.0"></script>
|
<script src="/static/loader.js?v=1.58.0"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -42,37 +42,15 @@ async function _libReviewVisualPairs(button, container) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _libCharsCache = null;
|
async function libraryRenderCharacters() {
|
||||||
|
|
||||||
// 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');
|
const container = document.getElementById('lib-chars-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const useCache = !!(opts && opts.cached === true && _libCharsCache);
|
|
||||||
const renderSeq = ++_libCharsRenderSeq;
|
const renderSeq = ++_libCharsRenderSeq;
|
||||||
|
|
||||||
// This page can open before the shared voice library has finished loading.
|
// This page can open before the shared voice library has finished loading.
|
||||||
// Ratings live in window._voices, so rendering during that gap makes every
|
// 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.
|
// assigned voice look unrated even when its .meta.json still has a rating.
|
||||||
if (!useCache && (!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
|
if ((!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
|
||||||
try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); }
|
try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); }
|
||||||
if (renderSeq !== _libCharsRenderSeq) return;
|
if (renderSeq !== _libCharsRenderSeq) return;
|
||||||
}
|
}
|
||||||
@ -84,15 +62,13 @@ async function libraryRenderCharacters(opts) {
|
|||||||
// while a bulk voice-design run elsewhere was mid-restart. Leave whatever
|
// 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.
|
// is already on screen alone and let the next real refresh fix it.
|
||||||
let all = [];
|
let all = [];
|
||||||
if (useCache) all = _libCharsCache;
|
try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; }
|
||||||
else try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; }
|
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.warn('[characters] load failed', e);
|
console.warn('[characters] load failed', e);
|
||||||
if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error');
|
if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (renderSeq !== _libCharsRenderSeq) return;
|
if (renderSeq !== _libCharsRenderSeq) return;
|
||||||
_libCharsCache = all;
|
|
||||||
|
|
||||||
// Every action on this page (remove voice, auto-design, delete...) re-runs
|
// Every action on this page (remove voice, auto-design, delete...) re-runs
|
||||||
// this full rebuild via `refresh()`. Collapsing the container down to a
|
// this full rebuild via `refresh()`. Collapsing the container down to a
|
||||||
@ -108,24 +84,7 @@ async function libraryRenderCharacters(opts) {
|
|||||||
|
|
||||||
const byId = new Map(all.map(function (rec) { return [rec.id, rec]; }));
|
const byId = new Map(all.map(function (rec) { return [rec.id, rec]; }));
|
||||||
|
|
||||||
const inStudio = !!container.closest('#stu-voices-slot');
|
if (!all.length) {
|
||||||
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">'
|
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>'
|
+ '<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>'
|
||||||
@ -149,13 +108,6 @@ async function libraryRenderCharacters(opts) {
|
|||||||
byBook[bk].push(rec);
|
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
|
// The Narrator reads every non-dialogue line but was never a real Library
|
||||||
// record (castWriteBack deliberately never creates one, to keep narrators
|
// record (castWriteBack deliberately never creates one, to keep narrators
|
||||||
// out of the cast library) — so there was nowhere in Assign Voices to give
|
// out of the cast library) — so there was nowhere in Assign Voices to give
|
||||||
@ -236,32 +188,6 @@ async function libraryRenderCharacters(opts) {
|
|||||||
});
|
});
|
||||||
container.appendChild(bar);
|
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 = {
|
const _charSortCmp = {
|
||||||
tier: function (a, b) {
|
tier: function (a, b) {
|
||||||
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
||||||
@ -293,8 +219,7 @@ async function libraryRenderCharacters(opts) {
|
|||||||
const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc';
|
const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
const productions = document.createDocumentFragment();
|
const productions = document.createDocumentFragment();
|
||||||
let shownBands = 0;
|
Object.keys(byBook).sort().forEach(function (book) {
|
||||||
Object.keys(byBook).sort().forEach(function (book, bandIndex) {
|
|
||||||
const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier);
|
const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier);
|
||||||
if (sortDir === 'desc') chars.reverse();
|
if (sortDir === 'desc') chars.reverse();
|
||||||
// Narrator stays pinned first regardless of sort — same convention as
|
// Narrator stays pinned first regardless of sort — same convention as
|
||||||
@ -305,18 +230,13 @@ async function libraryRenderCharacters(opts) {
|
|||||||
// Keep large productions responsive: the complete record set remains in
|
// Keep large productions responsive: the complete record set remains in
|
||||||
// memory for sorting, relationship lookups and bulk operations, while
|
// memory for sorting, relationship lookups and bulk operations, while
|
||||||
// only one 50-row page is put into the DOM at a time.
|
// 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;
|
const pageKey = 'ttsvc_libchars_page::' + book;
|
||||||
// Keep every page comfortably below the DOM budget on large casts. The
|
// Keep every page comfortably below the DOM budget on large casts. The
|
||||||
// complete record set remains available for sorting and bulk operations.
|
// complete record set remains available for sorting and bulk operations.
|
||||||
const pageSize = 25;
|
const pageSize = 25;
|
||||||
const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1);
|
const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1);
|
||||||
const visibleCount = Math.min(matched.length, requestedPage * pageSize);
|
const visibleCount = Math.min(chars.length, requestedPage * pageSize);
|
||||||
const visibleChars = matched.slice(0, visibleCount);
|
const visibleChars = chars.slice(0, visibleCount);
|
||||||
|
|
||||||
const cov = libBookCover(book);
|
const cov = libBookCover(book);
|
||||||
const prod = document.createElement('div');
|
const prod = document.createElement('div');
|
||||||
@ -329,22 +249,12 @@ async function libraryRenderCharacters(opts) {
|
|||||||
// production shut, so a 40+ character roster from an unrelated book
|
// production shut, so a 40+ character roster from an unrelated book
|
||||||
// doesn't bury the one actually being worked on.
|
// doesn't bury the one actually being worked on.
|
||||||
const collapseKey = 'ttsvc_libchars_collapsed::' + book;
|
const collapseKey = 'ttsvc_libchars_collapsed::' + book;
|
||||||
// Global view: collapsed unless the user opened it (the first book starts
|
let isCollapsed = localStorage.getItem(collapseKey) === '1';
|
||||||
// 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 (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');
|
if (isCollapsed) prod.classList.add('lib-chars-production-collapsed');
|
||||||
prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">'
|
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" 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>'
|
+ '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production"><span class="mdi mdi-chevron-down"></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-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
|
||||||
+ '<div class="lib-chars-prod-actions">'
|
+ '<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-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-casting-btn" data-book="' + escHtml(book) + '" title="Back to the casting script (speaker attribution)"><span class="mdi mdi-drama-masks"></span> Casting</button>'
|
||||||
@ -370,14 +280,14 @@ async function libraryRenderCharacters(opts) {
|
|||||||
+ '<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-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>'
|
+ '<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></div>'
|
||||||
+ '<div class="lib-chars-prod-body" id="' + bodyId + '" role="region" aria-label="' + escHtml(book) + '">'
|
+ '<div class="lib-chars-prod-body">'
|
||||||
+ (lazy ? '' : viewMode === 'table'
|
+ (viewMode === 'table'
|
||||||
? _charsTableHtml(visibleChars, sortMode, sortDir)
|
? _charsTableHtml(visibleChars, sortMode, sortDir)
|
||||||
: '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
|
: '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
|
||||||
+ visibleChars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
|
+ visibleChars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
|
||||||
+ '</div>')
|
+ '</div>')
|
||||||
+ (!lazy && visibleCount < matched.length
|
+ (visibleCount < chars.length
|
||||||
? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + matched.length + ')</button>'
|
? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + chars.length + ')</button>'
|
||||||
: '')
|
: '')
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
|
|
||||||
@ -388,17 +298,7 @@ async function libraryRenderCharacters(opts) {
|
|||||||
|
|
||||||
prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) {
|
prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) {
|
||||||
e.stopPropagation();
|
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');
|
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');
|
localStorage.setItem(collapseKey, collapsed ? '1' : '0');
|
||||||
});
|
});
|
||||||
// The whole head bar toggles too, not just the small chevron button —
|
// The whole head bar toggles too, not just the small chevron button —
|
||||||
@ -407,7 +307,6 @@ async function libraryRenderCharacters(opts) {
|
|||||||
// hit area than the chevron alone.
|
// hit area than the chevron alone.
|
||||||
prod.querySelector('.lib-chars-prod-head').addEventListener('click', function (e) {
|
prod.querySelector('.lib-chars-prod-head').addEventListener('click', function (e) {
|
||||||
if (e.target.closest('button, select, input, a')) return;
|
if (e.target.closest('button, select, input, a')) return;
|
||||||
if (inStudio) return;
|
|
||||||
prod.querySelector('.lib-chars-prod-collapse-btn').click();
|
prod.querySelector('.lib-chars-prod-collapse-btn').click();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -695,22 +594,6 @@ async function libraryRenderCharacters(opts) {
|
|||||||
productions.appendChild(prod);
|
productions.appendChild(prod);
|
||||||
});
|
});
|
||||||
container.appendChild(productions);
|
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);
|
if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(container);
|
||||||
|
|
||||||
// Scroll to a specific book's production block — set by callers that land
|
// Scroll to a specific book's production block — set by callers that land
|
||||||
|
|||||||
@ -188,51 +188,6 @@ function _stuInstallNavGuardOnce() {
|
|||||||
// the function.
|
// the function.
|
||||||
function _stuCallSuppressingNav(fn) { return fn(); }
|
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
|
// Per-phase borrow wiring — called by showStudioPhase whenever a phase
|
||||||
// becomes active. Each case borrows the real subtree that section already
|
// becomes active. Each case borrows the real subtree that section already
|
||||||
// renders into/wires listeners on; no logic is duplicated here.
|
// renders into/wires listeners on; no logic is duplicated here.
|
||||||
@ -245,7 +200,6 @@ function _stuEnterPhase(n) {
|
|||||||
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
|
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
|
||||||
});
|
});
|
||||||
} else if (n === 2) {
|
} else if (n === 2) {
|
||||||
_stuUpdateBookChip();
|
|
||||||
_stuShowCastView(_stuCastView);
|
_stuShowCastView(_stuCastView);
|
||||||
} else if (n === 3) {
|
} else if (n === 3) {
|
||||||
_stuBorrow('lib-chars-list', 'stu-voices-slot');
|
_stuBorrow('lib-chars-list', 'stu-voices-slot');
|
||||||
@ -260,12 +214,14 @@ function _stuEnterPhase(n) {
|
|||||||
// whichever book's cast session is active, so resolve the title from
|
// whichever book's cast session is active, so resolve the title from
|
||||||
// that via the server instead of trusting readerState.title first.
|
// that via the server instead of trusting readerState.title first.
|
||||||
(async () => {
|
(async () => {
|
||||||
// Studio works inside ONE book: the Voices step lists only that book's
|
let title = null;
|
||||||
// characters (library-characters.js reads _libCharsStudioBook while the
|
if (window._audiobook && window._audiobook.bookId) {
|
||||||
// list is borrowed into this slot; '' shows the "pick a book" prompt).
|
try {
|
||||||
const title = await _stuResolveBookTitle();
|
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
|
||||||
window._libCharsStudioBook = title || '';
|
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
|
||||||
window._libCharsScrollToBook = null;
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
window._libCharsScrollToBook = title || (window.readerState && readerState.title) || null;
|
||||||
_stuCallSuppressingNav(function () {
|
_stuCallSuppressingNav(function () {
|
||||||
if (typeof window.libraryRender === 'function') window.libraryRender('characters');
|
if (typeof window.libraryRender === 'function') window.libraryRender('characters');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -56,7 +56,6 @@
|
|||||||
|
|
||||||
<!-- ── Phase 2: Characters ─────────────────────────────────────── -->
|
<!-- ── Phase 2: Characters ─────────────────────────────────────── -->
|
||||||
<div class="stu-phase" id="stu-phase-2" hidden>
|
<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">
|
<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-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>
|
<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>
|
||||||
|
|||||||
@ -5989,29 +5989,3 @@ 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; }
|
.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; } }
|
@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); }
|
.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%; }
|
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
// @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();
|
|
||||||
});
|
|
||||||
@ -316,18 +316,6 @@ class PlanTests(unittest.TestCase):
|
|||||||
layout, articles = sample()
|
layout, articles = sample()
|
||||||
self.assertEqual(mu.plan(layout, articles, min_confidence=1.01)["merges"], [])
|
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):
|
def test_apply_titles_only_touches_placeholders(self):
|
||||||
_, articles = sample()
|
_, articles = sample()
|
||||||
n = mu.apply_titles(articles, [{"id": "article-001", "after": "X"}, {"id": "article-004", "after": "Anzeige",
|
n = mu.apply_titles(articles, [{"id": "article-001", "after": "X"}, {"id": "article-004", "after": "Anzeige",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user