From 9d80ec27e7e96462b82f889fd06acd1c60546aa9 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 3 Jul 2026 17:45:38 +0200 Subject: [PATCH] Merge Reader/Casting/Rehearser pipeline into one guided workflow (v1.12.84) - Add a 6-stage pipeline stepper (Source -> Cast Audiobook -> Cast Characters -> Script Rehearser -> Generate MP3s -> Audiobook) with direct, non-destructive jumps between stages and a prominent guided-tour look - Split PDF import into an explicit "load" then "Extract Text" step, with in-browser OCR (Tesseract.js, vendored) to recover chapter headlines baked into a PDF as images instead of real text - Fix casting feed silently merging pages after leaving/returning: segments now carry their own page number instead of re-guessing it from text - Fix excessive "Unknown" speaker attribution: restore the attribution LLM's output token budget, which had been cut roughly in half and was truncating dialogue-dense passages - Fix Theater Play library cards failing to open (dead pre-migration IndexedDB API calls, missing section navigation) - Fix bulk "Set tag" wiping a voice's existing tags instead of adding to them - Start merging Casting's feed with Script Rehearser's Stage UI: collapsible character sidebar, shared "paper" page styling, inline text editing - Fix a performance regression from that merge (per-row listeners on every redraw) by moving to event delegation - Various layout/clutter fixes: hide reader chrome until a document is loaded, collapse secondary settings by default, fix overlapping toolbar icons, fix duplicate "opening" notifications Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 149 ++ Dockerfile | 12 + VERSION | 2 +- core/constants.py | 32 +- core/database.py | 90 + routes/conversation.py | 207 +- routes/reader.py | 91 +- routes/tts.py | 111 +- static/index.html | 11 +- static/js/ai-backends.js | 1 + static/js/audiobook.js | 1905 ++++++++++++++--- static/js/character-sheets.js | 298 ++- static/js/characters-library.js | 124 +- static/js/library-characters.js | 13 + static/js/library.js | 12 +- static/js/reader.js | 647 +++++- static/js/rehearser.js | 17 +- static/js/seed-finder.js | 68 +- static/js/settings.js | 4 + static/js/stt.js | 3 +- static/js/tesseract/lang/deu.traineddata.gz | Bin 0 -> 854318 bytes static/js/tesseract/lang/eng.traineddata.gz | Bin 0 -> 1984273 bytes .../tesseract/tesseract-core-simd-lstm.wasm | Bin 0 -> 2859709 bytes .../tesseract-core-simd-lstm.wasm.js | 282 +++ static/js/tesseract/tesseract.min.js | 3 + static/js/tesseract/worker.min.js | 3 + static/js/utils.js | 237 +- static/js/voice-library.js | 14 +- static/js/voice-picker.js | 40 +- static/nav.js | 6 + static/sections/s-reader.html | 81 +- static/sections/s-rehearser.html | 2 + static/sections/s-voices.html | 2 +- static/style.css | 610 +++++- 34 files changed, 4451 insertions(+), 626 deletions(-) create mode 100644 static/js/tesseract/lang/deu.traineddata.gz create mode 100644 static/js/tesseract/lang/eng.traineddata.gz create mode 100644 static/js/tesseract/tesseract-core-simd-lstm.wasm create mode 100644 static/js/tesseract/tesseract-core-simd-lstm.wasm.js create mode 100644 static/js/tesseract/tesseract.min.js create mode 100644 static/js/tesseract/worker.min.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b066cf..6190cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,155 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.12.84] — 2026-07-03 + +### Fixed +- **Clicking "PDF" or "Text" in the pipeline stepper did nothing** — `navTo('s-reader')` auto-restores an active casting panel a tick later via a deferred `setTimeout` (nav.js), which silently undid the stepper's own `showReaderView('main')` call right after it ran. Now sets the existing `_readerSuppressCastRestore` flag (already used by the Library's "open a fresh book" flow for the same reason) before navigating. + +### Changed +- **Merged the "PDF" and "Text" stepper stops into one "Source"** — both stops led to the exact same Reader screen, so distinguishing them added a step without adding a real destination. Named "Source" to match the label already used on the Reader's import card. The stepper is now 6 stops instead of 7. + +--- + +## [1.12.83] — 2026-07-03 + +### Fixed +- **Casting feed merge/split/edit got slower as the merge work landed** — the new per-row inline-edit feature attached 2 extra event listeners to every segment row on every redraw, and merge/split rebuild the *entire* feed from scratch; for a 1000+ segment book that's thousands of extra listener attachments on every single merge/split. Moved editing to the same event-delegation pattern already used for merging and speaker assignment (one listener on the feed container, not one per row) — row creation is back to just building HTML. +- **Reader's playback transport bar stayed visible with nothing loaded** — `readerResetDoc()` hid the synth bar and the zoom/search toolbar but never hid the play/pause/skip transport bar, so a reset or fresh session showed "0 / 0" controls with no document — one of several boxes making the empty-state Reader screen feel cluttered. + +### Changed +- **Pipeline stepper made prominent, like a guided tour** — bigger numbered circles, a current-step highlight ring, and completed steps now show a checkmark and stay tinted instead of all steps looking equally flat. +- **"Voice & synthesis settings" now collapsed by default** — it's secondary until you actually have a document loaded; this was the first thing filling the empty-state screen with a large expanded box before you'd done anything. +- **`.ab-castpanel-inline` background set to `var(--panel)`** explicitly, per spec. + +--- + +## [1.12.82] — 2026-07-03 + +### Fixed +- **Duplicate "opening" notifications when reopening a saved book** — a toast ("Opening book…") and the "Reading PDF… page N / total" progress pill appeared at almost the same instant and visibly overlapped on screen. Removed the toast — the progress pill already says the same thing with more detail (live page count) and stays up for the whole load instead of auto-dismissing. + +--- + +## [1.12.81] — 2026-07-03 + +### Fixed +- **Casting toolbar icons overlapping at narrower panel widths** — `.ab-cv-topbar` (character-selection bar + undo/redo + page navigation) never wrapped, so when all three were visible at once at a moderate panel width, its fixed-width buttons got crammed together instead of flowing to a second row. Added `flex-wrap: wrap`. + +--- + +## [1.12.80] — 2026-07-03 + +### Added +- **First slice of the Read Aloud / Rehearser merge** — three pieces of the planned unified screen land in Casting audiobook now: (1) the "paper" page look — pages are now literal white/off-white sheets (Courier New, matching Script Rehearser's Stage exactly) inside the Casting border/shadow "stacked card" treatment, instead of a plain surface-coloured box; (2) the character sidebar is now collapsible to just avatar dots via a new chevron toggle, for more room when you don't need the full list; (3) every line in the casting feed can now be edited inline (pencil icon or double-click, Ctrl+Enter to save, Esc to cancel) — previously only speaker reassignment and segment merging were possible, not correcting the text itself. More pieces of the merge (shared page navigation, folding Script Rehearser's Stage into this same view) are in progress. + +--- + +## [1.12.79] — 2026-07-03 + +### Fixed +- **Theater Play library cards silently failed to open at all** — the real cause, on top of the missing navigation fixed in 1.12.78: opening a saved rehearsal (from Library → Theater Plays, and from Rehearser's own Library tab's open/export buttons) called `rehDbOpen()`/`REH_STORE`, a raw-IndexedDB API left over from before the app migrated rehearsals to server-backed SQLite. Neither exists anymore, so every click threw a `ReferenceError` caught silently by the surrounding try/catch — `loadRecord()` was never even reached, which is also why the 1.12.78 navigation fix alone didn't fully solve it. All three spots now use `rehDbGetById()`, the current API. + +## [1.12.78] — 2026-07-03 + +### Fixed +- **Theater Play library cards did nothing when clicked** — opening a saved rehearsal from Library → Theater Plays called `loadRecord()`, which loads the script/cast and switches to the Cast phase, but never navigated to the Rehearser section itself. Since you're sitting on the Library section when you click a card, the phase switch happened inside `#s-rehearser` while that whole section stayed hidden — so nothing visibly changed. `loadRecord()` now navigates to Script Rehearser first. + +--- + +## [1.12.77] — 2026-07-03 + +### Fixed +- **Excessive "Unknown" speakers in Casting audiobook** — the attribution LLM's output budget had been tightened to roughly half its previous size (`len(text)*0.45+512` vs. the old `len(text)+1000`, both capped). Narration-heavy passages fit fine, but dialogue-dense scenes need far more output than input — every quoted line becomes a full JSON object — so the tighter cap truncated the response mid-JSON on exchange-heavy passages. The truncation-repair only salvages a cut that lands right after a complete segment, so most failures degraded the whole chunk to naive quote-splitting, which has no way to attribute a speaker and labels everything "Unknown". Restored the more generous budget. + +--- + +## [1.12.76] — 2026-07-03 + +### Fixed +- **Bulk "Set tag" wiped a voice's existing tags** — applying a tag to multiple selected voices overwrote each voice's `tag` field outright instead of adding to it, so a voice tagged "Privat, Sillytavern" lost both tags the moment you bulk-tagged it with something new. It now merges the typed tag(s) into each voice's existing tag list instead of replacing it. + +--- + +## [1.12.75] — 2026-07-03 + +### Fixed +- **Casting audiobook panel grew to fit its content instead of the browser window** — the inline casting view (`.ab-castpanel-inline`) had no bounded height, so `.ab-cv-body`'s `flex:1` had nothing to divide up; the feed grew with every passage cast, pushing the action bar ("Continue casting" / "Edit in Rehearser" / ...) further down the page and forcing a full-page scroll to reach it. It's now bounded to the viewport like the floating casting popup already was, so the feed scrolls internally and the header/action bar stay in view. + +--- + +## [1.12.74] — 2026-07-03 + +### Fixed +- **Empty bar showing above Rehearser's Library tab (and potentially the Reader's "Extract Text" prompt)** — the new `.wf-stepper` and `.reader-extract-banner` rules set `display: flex` unconditionally, which beats the browser's default `[hidden] { display: none }` rule at equal specificity — the same bug class fixed previously for `.ab-char-bar`. Both now have an explicit `[hidden]` override so they actually disappear when hidden instead of rendering as an empty box. + +--- + +## [1.12.73] — 2026-07-03 + +### Added +- **Explicit "Extract Text" step for PDF import** — importing a PDF now stops after loading and rasterizing its pages (fast — you can already scroll/zoom and look at it) instead of immediately and silently running text extraction. A new banner shows the page count and an "Extract Text" button; the OCR-headings checkbox now lives here since it only matters right before extraction runs. This makes the pipeline stepper's "PDF" and "Text" stops correspond to two real, distinct stages instead of one being a no-op. Reopening an already-read document from the library is unaffected — it still extracts automatically, since there's nothing to decide on a document you've already extracted before. + +--- + +## [1.12.72] — 2026-07-03 + +### Added +- **Cross-workflow pipeline stepper** — a persistent strip above Reader/Casting and Rehearser now shows the full production pipeline (PDF → Text → Cast Audiobook → Cast Characters (optional) → Script Rehearser → Generate MP3s → Audiobook) as clickable stops. Every reachable stop jumps directly to that stage without losing state — previously "Open in Rehearser" was a one-way door with no way back except hunting through the sidebar, and re-entering the casting panel could lose track of where you were. + +### Fixed +- **Casting feed silently merged pages after leaving and returning to the casting panel** — page cards ("PAGE 4", "PAGE 6"...) were reconstructed on every redraw by re-guessing each segment's position via a forward-only text search against the cached source text; if a segment's text didn't exactly re-match (LLM cleanup, dehyphenation, short lines), the search stalled and every later page boundary was silently dropped, folding several pages into one card. Segments are now stamped with their real page number at cast time and the feed redraws directly from that instead of re-guessing it. + +--- + +## [1.12.71] — 2026-07-03 + +### Changed +- **"Not synthesised" highlight no longer paints the whole book red on import** — every sentence starts in the `pending` state, so a freshly-imported document showed wall-to-wall red before you'd asked for anything to be synthesised. The red tint (PDF overlay and text-mode word spans) now stays invisible until synthesis actually starts — clicking "Synthesise pages", pressing play, or reopening a saved document that already has some audio — at which point pending/synthesising/ready/reading colours all show normally to track real progress. + +--- + +## [1.12.70] — 2026-07-03 + +### Added +- **Recover chapter headlines baked into a PDF as images** — pdf.js's text extraction only ever sees real text glyphs, so a stylised chapter-title graphic (e.g. "1. Kapitel", "Prolog") silently vanished from the reader with no trace it was ever there. The importer now flags any page whose real text starts well below the top edge, rasterizes just that band, and OCRs it with a locally-vendored Tesseract.js worker (German + English), splicing the recognized heading back into the sentence stream so it reads aloud and highlights normally. Toggle via the new "OCR headings" checkbox above the import dropzone (on by default). + +--- + +## [1.12.61] — 2026-07-01 + +### Fixed +- **Cascading 429s during casting/recasting** — the frontend already sends attribution requests one at a time, but a client-side timeout only aborts the browser fetch; the backend's blocking call to the LLM kept running in its thread regardless, leaving a "ghost" request still occupying the LLM's processing slot. The next chunk (or a timeout's retry-in-halves) then fired into that busy slot and the LLM answered with 429, and the ghosts piled up faster than they drained. Outbound calls to the audiobook-attribution LLM are now serialized behind a backend lock so the app never has more than one real request in flight against the engine, no matter how many chunks or retries the frontend queues up. +- **Casting timeouts on slow/local models** — the attribution request timeout (90s) was tuned for fast hosted APIs, but a local model generating at ~15 tokens/sec can take several minutes to finish a single passage (up to ~4000 output tokens), so nearly every request was aborted client-side before the model replied. Attribution, retry, recast, and warmup timeouts are all raised (90s→6min, 60s→3min, 75s→4min, 3min→4min) to match realistic local-inference generation time. + +--- + +## [1.12.60] — 2026-07-01 + +### Fixed +- **Casting crash strands the panel and breaks the reader** — an unexpected per-passage error (bad LLM response shape, mid-retry network failure) could throw uncaught out of the casting loop instead of degrading gracefully to quote-splitting for that passage. The escaped error left `_audiobook.running` false with the panel stuck mid-cast; clicking "Stop Casting" then navigated back to the reader, which hit an unrelated null-pointer bug while re-rendering the PDF and showed an empty import screen. Per-passage attribution is now wrapped so any unexpected error falls back to auto-detected dialogue and the cast continues or ends in the normal "stopped early" panel instead of crashing. +- **Character selection bar placement** — reverted to sit above the casting feed (previous change had moved it below). + +--- + +## [1.12.59] — 2026-07-01 + +### Added +- **Resume interrupted audiobook casts** — a "Continue casting" button now appears after restoring an autosaved cast that was cut short (e.g. by a server crash), picking up at the passage it stopped on and keeping already-cast passages instead of requiring a full "Recast all". + +### Changed +- **Casting panel layout** — the progress bar now sits at the bottom of the casting panel, just above the action buttons, with the "Live" jump-to-latest button above it instead of overlapping the top of the feed. +- **Casting feed reads as paginated pages** — each source page in the casting feed now renders as its own card (white surface, thin border, soft shadow, margin between cards) on a neutral canvas background, instead of a plain dashed "Page N" divider inline with the text. +- **Casting panel element placement** — the character selection bar now appears below the feed instead of above it, and the "N characters · M segments" summary now sits under the character sidebar instead of in the action-button footer. + +### Fixed +- **Stale progress bar on restored casts** — reopening a finished or restored audiobook cast no longer re-shows the passage-count header and progress bar that the completed-state view had just hidden, which was also causing the "N% (Passage X of Y)" label to wrap and clip. +- **Interrupted casts silently reported as 100% done** — manually fixing a speaker name, or stopping a "Recast unknown"/"2nd Quality Run" pass, used to overwrite the saved draft's progress with a `done:-1,total:-1` sentinel that displayed as "100% complete", permanently hiding that the original cast never finished. Both now preserve the real progress instead, and a successful "Recast unknown" pass now persists its corrections (previously not saved at all). +- **Character selection bar showing as an empty box** — `.ab-char-bar` set `display:flex` unconditionally, which beat the browser's default `[hidden]` rule at equal CSS specificity, so the bar rendered as an empty box even while "hidden". Added an explicit `[hidden]` override. +- **Stale static assets after edits** — the app loads a prebuilt bundle (`static/dist/main.min.js`) in front of the individual `static/js/*.js` files, and the cache-busting version query string wasn't being bumped, so recent fixes weren't reaching the browser. Bundle rebuilt and version bumped to force a fresh fetch. + +--- + ## [1.12.58] — 2026-06-30 ### Fixed diff --git a/Dockerfile b/Dockerfile index 907a405..b936a75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,18 @@ COPY core/ core/ COPY routes/ routes/ COPY static/ static/ +# ── Vendor Tesseract.js (client-side OCR for image-based PDF chapter headings) ─ +# Skipped if already present (e.g. bind-mounted from host during development). +RUN [ -f static/js/tesseract/tesseract.min.js ] || ( \ + mkdir -p static/js/tesseract/lang && \ + curl -sL -o static/js/tesseract/tesseract.min.js "https://unpkg.com/tesseract.js@5.1.1/dist/tesseract.min.js" && \ + curl -sL -o static/js/tesseract/worker.min.js "https://unpkg.com/tesseract.js@5.1.1/dist/worker.min.js" && \ + curl -sL -o static/js/tesseract/tesseract-core-simd-lstm.wasm.js "https://unpkg.com/tesseract.js-core@5.1.1/tesseract-core-simd-lstm.wasm.js" && \ + curl -sL -o static/js/tesseract/tesseract-core-simd-lstm.wasm "https://unpkg.com/tesseract.js-core@5.1.1/tesseract-core-simd-lstm.wasm" && \ + curl -sL -o static/js/tesseract/lang/deu.traineddata.gz "https://tessdata.projectnaptha.com/4.0.0_fast/deu.traineddata.gz" && \ + curl -sL -o static/js/tesseract/lang/eng.traineddata.gz "https://tessdata.projectnaptha.com/4.0.0_fast/eng.traineddata.gz" \ + ) + # ── Runtime user ────────────────────────────────────────────────────────────── RUN mkdir -p /voices/active_voices /voices/hidden_voices /home/app/.config/tts-voice-creator && \ useradd -m -s /bin/bash app && \ diff --git a/VERSION b/VERSION index 26af9b2..aad4e4c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.58 +1.12.84 diff --git a/core/constants.py b/core/constants.py index 54b5ae9..0876dcc 100644 --- a/core/constants.py +++ b/core/constants.py @@ -22,24 +22,24 @@ __version__: str = _read_version() _VOICES_DIR_DEFAULT = os.environ.get("VOICES_DIR", "/voices") _OUTPUT_DIR_DEFAULT = os.environ.get("OUTPUT_DIR", "/voices/active_voices") -_WHISPER_DEFAULT = os.environ.get("WHISPER_URL", "http://host.docker.internal:8010") -_TTS_DEFAULT = os.environ.get("TTS_URL", "http://host.docker.internal:8020") -_TTS_STREAM_DEFAULT = os.environ.get("TTS_STREAM_URL", "http://host.docker.internal:8023") -_CUSTOMVOICE_DEFAULT = os.environ.get("CUSTOMVOICE_URL", "http://host.docker.internal:8022") -_VOICE_DESIGN_DEFAULT = os.environ.get("VOICE_DESIGN_URL", "http://host.docker.internal:8021") -_NVIDIA_ROUTER_DEFAULT = os.environ.get("NVIDIA_SPEECH_ROUTER_URL", "http://host.docker.internal:8090") -_NVIDIA_TTS_DEFAULT = os.environ.get("NVIDIA_MAGPIE_TTS_URL", "http://host.docker.internal:8091") -_NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://host.docker.internal:8092") -_NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://host.docker.internal:8093") +_WHISPER_DEFAULT = os.environ.get("WHISPER_URL", "http://192.168.178.8:8010") +_TTS_DEFAULT = os.environ.get("TTS_URL", "http://192.168.178.8:8020") +_TTS_STREAM_DEFAULT = os.environ.get("TTS_STREAM_URL", "http://192.168.178.8:8023") +_CUSTOMVOICE_DEFAULT = os.environ.get("CUSTOMVOICE_URL", "http://192.168.178.8:8022") +_VOICE_DESIGN_DEFAULT = os.environ.get("VOICE_DESIGN_URL", "http://192.168.178.8:8021") +_NVIDIA_ROUTER_DEFAULT = os.environ.get("NVIDIA_SPEECH_ROUTER_URL", "http://192.168.178.8:8090") +_NVIDIA_TTS_DEFAULT = os.environ.get("NVIDIA_MAGPIE_TTS_URL", "http://192.168.178.8:8091") +_NVIDIA_ASR_DEFAULT = os.environ.get("NVIDIA_PARAKEET_ASR_URL", "http://192.168.178.8:8092") +_NVIDIA_CLONE_DEFAULT = os.environ.get("NVIDIA_TTS_CLONE_URL", "http://192.168.178.8:8093") _NVIDIA_ZEROSHOT_DEFAULT = os.environ.get("NVIDIA_ZEROSHOT_TTS_URL", _NVIDIA_CLONE_DEFAULT) -_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://host.docker.internal:8094") -_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://host.docker.internal:8010") -_WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://host.docker.internal:8085") +_NVIDIA_FLOW_DEFAULT = os.environ.get("NVIDIA_FLOW_TTS_URL", "http://192.168.178.8:8094") +_FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://192.168.178.8:8010") +_WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://192.168.178.8:8085") _GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1" -_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1") +_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://192.168.178.8:8880/v1") _VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027") -_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8005") -_FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://host.docker.internal:38080") +_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://192.168.178.8:8005") +_FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://192.168.178.8:38080") _TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts") _TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override _VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign") @@ -48,7 +48,7 @@ _VOICE_PEAK_DBFS = float(os.environ.get("VOICE_PEAK_DBFS", "-1.0")) _MAX_UPLOAD_BYTES = int(os.environ.get("MAX_UPLOAD_MB", "1024")) * 1024 * 1024 _MAX_PICTURE_BYTES = int(os.environ.get("MAX_PICTURE_MB", "10")) * 1024 * 1024 _MAX_SOUND_BYTES = int(os.environ.get("MAX_SOUND_MB", "25")) * 1024 * 1024 -_MAX_TTS_OUTPUT_SECONDS = float(os.environ.get("MAX_TTS_OUTPUT_SECONDS", "30")) +_MAX_TTS_OUTPUT_SECONDS = float(os.environ.get("MAX_TTS_OUTPUT_SECONDS", "60")) _STT_REQUEST_TIMEOUT = float(os.environ.get("STT_REQUEST_TIMEOUT", os.environ.get("REQUEST_TIMEOUT", "900"))) _BENCHMARK_TEXT = os.environ.get("VOICE_BENCHMARK_TEXT", "This is a short realtime voice benchmark.") _BENCHMARK_SENTENCES = [ diff --git a/core/database.py b/core/database.py index aa0d613..55592ef 100644 --- a/core/database.py +++ b/core/database.py @@ -50,6 +50,18 @@ CREATE TABLE IF NOT EXISTS rehearsals ( ); CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title); +CREATE TABLE IF NOT EXISTS reader_scripts ( + doc_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT 'cast', + title TEXT NOT NULL DEFAULT '', + payload TEXT NOT NULL DEFAULT '{}', + saved_at TEXT, + created TEXT, + updated TEXT, + PRIMARY KEY (doc_id, name) +); +CREATE INDEX IF NOT EXISTS idx_reader_scripts_doc ON reader_scripts(doc_id); + CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT 'null', @@ -105,6 +117,7 @@ def _row_to_char(row: sqlite3.Row) -> dict: d = dict(row) d["sheet"] = json.loads(d.get("sheet") or "{}") d["analysis"] = json.loads(d.get("analysis") or "null") + d["color"] = d["sheet"].get("color") return d @@ -241,6 +254,83 @@ def reh_delete(reh_id: int) -> bool: return True +# ── Reader scripts / audiobook casts ───────────────────────────────────────── + +def reader_script_list(doc_id: str) -> list[dict]: + with _open() as conn: + rows = conn.execute( + "SELECT name, title, payload, saved_at, updated FROM reader_scripts WHERE doc_id=? ORDER BY updated DESC", + (doc_id,), + ).fetchall() + out: list[dict] = [] + for row in rows: + payload: dict = {} + try: + payload = json.loads(row["payload"] or "{}") + except Exception: + payload = {} + out.append({ + "name": row["name"], + "title": row["title"], + "savedAt": row["saved_at"] or payload.get("savedAt"), + "updated": row["updated"], + "segments": len(payload.get("segments", [])) if isinstance(payload.get("segments"), list) else 0, + "roster": payload.get("roster", []), + }) + return out + + +def reader_script_get(doc_id: str, name: str = "cast") -> dict | None: + with _open() as conn: + row = conn.execute( + "SELECT payload FROM reader_scripts WHERE doc_id=? AND name=?", + (doc_id, name), + ).fetchone() + if not row: + return None + try: + data = json.loads(row["payload"] or "{}") + return data if isinstance(data, dict) else None + except Exception: + return None + + +def reader_script_put(doc_id: str, name: str, data: dict) -> dict: + now = _now() + with _open() as conn: + existing = conn.execute( + "SELECT created FROM reader_scripts WHERE doc_id=? AND name=?", + (doc_id, name), + ).fetchone() + created = existing["created"] if existing else now + conn.execute(""" + INSERT INTO reader_scripts (doc_id, name, title, payload, saved_at, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(doc_id, name) DO UPDATE SET + title=excluded.title, + payload=excluded.payload, + saved_at=excluded.saved_at, + updated=excluded.updated + """, ( + doc_id, + name, + str(data.get("title", "")), + _j(data), + str(data.get("savedAt", "")), + created, + now, + )) + conn.commit() + return data + + +def reader_script_delete(doc_id: str, name: str = "cast") -> bool: + with _open() as conn: + conn.execute("DELETE FROM reader_scripts WHERE doc_id=? AND name=?", (doc_id, name)) + conn.commit() + return True + + # ── Generic app state ───────────────────────────────────────────────────────── def state_get(key: str, default: Any = None) -> Any: diff --git a/routes/conversation.py b/routes/conversation.py index 32fcc29..9854972 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -34,6 +34,14 @@ from routes.stt import _transcribe_audio, _clean_stt_backend router = APIRouter() +# Serializes outbound calls to the audiobook-attribution LLM. A client-side +# timeout aborts the browser fetch but can't kill the backend's blocking +# requests.post thread, so a slow/cold model leaves "ghost" requests still +# occupying the LLM's single processing slot. Without this lock, the next +# chunk (or the retry-in-halves) fires into that busy slot and the LLM +# answers with 429s that cascade until the ghosts drain. +_attribution_llm_lock = asyncio.Lock() + # ── STT hallucination filter ────────────────────────────────────────────────── # Whisper commonly hallucinates these phrases on silence/noise. # Treat them as "no speech detected" rather than passing them to the LLM. @@ -104,6 +112,76 @@ def _request_timeout_seconds(value, default: float = 600.0, minimum: float = 5.0 return max(minimum, min(maximum, timeout)) +def _is_router_model_alias(model: str) -> bool: + return bool(re.match(r"^\s*auto[-_ ]?router", model or "", re.I)) + + +def _response_error_text(resp) -> str: + try: + text = resp.text or "" + except Exception: + text = "" + return text.strip()[:2000] or getattr(resp, "reason", "") or "Unknown upstream error" + + +def _llm_chat_completion_urls(llm_url: str) -> list[str]: + base = (llm_url or "http://localhost:11434/v1").rstrip("/") + urls: list[str] = [] + if not re.search(r"/v\d+(?:\.\d+)?$", base): + urls.append(f"{base}/v1/chat/completions") + urls.append(f"{base}/chat/completions") + return list(dict.fromkeys(urls)) + + +def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeout: float): + last_resp = None + last_exc = None + for url in _llm_chat_completion_urls(llm_url): + try: + resp = requests.post(url, json=payload, headers=headers, timeout=timeout) + last_resp = resp + if resp.status_code in (404, 405) and url != _llm_chat_completion_urls(llm_url)[-1]: + continue + return resp + except Exception as exc: + last_exc = exc + if last_resp is not None: + return last_resp + if last_exc: + raise last_exc + raise RuntimeError("LLM request failed") + + +def _fallback_attribute_response(text: str) -> dict: + """Deterministic fallback for attribution when an upstream LLM fails.""" + quote_re = re.compile( + r"»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|\"([^\"]+)\"|「([^」]+)」|『([^』]+)』", + re.DOTALL, + ) + segments = [] + pos = 0 + for match in quote_re.finditer(text): + if match.start() > pos: + narr = text[pos:match.start()].strip() + if narr: + segments.append({"speaker": "Narrator", "type": "narration", "text": narr, "emotion": ""}) + spoken = next((g for g in match.groups() if g), "") + if spoken.strip(): + segments.append({"speaker": "Unknown", "type": "dialogue", "text": spoken.strip(), "emotion": ""}) + pos = match.end() + tail = text[pos:].strip() + if tail: + segments.append({"speaker": "Narrator", "type": "narration", "text": tail, "emotion": ""}) + if not segments: + segments = [{"speaker": "Narrator", "type": "narration", "text": text, "emotion": ""}] + chars = [] + for seg in segments: + sp = seg.get("speaker") + if seg.get("type") == "dialogue" and sp and sp not in ("Narrator", "Unknown") and sp not in chars: + chars.append(sp) + return {"segments": segments, "characters": chars, "fallback": True} + + def _resolve_speak_voice(settings: dict, client_id: str, explicit_voice: str) -> str: if explicit_voice: return explicit_voice @@ -457,7 +535,7 @@ async def character_sheets(request: Request): Body: {text, known_characters:[...], language, llm_url, model} The text may contain "[p.N]" page markers so the model can cite sources. - Returns: {sheets:[{name, aliases, archetype, physical, alignment, + Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment, attribute_high, attribute_low, skills, inventory:[...], secret, conflict_style, win_condition, tier:"main"|"supporting", sources:[{page, quote}]}], characters:[names]} @@ -466,6 +544,7 @@ async def character_sheets(request: Request): data = await request.json() text: str = (data.get("text") or "").strip() known: list = data.get("known_characters") or [] + target_mode: bool = bool(data.get("target_mode")) existing: str = (data.get("existing") or "").strip() # partial sheets so far (progressive fill) language: str = (data.get("language") or "").strip() _settings = _load_settings() @@ -481,12 +560,25 @@ async def character_sheets(request: Request): f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n" f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, " f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, " - f"conflict_style, win_condition, archetype, aliases.\n" + f"conflict_style, win_condition, archetype, aliases, first_name, last_name, full_name, title.\n" + f"voice_design_prompt and image_prompt should be concise English tool prompts for generation tools.\n" f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n" f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n" ) if language else "" + target_instruction = ( + "CAST TARGET MODE:\n" + "- The known-characters list is the already recognized cast roster from the audiobook pass. " + "Treat it as TARGET PROFILES to fill, not as a list of aliases and not as proof that those names are the same person.\n" + "- Prefer outputting sheets for those target names when this passage reveals usable profile details. " + "Do not output a blank sheet just because a target is listed.\n" + "- If the passage proves that a target has another name/title/nickname, keep ONE profile using the best known target name " + "and put only the proven alternate form in aliases/title/full_name.\n" + "- You may add a brand-new character only when the passage clearly introduces one that is not in the target roster.\n" + "- NEVER copy the known-characters roster into one character's aliases, relationships, title, or description fields.\n\n" + ) if target_mode and known else "" system = ( f"{lang_instruction}" + f"{target_instruction}" "You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich " "character sheets passage by passage as a book is read. Extract playable, action-oriented sheets " "an actor can use to immediately know how to PLAY the character.\n" @@ -496,7 +588,9 @@ async def character_sheets(request: Request): "if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue " "and actions when reasonable, and mark any deduced value with a trailing ' *'.\n" "For each character output these fields:\n" - "- name, aliases\n" + "- name: canonical display name for this one character. Use the real personal name if known; otherwise use the most stable role/title.\n" + "- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n" + "- first_name, last_name, full_name, title: split the character identity when known. Leave unknown parts empty. Put noble/office/role labels in title (e.g. 'Henker', 'Vampir', 'Graf').\n" "- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n" "- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n" "- clothing: distinctive clothing, armour, accessories — as observed in the text\n" @@ -513,20 +607,26 @@ async def character_sheets(request: Request): "- fears: their deepest fears, phobias or dread\n" "- mannerisms: habitual gestures, tics, body language, habits and quirks\n" "- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n" + "- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.\n" + "- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n" "- inventory: 1-3 defining items/props/clothing (array of short strings)\n" "- secret: dark secret or fatal flaw\n" "- conflict_style: fight, flight, or manipulate — how they act when cornered\n" "- win_condition: the specific event that would make them feel they have won\n" "- tier: 'main' or 'supporting'\n" "- sources: array of {page, quote, line_hint} — the page number from the nearest [p.N] marker, " - "a short verbatim quote that supports the sheet (1-5 entries), and a brief label (e.g. 'appearance', 'alignment'). " - "Use null page if unknown.\n" - "Reuse the EXACT names from the known-characters list for returning characters. " + "a short verbatim quote that supports the sheet (1-12 entries), and a brief label (e.g. 'physical', 'clothing', 'relationships', 'motivation'). " + "Use null page if unknown. line_hint MUST name the supported field when possible: physical, clothing, relationships, motivation, fears, mannerisms, voice_pattern, backstory, alignment, skills, capabilities, secret, conflict_style, or win_condition.\n" + "IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). " + "Examples: 'Zerwas', 'Henker', and 'Vampir' may all refer to ONE profile if context shows they are the same person. " + "Do NOT create separate sheets for aliases/titles of the same person; put the alternate forms in aliases/title/full_name and keep one canonical name.\n" + "Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. " + "Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n" "Respond with STRICT JSON only:\n" - '{"sheets":[{"name":"","aliases":"","archetype":"","physical":"","clothing":"",' + '{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","physical":"","clothing":"",' '"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",' '"attribute_high":"","attribute_low":"","skills":"","capabilities":"",' - '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"",' + '"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",' '"inventory":[],"secret":"","conflict_style":"","win_condition":"",' '"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think' ) @@ -552,9 +652,9 @@ async def character_sheets(request: Request): payload["model"] = model try: resp = await asyncio.to_thread( - requests.post, - f"{llm_url}/chat/completions", json=payload, - headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=timeout_seconds, + _post_llm_chat_completion, + llm_url, payload, + {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds, ) resp.raise_for_status() _msg = resp.json()["choices"][0]["message"] @@ -575,18 +675,42 @@ async def character_sheets(request: Request): except Exception: continue clean, names = [], [] + alias_splitter = re.compile(r"[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b", re.I) for s in sheets: if not isinstance(s, dict): continue name = str(s.get("name") or "").strip() if not name: continue + alias_raw = str(s.get("aliases") or "").strip() + alias_parts = [p.strip() for p in alias_splitter.split(alias_raw) if p.strip()] + alias_parts = [p for p in alias_parts if len(p) <= 80 and not re.match(r"^(needs?:|complete$)", p, re.I)] + if len(alias_raw) > 500 or len(alias_parts) > 12: + aliases = "" + else: + aliases = ", ".join(alias_parts[:6]) inv = s.get("inventory") if isinstance(inv, str): inv = [x.strip() for x in inv.split(",") if x.strip()] elif not isinstance(inv, list): inv = [] - src = s.get("sources") if isinstance(s.get("sources"), list) else [] + src_raw = s.get("sources") if isinstance(s.get("sources"), list) else [] + src = [] + for item in src_raw: + if not isinstance(item, dict): + continue + quote = str(item.get("quote") or "").strip() + if not quote: + continue + try: + page = int(item.get("page")) if item.get("page") is not None else None + except (TypeError, ValueError): + page = None + src.append({ + "page": page, + "quote": quote[:240], + "line_hint": str(item.get("line_hint") or "").strip()[:60], + }) # Clamp moral alignment score try: mas = int(s.get("moral_alignment_score") or 50) @@ -597,9 +721,9 @@ async def character_sheets(request: Request): if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"): arc = "neutral" s.update({ - "name": name, "inventory": inv[:3], + "name": name, "aliases": aliases, "inventory": inv[:3], "tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting", - "sources": src[:5], + "sources": src[:12], "moral_alignment_score": mas, "arc_direction": arc, }) @@ -714,7 +838,10 @@ async def attribute_dialogue(request: Request): lang_hint = f" The text language is {language}; keep names and wording in that language." if language else "" - base_prompt = _settings.get("audiobook_prompt") or "" + prompt_override = data.get("audiobook_prompt") + if not isinstance(prompt_override, str): + prompt_override = data.get("prompt") + base_prompt = (prompt_override if isinstance(prompt_override, str) else "") or _settings.get("audiobook_prompt") or "" if not base_prompt.strip(): base_prompt = ( "You attribute dialogue in prose fiction for a multi-voice audiobook. " @@ -742,6 +869,11 @@ async def attribute_dialogue(request: Request): "context — this should be rare. Prefer the most likely named character over 'Unknown'.\n" "RULES:\n" "- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.\n" + "- GRAMMAR CHECK FOR NARRATION: if a fragment is a reporting clause about speech " + "(finite speech verb + speaker/pronoun/name, e.g. 'murmelte er mit erstickter Stimme', " + "', entgegnete Marcian kalt', 'asked Peter quietly'), it is narration, never dialogue.\n" + "- Action beats are narration: a character looks, walks, laughs, stays silent, raises a hand, " + "turns away, etc. Only the actual quoted words are dialogue.\n" "- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts " "into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.\n" "- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.\n" @@ -769,21 +901,52 @@ async def attribute_dialogue(request: Request): {"role": "user", "content": user}, ], "temperature": 0.2, + # Dialogue-dense passages need much more output than input: every quoted + # line becomes a full JSON object ({"speaker":...,"type":...,"text":..., + # "emotion":...}), so budgeting off raw character count alone (as a + # tighter cap here once did) truncates mid-JSON on exchange-heavy scenes + # — which the parser can't always repair, degrading the whole chunk to + # naive quote-splitting with every speaker labelled "Unknown". "max_tokens": min(8192, max(2048, len(text) + 1000)), } if model: payload["model"] = model try: - resp = await asyncio.to_thread( - requests.post, - f"{llm_url}/chat/completions", json=payload, - headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=timeout_seconds, - ) - resp.raise_for_status() + async with _attribution_llm_lock: + resp = await asyncio.to_thread( + _post_llm_chat_completion, + llm_url, payload, + {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds, + ) + fallback_model = (_settings.get("llm_model") or "").strip() + if ( + resp.status_code >= 400 + and _is_router_model_alias(model) + and fallback_model + and fallback_model != model + and not _is_router_model_alias(fallback_model) + ): + retry_payload = dict(payload) + retry_payload["model"] = fallback_model + resp = await asyncio.to_thread( + _post_llm_chat_completion, + llm_url, retry_payload, + {"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds, + ) + payload = retry_payload + if resp.status_code >= 400: + print( + f"[attribute-dialogue] LLM failed ({resp.status_code}) for model " + f"{payload.get('model') or '(default)'}: {_response_error_text(resp)}" + ) + return _fallback_attribute_response(text) msg = resp.json()["choices"][0]["message"] raw = (msg.get("content") or msg.get("reasoning_content") or "").strip() + except HTTPException: + raise except Exception as e: - raise HTTPException(502, f"LLM attribution failed: {e}") + print(f"[attribute-dialogue] LLM failed, using deterministic fallback: {e}") + return _fallback_attribute_response(text) content = re.sub(r".*?", "", raw, flags=re.DOTALL).strip() or raw segments = [] diff --git a/routes/reader.py b/routes/reader.py index aaf58a5..b53136f 100644 --- a/routes/reader.py +++ b/routes/reader.py @@ -18,6 +18,12 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import FileResponse, Response from core.constants import CONFIG_DIR +from core.database import ( + reader_script_delete as db_reader_script_delete, + reader_script_get as db_reader_script_get, + reader_script_list as db_reader_script_list, + reader_script_put as db_reader_script_put, +) router = APIRouter() @@ -191,16 +197,24 @@ async def reader_get_cover(doc_id: str): return FileResponse(str(f), media_type="image/jpeg", headers={"Cache-Control": "max-age=31536000"}) -# ── Scripts (cast save/load) ─────────────────────────────────────────────────── -# Saves the LLM-cast result (segments + roster) as a Markdown file under -# //Scripts/cast.md -# so it survives browser-cache clears and works across devices. +# ── Scripts (cast save/load/export) ──────────────────────────────────────────── +# Durable storage lives in SQLite. Markdown files are export/import-compatible +# snapshots only: old files are still readable and get migrated into the DB on +# first access, while new autosaves no longer create files behind the user's back. def _scripts_dir(doc_id: str) -> Path: d = _doc_dir(doc_id) / "Scripts" d.mkdir(parents=True, exist_ok=True) return d + +def _legacy_scripts_dir(doc_id: str) -> Path: + return _doc_dir(doc_id) / "Scripts" + + +def _valid_script_name(name: str) -> bool: + return bool(name and name.replace("-", "").replace("_", "").isalnum()) + def _cast_to_md(data: dict) -> str: """Serialise cast JSON as a fenced code-block Markdown file.""" meta = {k: v for k, v in data.items() if k != "segments"} @@ -229,53 +243,98 @@ def _md_to_cast(text: str) -> dict: @router.get("/api/reader/docs/{doc_id}/scripts") async def reader_list_scripts(doc_id: str): - sd = _scripts_dir(doc_id) - scripts = [] + _doc_dir(doc_id) + scripts = db_reader_script_list(doc_id) + seen = {s.get("name") for s in scripts} + sd = _legacy_scripts_dir(doc_id) + if not sd.is_dir(): + return scripts for f in sorted(sd.glob("*.md")): + if f.stem in seen: + continue try: data = _md_to_cast(f.read_text(encoding="utf-8")) scripts.append({ "name": f.stem, + "title": data.get("title", ""), "savedAt": data.get("savedAt"), "segments": len(data.get("segments", [])), "roster": data.get("roster", []), + "legacy": True, }) except Exception: - scripts.append({"name": f.stem}) + scripts.append({"name": f.stem, "legacy": True}) return scripts @router.get("/api/reader/docs/{doc_id}/scripts/{name}") async def reader_get_script(doc_id: str, name: str): - if not name.replace("-", "").replace("_", "").isalnum(): + if not _valid_script_name(name): raise HTTPException(400, "Bad script name") - f = _scripts_dir(doc_id) / f"{name}.md" + _doc_dir(doc_id) + data = db_reader_script_get(doc_id, name) + if data is not None: + return data + f = _legacy_scripts_dir(doc_id) / f"{name}.md" if not f.is_file(): raise HTTPException(404, "Script not found") try: - return _md_to_cast(f.read_text(encoding="utf-8")) + data = _md_to_cast(f.read_text(encoding="utf-8")) + db_reader_script_put(doc_id, name, data) + return data except Exception as e: raise HTTPException(500, str(e)) @router.put("/api/reader/docs/{doc_id}/scripts/{name}") async def reader_save_script(doc_id: str, name: str, request: Request): - if not name.replace("-", "").replace("_", "").isalnum(): + if not _valid_script_name(name): raise HTTPException(400, "Bad script name") + d = _doc_dir(doc_id) + if not d.is_dir(): + raise HTTPException(404, "Document not found") try: data = await request.json() except Exception: raise HTTPException(400, "Invalid JSON") - f = _scripts_dir(doc_id) / f"{name}.md" - f.write_text(_cast_to_md(data), encoding="utf-8") - return {"saved": str(f.relative_to(_LIB))} + db_reader_script_put(doc_id, name, data) + return {"saved": f"database:{doc_id}/{name}"} + + +@router.get("/api/reader/docs/{doc_id}/scripts/{name}/export") +async def reader_export_script(doc_id: str, name: str): + if not _valid_script_name(name): + raise HTTPException(400, "Bad script name") + _doc_dir(doc_id) + data = db_reader_script_get(doc_id, name) + if data is None: + f = _legacy_scripts_dir(doc_id) / f"{name}.md" + if not f.is_file(): + raise HTTPException(404, "Script not found") + try: + data = _md_to_cast(f.read_text(encoding="utf-8")) + except Exception as e: + raise HTTPException(500, str(e)) + filename = f"{name}.md" + title = str(data.get("title") or "").strip() + if title: + safe_title = "".join(c if c.isalnum() or c in "._- " else "_" for c in title).strip().replace(" ", "_") + if safe_title: + filename = f"{safe_title}_{name}.md" + return Response( + _cast_to_md(data), + media_type="text/markdown; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) @router.delete("/api/reader/docs/{doc_id}/scripts/{name}") async def reader_delete_script(doc_id: str, name: str): - if not name.replace("-", "").replace("_", "").isalnum(): + if not _valid_script_name(name): raise HTTPException(400, "Bad script name") - f = _scripts_dir(doc_id) / f"{name}.md" + _doc_dir(doc_id) + db_reader_script_delete(doc_id, name) + f = _legacy_scripts_dir(doc_id) / f"{name}.md" if f.is_file(): f.unlink() return {"deleted": name} diff --git a/routes/tts.py b/routes/tts.py index eedfaf7..928f2b4 100644 --- a/routes/tts.py +++ b/routes/tts.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio import io +import json import logging import os import re +import threading import time import uuid from pathlib import Path @@ -30,6 +32,7 @@ from core.voice import ( _find_voice_audio, _active_voices_dir, _voice_audio_files, _load_meta, _save_meta, _AUDIO_EXTS, + _read_reference_text, _voice_health, ) from core.voice_index import indexed_voices, refresh_voice_index_background @@ -50,6 +53,7 @@ from core.docker_client import _docker_post logger = logging.getLogger("uvicorn.error") router = APIRouter() +_SEED_FINDER_VOICE_LOCK = threading.Lock() # ── TTS backend helpers ─────────────────────────────────────────────────────── @@ -194,6 +198,98 @@ def _backend_available(backend: str, voices: list, health: bool) -> bool: return bool(voices) or health +def _seed_finder_voices_json_path(settings: dict) -> Path: + configured = Path(str(settings.get("tts_config_dir") or "/tts-config")) + candidates = [ + configured / "voices.json", + Path("/tts-config/voices.json"), + Path("/config/voices.json"), + ] + for path in candidates: + if path.exists(): + return path + return candidates[0] + + +def _seed_finder_backend_ref_audio(audio: Path, settings: dict) -> str: + output_dir = _active_voices_dir(settings) + try: + rel = audio.resolve().relative_to(output_dir.resolve()) + return "/voices/" + rel.as_posix() + except Exception: + pass + return f"/voices/{audio.name}" + + +def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict: + cfg = dict(voices.get(voice) or {}) + if cfg.get("ref_audio"): + return cfg + + scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) + audio = _find_voice_audio(voice, scan_dir) + if not audio: + raise RuntimeError(f"reference WAV for voice {voice!r} not found") + _has_ref, ref_text = _read_reference_text(audio) + lang = { + "DE": "German", "EN": "English", "GB": "English", "FR": "French", + "ES": "Spanish", "IT": "Italian", "PT": "Portuguese", "NL": "Dutch", + "PL": "Polish", "ZH": "Chinese", "JA": "Japanese", "KO": "Korean", + }.get(voice.split("_", 1)[0].upper(), "Auto") + return { + "ref_audio": _seed_finder_backend_ref_audio(audio, settings), + "language": lang, + "chunk_size": 4, + "ref_text": ref_text, + } + + +def _seed_finder_request_audio(text: str, voice: str, settings: dict, seed: int) -> tuple[bytes, str]: + """Generate a Seed Finder sample from the reference WAV, not the voice's cached .pt. + + The Qwen3 VoiceClone server does not accept per-request ``seed`` on + /v1/audio/speech; it reads the seed from voices.json. For Seed Finder we create + a temporary voice entry that points at the selected reference WAV, omits the + existing speaker embedding, sets the requested seed, generates once, then + restores voices.json and removes the temporary .pt cache. + """ + voices_path = _seed_finder_voices_json_path(settings) + if not voices_path.exists(): + raise RuntimeError(f"TTS voices.json not found at {voices_path}") + + safe_voice = re.sub(r"[^A-Za-z0-9_\-.]+", "_", voice).strip("._-")[:60] or "voice" + temp_voice = f"__seedfinder_{safe_voice}_{int(seed)}_{uuid.uuid4().hex[:8]}" + temp_pt = Path("/tts-config/speakers") / f"{temp_voice}.pt" + + with _SEED_FINDER_VOICE_LOCK: + original_text = voices_path.read_text(encoding="utf-8") + try: + voices = json.loads(original_text or "{}") + if not isinstance(voices, dict): + raise RuntimeError("voices.json is not an object") + cfg = _seed_finder_voice_config(voice, voices, settings) + cfg = dict(cfg) + cfg.pop("speaker_embeddings", None) + cfg.pop("speaker embeddings", None) + cfg["seed"] = int(seed) + voices[temp_voice] = cfg + voices_path.write_text(json.dumps(voices, indent=2, ensure_ascii=False), encoding="utf-8") + os.utime(voices_path, None) + time.sleep(0.35) # let the backend hot-reload voices.json before resolve_voice() + return _preview_request_audio(text, temp_voice, settings, "", "voice_clone") + finally: + try: + voices_path.write_text(original_text, encoding="utf-8") + os.utime(voices_path, None) + except Exception as exc: + logger.warning("Failed to restore voices.json after Seed Finder sample: %s", exc) + try: + if temp_pt.exists(): + temp_pt.unlink() + except Exception: + pass + + _TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers", "/voices") _KOKORO_BUILTIN_VOICES = [ "af", "af_bella", "af_nicole", "af_sarah", "af_sky", @@ -523,14 +619,23 @@ async def tts_preview(request: Request): raise HTTPException(502, f"Persona rewrite failed: {e}") try: - audio, media_type = await asyncio.to_thread(_preview_request_audio, text, voice, settings, instruct, backend) + if backend == "voice_clone" and data.get("seed_finder") and "seed" in _overrides: + audio, media_type = await asyncio.to_thread( + _seed_finder_request_audio, text, voice, settings, int(_overrides["seed"]) + ) + else: + audio, media_type = await asyncio.to_thread(_preview_request_audio, text, voice, settings, instruct, backend) except Exception as e: raise HTTPException(502, f"TTS error: {e}") - audio, media_type, ext, _duration_sec, _clipped = _prepare_proxy_audio(audio, media_type, response_format) + audio, media_type, ext, duration_sec, clipped = _prepare_proxy_audio(audio, media_type, response_format) return Response( content=audio, media_type=media_type, - headers={"Content-Disposition": f'inline; filename="{voice}_preview.{ext}"'}, + headers={ + "Content-Disposition": f'inline; filename="{voice}_preview.{ext}"', + "X-TTS-Audio-Duration": f"{duration_sec:.3f}" if duration_sec is not None else "", + "X-TTS-Audio-Clipped": "true" if clipped else "false", + }, ) diff --git a/static/index.html b/static/index.html index cf572ea..75e0fbb 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -316,7 +316,10 @@
-
Loading…
+
+ Loading… + +
-
@@ -395,8 +826,11 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
-
-
Characters found
+
+
+ Characters found + +
${[38,62,45,28,54,35].map(w => `
`).join('')} @@ -404,9 +838,12 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
-
+ @@ -419,6 +856,12 @@ WICHTIGE DEFINITION VON DIALOG: Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden. Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog). +GRAMMATIK-REGELN FÜR NARRATION: +- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt."). +- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht. +- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue. +- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext. + ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv): 1. Direkte Zuordnung: Achte auf Wörter wie "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) zum tatsächlichen Namen auf. 2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausführt und direkt davor/danach wörtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. »Es regnet.«"). @@ -430,16 +873,46 @@ ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv): FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS: - speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge. - type: 'narration' oder 'dialogue' -- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen. +- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen). - emotion: Bei Dialogen 1-2 deutsche Wörter, die den Tonfall beschreiben (z.B. wütend, flüsternd, ängstlich). Bei Narration leer lassen (''). STRIKTE FORMAT- UND TEXTREGELN: - Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt."). - »Text?« und »Text!« sind vollständige Dialoge — das ?« bzw. !« schließt das Zitat ab, auch wenn es ungewohnt aussieht. -- Wenn ein Auszug mit einem offenen »-Zitat endet (kein schließendes «), behandle den Text ab » bis Textende als 'dialogue'. -- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!`; +- PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("flüsterte er", "sagte sie", "rief Uriens") oder normale Erzählerhandlung weitergeht. +- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. "Der Tod trägt rot. «"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text. +- Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'. +- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!`; - const globalPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT; + const normalizeCastingPrompt = (prompt) => { + let p = prompt || AB_DEFAULT_PROMPT; + p = p.replace( + "- Wenn ein Auszug mit einem offenen »-Zitat endet (kein schließendes «), behandle den Text ab » bis Textende als 'dialogue'.", + "- PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt (\"flüsterte er\", \"sagte sie\", \"rief Uriens\") oder normale Erzählerhandlung weitergeht.\n- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.\n- Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'." + ); + p = p.replace( + "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen.", + "- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen)." + ); + p = p.replace( + "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!", + "- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!" + ); + if (!/GRAMMATIK-REGELN FÜR NARRATION/.test(p)) { + p = p.replace( + 'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).', + 'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nGRAMMATIK-REGELN FÜR NARRATION:\n- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").\n- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.\n- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.\n- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.' + ); + } + return p; + }; + + const rawPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT; + const globalPrompt = normalizeCastingPrompt(rawPrompt); + if (globalPrompt !== rawPrompt && typeof _appSettings !== 'undefined') { + _appSettings.audiobook_prompt = globalPrompt; + fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: globalPrompt }) }).catch(() => {}); + } panel.querySelector('#ab-cv-prompt-text').value = globalPrompt; const closePanel = () => { @@ -507,6 +980,28 @@ STRIKTE FORMAT- UND TEXTREGELN: }); })(); + // Collapse the character sidebar to just avatar dots when space is tight, + // or you just want more room for the script itself. + (function () { + const side = panel.querySelector('#ab-cv-side'); + const body = panel.querySelector('.ab-cv-body'); + const cBtn = panel.querySelector('#ab-cv-side-collapse'); + function apply(collapsed) { + if (side) side.classList.toggle('is-collapsed', collapsed); + if (body) body.classList.toggle('side-collapsed', collapsed); + if (cBtn) cBtn.querySelector('.mdi').className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left'); + if (cBtn) cBtn.title = collapsed ? 'Expand character list' : 'Collapse to avatars'; + } + let collapsed = false; + try { collapsed = localStorage.getItem('ttsvc_ab_side_collapsed') === '1'; } catch (_) {} + apply(collapsed); + if (cBtn) cBtn.addEventListener('click', () => { + collapsed = !collapsed; + try { localStorage.setItem('ttsvc_ab_side_collapsed', collapsed ? '1' : '0'); } catch (_) {} + apply(collapsed); + }); + })(); + // Prompt Library logic let savedPrompts = []; try { savedPrompts = JSON.parse(localStorage.getItem('ttsvc_ab_prompts') || '[]'); } catch (_) { savedPrompts = []; } @@ -598,8 +1093,10 @@ STRIKTE FORMAT- UND TEXTREGELN: .then(r => r.json()) .then(d => { if (d.models && d.models.length) { + const preferred = audiobookSafeLlmModel(oldVal || defaultModel); sel.innerHTML = d.models.map(m => ``).join(''); - if (oldVal && d.models.includes(oldVal)) sel.value = oldVal; + if (preferred && d.models.includes(preferred)) sel.value = preferred; + else if (oldVal && d.models.includes(oldVal) && !audiobookIsRouterModel(oldVal)) sel.value = oldVal; else if (d.models.includes(defaultModel)) sel.value = defaultModel; } else { sel.innerHTML = ``; @@ -615,12 +1112,12 @@ STRIKTE FORMAT- UND TEXTREGELN: const applyPromptAndRun = (callback) => { const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; - const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); - const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; + const choice = audiobookCurrentCastLlm(panel); + const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) }) .finally(() => { - if (callback) callback(currentUrl, currentModel); + if (callback) callback(savedChoice.url, savedChoice.model); }); }; @@ -648,14 +1145,55 @@ STRIKTE FORMAT- UND TEXTREGELN: const fill = panel.querySelector('#ab-cv-fill'), count = panel.querySelector('#ab-cv-count'); const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars'); const roster = new Map(); // name -> { count, color } - const colorFor = name => { - if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] }); + const characterRecords = new Map(); // lower-case name -> character-library record + const AB_ALIAS_MAX_TOKENS = 12; + const AB_ALIAS_MAX_CHARS = 500; + const splitIdentityTokens = (v, opts = {}) => { + const raw = _abStr(v); + const parts = raw + .split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i) + .map(x => x.trim()) + .filter(Boolean) + .filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x)); + if (opts.aliases && (raw.length > AB_ALIAS_MAX_CHARS || parts.length > AB_ALIAS_MAX_TOKENS)) return []; + return parts.slice(0, opts.aliases ? AB_ALIAS_MAX_TOKENS : undefined); + }; + const identityNames = (recOrSheet) => { + const s = recOrSheet?.sheet || recOrSheet || {}; + const out = new Set(); + const add = (v, opts = {}) => splitIdentityTokens(v, opts).forEach(x => out.add(x)); + add(recOrSheet?.name || s.name); + ['aliases', 'first_name', 'last_name', 'full_name', 'title'].forEach(k => add(s[k], { aliases: k === 'aliases' })); + return [...out]; + }; + const registerCharacterRecord = (rec) => { + if (!rec?.name) return; + for (const n of identityNames(rec)) characterRecords.set(n.toLowerCase(), rec); + }; + const recordForName = (name) => characterRecords.get(String(name || '').toLowerCase()) || null; + const colorFor = (name, rec) => { + const key = String(name || '').toLowerCase(); + const stored = rec || characterRecords.get(key); + const color = stored ? _abRecordColor(stored, name) : _abDefaultCharacterColor(name); + if (!roster.has(name)) roster.set(name, { count: 0, color }); + else roster.get(name).color = color; return roster.get(name).color; }; + const setCharacterColor = (name, color) => { + const safe = _abNormalizeColor(color, name); + const info = roster.get(name); + if (info) info.color = safe; + const key = String(name || '').toLowerCase(); + const rec = characterRecords.get(key); + if (rec) { rec.color = safe; if (rec.sheet) rec.sheet.color = safe; } + return safe; + }; const highlightText = (text) => { if (!text) return ''; let html = escHtml(text); - const names = [...roster.keys()] + const extraNames = []; + new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec))); + const names = [...new Set([...roster.keys(), ...extraNames])] .filter(n => n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n)) .sort((a, b) => b.length - a.length); for (const name of names) { @@ -676,15 +1214,37 @@ STRIKTE FORMAT- UND TEXTREGELN: const feedWrap = panel.querySelector('.ab-cv-feed-wrap'); const jumpBtn = panel.querySelector('#ab-cv-jump-btn'); - // Insert bar above the feed (once) + // Compact toolbar above the feed: selected character, edit history, page nav. + const _abTopbar = document.createElement('div'); + _abTopbar.className = 'ab-cv-topbar'; + feedWrap.insertBefore(_abTopbar, feedWrap.firstChild); + const _abBar = document.createElement('div'); _abBar.className = 'ab-char-bar'; _abBar.hidden = true; - feedWrap.insertBefore(_abBar, feedWrap.firstChild); + _abTopbar.appendChild(_abBar); + + const _abEditToolbar = document.createElement('div'); + _abEditToolbar.className = 'ab-edit-toolbar'; + _abEditToolbar.innerHTML = ` + + `; + _abTopbar.appendChild(_abEditToolbar); + + const _abPageNav = document.createElement('div'); + _abPageNav.className = 'ab-page-nav'; + _abPageNav.hidden = true; + _abPageNav.innerHTML = ` + + + + `; + _abTopbar.appendChild(_abPageNav); let _abCharRows = []; // rows in feed for selected character let _abNavIdx = 0; let _abDetailEl = null; // full profile panel (when Profil is open) + let _abCurrentPageNum = 1; const _abClearHL = () => { feed.querySelectorAll('.ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus') @@ -707,6 +1267,7 @@ STRIKTE FORMAT- UND TEXTREGELN: _abClearHL(); _abCharRows = []; chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.remove('is-active')); + if (typeof _abSyncTopbar === 'function') _abSyncTopbar(); }; const _abNav = (dir, pool) => { @@ -720,6 +1281,28 @@ STRIKTE FORMAT- UND TEXTREGELN: if (pos) pos.textContent = (_abNavIdx + 1) + ' / ' + rows.length; }; + const _abRefreshCharacterColors = (name) => { + const names = name ? [name] : [...roster.keys()]; + for (const n of names) colorFor(n); + feed.querySelectorAll('.ab-cv-row').forEach(row => { + const seg = row.__seg; + if (!seg) return; + const isNarrator = seg.type !== 'dialogue' || !seg.speaker || seg.speaker.toLowerCase() === 'narrator'; + const speakerName = isNarrator ? 'Narrator' : seg.speaker; + const c = colorFor(speakerName); + const spk = row.querySelector('.ab-cv-spk'); + const txt = row.querySelector('.ab-cv-txt'); + if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c; + if (txt) txt.innerHTML = highlightText(seg.text || ''); + }); + renderRoster(); + const selected = _abBar.hidden ? null : _abBar.dataset.charName; + if (selected) { + const dot = _abBar.querySelector('.ab-char-bar-dot'); + if (dot) dot.style.background = colorFor(selected); + } + }; + // Open the full character profile (hides feed) const _abShowProfile = async (name) => { _abCloseProfile(); @@ -734,7 +1317,8 @@ STRIKTE FORMAT- UND TEXTREGELN: const all = typeof clGetAllByTagOrBook === 'function' ? await clGetAllByTagOrBook(title) : (typeof clGetAll === 'function' ? await clGetAll() : []); - rec = all.find(r => (r.name || '').toLowerCase() === name.toLowerCase()); + for (const r of all || []) registerCharacterRecord(r); + rec = recordForName(name); } catch (_) {} const detail = document.createElement('div'); @@ -748,9 +1332,11 @@ STRIKTE FORMAT- UND TEXTREGELN: detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile); return; } + registerCharacterRecord(rec); const sh = rec.sheet || {}; - const hue = _abCharHue(rec.name), hue2 = (hue + 40) % 360; + const charColor = setCharacterColor(rec.name, rec.color || sh.color || colorFor(rec.name, rec)); + const accent2 = _abHslToHex((_abCharHue(rec.name) + 40) % 360, 58, 30); const tier = String(sh.tier || '').toLowerCase(); const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur'; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; @@ -758,7 +1344,7 @@ STRIKTE FORMAT- UND TEXTREGELN: const arcMap = { 'good-to-bad':'↘ Entwicklung zum Bösen','bad-to-good':'↗ Wandel zum Guten','complex':'↕ Komplex','stable-good':'→ Stabil gut','stable-bad':'→ Stabil böse','neutral':'→ Neutral' }; const avatarHtml = rec.image ? `
${escHtml(rec.name)}
` - : `
${escHtml((rec.name||'?')[0].toUpperCase())}
`; + : `
${escHtml((rec.name||'?')[0].toUpperCase())}
`; // field(label, value, sheet-key) — key enables editing when pencil is active const field = (lbl, val, sk) => { const v = _abStr(val); @@ -776,10 +1362,12 @@ STRIKTE FORMAT- UND TEXTREGELN: detail.innerHTML = ` -
+
${avatarHtml}
${escHtml(rec.name)}
+ ${_abStr(sh.full_name) && _abStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? `
${escHtml(_abStr(sh.full_name))}
` : ''} + ${_abStr(sh.title) ? `
${escHtml(_abStr(sh.title))}
` : ''} ${_abStr(sh.aliases) ? `
auch bekannt als ${escHtml(_abStr(sh.aliases))}
` : ''} ${_abStr(sh.archetype) ? `
${escHtml(_abStr(sh.archetype))}
` : ''}
${tierLabel}
@@ -791,9 +1379,11 @@ STRIKTE FORMAT- UND TEXTREGELN: ${voiceId ? escHtml(voiceId) : 'Noch keine Stimme'} +
${pct != null ? `
Böse
Gut
${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)
` : ''}
+
${field('Voller Name',sh.full_name,'full_name')}${field('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Titel / Rolle',sh.title,'title')}${field('Auch bekannt als',sh.aliases,'aliases')}
${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}
${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}
${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}
@@ -805,6 +1395,20 @@ STRIKTE FORMAT- UND TEXTREGELN: feedWrap.appendChild(detail); detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile); + detail.querySelector('.ab-cd-color input')?.addEventListener('input', async e => { + const color = setCharacterColor(rec.name, e.target.value); + rec.color = color; + if (!rec.sheet) rec.sheet = {}; + rec.sheet.color = color; + detail.querySelector('.lcd-header')?.style.setProperty('--lc1', color); + const av = detail.querySelector('.lcd-avatar'); + if (av && !av.querySelector('img')) av.style.background = color; + _abRefreshCharacterColors(rec.name); + clearTimeout(_audiobook._colorSaveTimer); + _audiobook._colorSaveTimer = setTimeout(async () => { + try { if (typeof clPut === 'function') await clPut(rec); } catch (_) {} + }, 400); + }); detail.querySelector('.ab-cd-pick')?.addEventListener('click', () => { if (typeof _openVoicePicker === 'function') _openVoicePicker(detail.querySelector('.lcd-voice-top'), rec, async () => { @@ -836,6 +1440,7 @@ STRIKTE FORMAT- UND TEXTREGELN: _saveTimer = setTimeout(async () => { if (!rec.sheet) rec.sheet = {}; rec.sheet[el.dataset.sk] = el.textContent.trim(); + if (['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(el.dataset.sk)) registerCharacterRecord(rec); if (typeof clPut === 'function') await clPut(rec); }, 900); }); @@ -874,11 +1479,12 @@ STRIKTE FORMAT- UND TEXTREGELN: ); _abNavIdx = 0; - const hue = _abCharHue(name); + const color = colorFor(name); _abBar.dataset.charName = name; _abBar.hidden = false; + _abSyncTopbar(); _abBar.innerHTML = - `${(name||'?')[0].toUpperCase()} + `${(name||'?')[0].toUpperCase()}
${escHtml(name)} ${_abCharRows.length} Zeilen @@ -933,10 +1539,10 @@ STRIKTE FORMAT- UND TEXTREGELN: const items = [...roster.entries()].filter(([, info]) => info.count > 0).sort((a, b) => b[1].count - a[1].count); if (!items.length) { chars.innerHTML = 'reading…'; return; } const prev = _abBar.hidden ? null : _abBar.dataset.charName; - chars.innerHTML = items.map(([n]) => { - const hue = _abCharHue(n); + chars.innerHTML = items.map(([n, info]) => { + const color = info.color || colorFor(n); return `
- ${escHtml((n||'?')[0].toUpperCase())} + ${escHtml((n||'?')[0].toUpperCase())} ${escHtml(n)} ${roster.get(n)?.count||0}
`; @@ -945,7 +1551,26 @@ STRIKTE FORMAT- UND TEXTREGELN: item.addEventListener('click', () => _abSelectChar(item.dataset.name)); }); }; - const MAXROWS = 600; // keep most of the book visible; only trim when user is at bottom + + (async () => { + const title = window.readerState?.title || ''; + try { + const records = typeof clGetAllByTagOrBook === 'function' + ? await clGetAllByTagOrBook(title) + : (typeof clGetAll === 'function' ? await clGetAll() : []); + for (const rec of records || []) { + if (!rec?.name) continue; + registerCharacterRecord(rec); + for (const alias of identityNames(rec)) { + const rosterName = [...roster.keys()].find(n => n.toLowerCase() === alias.toLowerCase()); + if (rosterName) setCharacterColor(rosterName, _abRecordColor(rec, rec.name)); + } + } + _abRefreshCharacterColors(); + } catch (_) {} + })(); + + const MAXROWS = Number.POSITIVE_INFINITY; // keep the full completed cast available for review/page navigation // Whether the user has scrolled up to review/edit earlier content. // While true: auto-scroll and trim are suppressed so their position is preserved. let _userScrolled = false; @@ -954,6 +1579,15 @@ STRIKTE FORMAT- UND TEXTREGELN: const atBottom = feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 80; _userScrolled = !atBottom; if (jumpBtn) jumpBtn.hidden = atBottom; + const labels = Array.from(feed.querySelectorAll('.ab-cv-page-label[data-page]')); + let current = _abCurrentPageNum; + for (const label of labels) { + const r = label.closest('.ab-cv-page')?.getBoundingClientRect(); + const fr = feed.getBoundingClientRect(); + if (r && r.top <= fr.top + 80) current = Number(label.dataset.page) || current; + else break; + } + if (current) _abSetCurrentPage(current); }, { passive: true }); const _scrollToBottom = () => { feed.scrollTop = feed.scrollHeight; }; if (jumpBtn) { @@ -965,10 +1599,327 @@ STRIKTE FORMAT- UND TEXTREGELN: } const trim = () => { if (_userScrolled) return; // don't touch the feed while user is reading/editing - while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); + let rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); + while (rows.length > MAXROWS) { + const first = rows[0]; + const page = first.closest('.ab-cv-page'); + first.remove(); + if (page && !page.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) page.remove(); + rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider'); + } _scrollToBottom(); }; + // Group casting feed content into per-page cards (white "paper" on the feed's + // canvas background) so long books read visually like a paginated document + // instead of one unbroken scroll. pagemark() starts a new card; everything + // else appends into whichever card is currently open. + let _abCurPage = null; + const _abJumpToReaderPage = async (pageNum) => { + const pg = parseInt(pageNum, 10); + if (!pg) return; + if (typeof window.readerJumpToPage === 'function') { + await window.readerJumpToPage(pg); + return; + } + if (typeof navTo === 'function') navTo('s-reader'); + if (typeof window.navReaderView === 'function') window.navReaderView('main'); + else if (typeof window.showReaderView === 'function') window.showReaderView('main'); + setTimeout(async () => { + const pageDiv = window.readerState?.pages?.[pg - 1]?.pageDiv; + if (pageDiv) { + if (typeof window.readerRenderPage === 'function') await window.readerRenderPage(pg - 1); + pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); + } + else if (typeof toast === 'function') toast('Source page is not loaded in Reader yet', 'info'); + }, 250); + }; + const _abNewPage = (label, pageNum) => { + // Drop the previous card if a page boundary produced no content at all + // (e.g. a blank page), so we don't leave an empty white box in the feed. + if (_abCurPage && !_abCurPage.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) _abCurPage.remove(); + const p = document.createElement('div'); + p.className = 'ab-cv-page'; + if (label) { + const pageAttr = pageNum ? ` data-page="${escHtml(String(pageNum))}"` : ''; + p.innerHTML = ``; + } + feed.appendChild(p); + _abCurPage = p; + if (pageNum) { + _abSetCurrentPage(pageNum); + _abUpdatePageNav(); + } + return p; + }; + const _abPage = () => _abCurPage || _abNewPage(); + const _abPageNumbers = () => { + const set = new Set(); + const readerPages = window.readerState?.mode === 'pdf' ? (window.readerState?.pages?.length || 0) : 0; + if (readerPages > 0) { + for (let i = 1; i <= readerPages; i++) set.add(i); + } else { + (_audiobook.pageMarks || []).forEach(m => set.add((m.page || 0) + 1)); + } + feed.querySelectorAll('.ab-cv-page-label[data-page]').forEach(el => { + const n = parseInt(el.dataset.page, 10); + if (n) set.add(n); + }); + return [...set].sort((a, b) => a - b); + }; + const _abSetCurrentPage = (pageNum) => { + const pg = parseInt(pageNum, 10); + if (!pg) return; + _abCurrentPageNum = pg; + const sel = _abPageNav.querySelector('.ab-page-select'); + if (sel && [...sel.options].some(o => Number(o.value) === pg)) sel.value = String(pg); + }; + const _abUpdatePageNav = () => { + const pages = _abPageNumbers(); + const sel = _abPageNav.querySelector('.ab-page-select'); + if (!sel || !pages.length) { _abPageNav.hidden = true; if (typeof _abSyncTopbar === 'function') _abSyncTopbar(); return; } + const old = Number(sel.value) || _abCurrentPageNum || pages[0]; + sel.innerHTML = pages.map(n => ``).join(''); + _abPageNav.hidden = pages.length <= 1; + _abSetCurrentPage(pages.includes(old) ? old : pages[0]); + if (typeof _abSyncTopbar === 'function') _abSyncTopbar(); + }; + const _abScrollToCastPage = (pageNum) => { + const pg = parseInt(pageNum, 10); + if (!pg) return; + let label = feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`); + if (!label) { + const active = _abActiveSegments(); + if (active.arr.length) _abRedrawSegments(active.arr); + label = feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`); + } + if (pg === 1) { + _userScrolled = true; + if (jumpBtn) jumpBtn.hidden = false; + const firstPage = label?.closest('.ab-cv-page') || feed.firstElementChild; + if (firstPage) firstPage.scrollIntoView({ behavior: 'smooth', block: 'start' }); + else feed.scrollTo({ top: 0, behavior: 'smooth' }); + _abSetCurrentPage(pg); + return; + } + if (!label) { + toast('This cast page is not available yet. Use the book button to jump to the source page.', 'info'); + _abSetCurrentPage(pg); + return; + } + _userScrolled = true; + if (jumpBtn) jumpBtn.hidden = false; + label.closest('.ab-cv-page')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + _abSetCurrentPage(pg); + }; + const _abStepPage = (dir) => { + const pages = _abPageNumbers(); + if (!pages.length) return; + const current = Number(_abPageNav.querySelector('.ab-page-select')?.value) || _abCurrentPageNum || pages[0]; + const idx = Math.max(0, pages.indexOf(current)); + const next = pages[Math.max(0, Math.min(pages.length - 1, idx + dir))]; + _abScrollToCastPage(next); + }; + _abPageNav.querySelector('.ab-page-select')?.addEventListener('change', e => _abScrollToCastPage(e.target.value)); + _abPageNav.querySelector('.ab-page-prev')?.addEventListener('click', () => _abStepPage(-1)); + _abPageNav.querySelector('.ab-page-next')?.addEventListener('click', () => _abStepPage(1)); + _abPageNav.querySelector('.ab-page-source')?.addEventListener('click', () => { + const pg = Number(_abPageNav.querySelector('.ab-page-select')?.value) || _abCurrentPageNum; + _abJumpToReaderPage(pg); + }); + + const _abUndoStack = []; + const _abRedoStack = []; + const _abHistoryLimit = 40; + const _abSyncTopbar = () => { + const hasSelection = !_abBar.hidden; + const hasHistory = _abUndoStack.length > 0 || _abRedoStack.length > 0; + const hasPages = !_abPageNav.hidden; + _abEditToolbar.hidden = !hasHistory; + _abTopbar.hidden = !(hasSelection || hasHistory || hasPages); + }; + const _abActiveSegments = () => { + if (_audiobook.running && Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments }; + if (Array.isArray(_audiobook.segments)) return { key: 'segments', arr: _audiobook.segments }; + if (Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments }; + return { key: 'segments', arr: [] }; + }; + const _abCloneSegments = (segs) => (segs || []).map(s => ({ ...s })); + const _abSyncRosterList = (segs) => { + const names = []; + for (const s of segs || []) { + const sp = s?.type === 'dialogue' && s.speaker ? s.speaker : ''; + if (!sp || /^Narrator$/i.test(sp) || /^Unknown|Unbekannt/i.test(sp)) continue; + if (!names.some(n => n.toLowerCase() === sp.toLowerCase())) names.push(sp); + } + _audiobook.roster = names; + }; + const _abPersistManualEdit = () => { + const active = _abActiveSegments(); + if (_audiobook.lastText) { + const done = _audiobook.completedChunks || active.arr.length; + const total = _audiobook.completedTotal || done; + setTimeout(() => _abSaveDraft(active.arr || [], _audiobook.roster || [], _audiobook.lastText, done, total), 50); + } + _audiobookDebouncedSave(); + }; + const _abUpdateEditButtons = () => { + const undo = _abEditToolbar.querySelector('.ab-edit-undo'); + const redo = _abEditToolbar.querySelector('.ab-edit-redo'); + if (undo) undo.disabled = !_abUndoStack.length; + if (redo) redo.disabled = !_abRedoStack.length; + _abSyncTopbar(); + }; + const _abPushEditState = (keyOverride, arrOverride) => { + const active = arrOverride ? { key: keyOverride || 'segments', arr: arrOverride } : _abActiveSegments(); + if (!active.arr.length) return false; + _abUndoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) }); + while (_abUndoStack.length > _abHistoryLimit) _abUndoStack.shift(); + _abRedoStack.length = 0; + _abUpdateEditButtons(); + return true; + }; + const _abRowSpeaker = (s) => { + const isNarrator = s.type !== 'dialogue' || !s.speaker || String(s.speaker).toLowerCase() === 'narrator'; + return { isNarrator, speakerName: isNarrator ? 'Narrator' : s.speaker }; + }; + const _abRowFromSegment = (s, extraClass = '') => { + const { isNarrator, speakerName } = _abRowSpeaker(s); + const c = colorFor(speakerName); + const row = document.createElement('div'); + row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}`; + row.__seg = s; + row.innerHTML = ` + + + + + ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''} + ${highlightText(s.text || '')}`; + // No per-row listeners here on purpose — with 1000+ segments in a big book, + // attaching 2 extra listeners per row on every redraw (merge/split rebuilds + // the whole feed) was measurably slower. Editing is wired once via event + // delegation on `feed` instead (see _abStartRowEdit and its callers below). + return row; + }; + // Swap a row's text for a textarea (edit button click or double-click), + // delegated from a single feed-level listener instead of per-row ones. + const _abStartRowEdit = (row) => { + if (!row || !row.__seg || row.querySelector('.ab-cv-edit-ta')) return; + const s = row.__seg; + const txtEl = row.querySelector('.ab-cv-txt'); + if (!txtEl) return; + const ta = document.createElement('textarea'); + ta.className = 'ab-cv-edit-ta'; + ta.value = s.text || ''; + txtEl.replaceWith(ta); + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + const commit = (save) => { + if (save) { + const val = ta.value; + if (val !== s.text) { + _abPushEditState(); + s.text = val; + _abPersistManualEdit(); + } + } + ta.replaceWith(txtEl); + txtEl.innerHTML = highlightText(s.text || ''); + }; + ta.addEventListener('keydown', e => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commit(true); } + else if (e.key === 'Escape') { e.preventDefault(); commit(false); } + }); + ta.addEventListener('blur', () => commit(true)); + }; + const _abRecountRoster = (segs) => { + for (const info of roster.values()) info.count = 0; + for (const s of segs || []) { + const { speakerName } = _abRowSpeaker(s || {}); + const c = colorFor(speakerName); + if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c }); + roster.get(speakerName).count++; + } + _abSyncRosterList(segs || []); + renderRoster(); + }; + const _abRedrawSegments = (segs) => { + const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : ''; + feed.innerHTML = ''; + _abCurPage = null; + // Segments cast since the .page-stamping fix carry their own page number — + // use that directly instead of re-guessing offsets from text, which used to + // silently merge pages whenever a segment's text didn't substring-match + // exactly (LLM cleanup, dehyphenation, short lines) after navigating away + // from the casting panel and back. Older drafts without a .page field fall + // back to the previous offset-matching approach. + const hasPageField = (segs || []).some(s => s.page != null); + if (hasPageField) { + let lastPage = null; + for (const s of segs || []) { + if (s.page != null && s.page !== lastPage) { + _abNewPage('Page ' + s.page, s.page); + lastPage = s.page; + } + _abPage().appendChild(_abRowFromSegment(s)); + } + } else { + const marks = (_audiobook.pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0)); + const src = _audiobook.lastText || ''; + let markIdx = 0, searchPos = 0; + if (marks.length && marks[0].offset <= 2) { + _abNewPage('Page ' + (marks[0].page + 1), marks[0].page + 1); + markIdx = 1; + } + for (const s of segs || []) { + if (src && markIdx < marks.length) { + const probe = String(s.text || '').trim().slice(0, 24); + const at = probe ? src.indexOf(probe, searchPos) : -1; + const pos = at >= 0 ? at : searchPos; + if (at >= 0) searchPos = at + probe.length; + while (markIdx < marks.length && pos >= marks[markIdx].offset) { + _abNewPage('Page ' + (marks[markIdx].page + 1), marks[markIdx].page + 1); + markIdx++; + } + } + _abPage().appendChild(_abRowFromSegment(s)); + } + } + _abRecountRoster(segs || []); + _abClearHL(); + _abUpdatePageNav(); + if (selectedChar) { + _abBar.hidden = true; + _abSelectChar(selectedChar); + } + }; + const _abRestoreEditState = (snap) => { + if (!snap) return; + const restored = _abCloneSegments(snap.segments); + if (snap.key === 'liveSegments') _audiobook.liveSegments = restored; + else _audiobook.segments = restored; + _abRedrawSegments(restored); + _abPersistManualEdit(); + }; + const _abUndoEdit = () => { + const active = _abActiveSegments(); + if (!_abUndoStack.length || !active.arr.length) return; + _abRedoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) }); + _abRestoreEditState(_abUndoStack.pop()); + _abUpdateEditButtons(); + }; + const _abRedoEdit = () => { + const active = _abActiveSegments(); + if (!_abRedoStack.length || !active.arr.length) return; + _abUndoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) }); + _abRestoreEditState(_abRedoStack.pop()); + _abUpdateEditButtons(); + }; + _abEditToolbar.querySelector('.ab-edit-undo')?.addEventListener('click', _abUndoEdit); + _abEditToolbar.querySelector('.ab-edit-redo')?.addEventListener('click', _abRedoEdit); + _abSyncTopbar(); + // Manual character assignment logic let assignModeSeg = null; let assignModeRow = null; @@ -1060,6 +2011,8 @@ STRIKTE FORMAT- UND TEXTREGELN: function assignName(name) { if (!assignModeSeg) return; + const active = _abActiveSegments(); + _abPushEditState(active.key, active.arr); const isNarrator = name.toLowerCase() === 'narrator'; // Decrement old speaker's count before reassigning const _oldSpk = assignModeSeg.speaker; @@ -1081,17 +2034,88 @@ STRIKTE FORMAT- UND TEXTREGELN: assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(speakerName)}${assignModeSeg.emotion ? ' (' + escHtml(assignModeSeg.emotion) + ')' : ''}`; assignModeRow.querySelector('.ab-cv-spk').style.color = c; assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character'; - renderRoster(); + _abRecountRoster(_abActiveSegments().arr); toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success'); closeAssignPopup(); - // Persist manual corrections to both localStorage (fast) and IndexedDB (durable) - if (_audiobook.lastText) { - setTimeout(() => _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1), 50); - } - _audiobookDebouncedSave(); + // Persist manual corrections to both localStorage (fast) and IndexedDB (durable). + // Reuse the real done/total the cast last reached — an interrupted cast must + // stay reported (and resumable) as interrupted, not flip to "100% done" just + // because a speaker got manually reassigned. + _abPersistManualEdit(); } + const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker); + const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker); + const _abJoinSegmentText = (a, b) => { + const left = String(a || ''); + const right = String(b || ''); + if (!left) return right; + if (!right) return left; + if (/\s$/.test(left) || /^\s/.test(right)) return left + right; + if (/^[,.;:!?»«”"')\]]/.test(right)) return left + right; + if (/[([{„“"']$/.test(left)) return left + right; + return left + ' ' + right; + }; + const _abMergedSegment = (a, b) => { + let base = a; + if (_abIsUnknownSeg(a) && !_abIsUnknownSeg(b)) base = b; + else if (_abIsNarrSeg(a) && !_abIsNarrSeg(b)) base = b; + const type = _abIsNarrSeg(base) ? 'narration' : 'dialogue'; + const speaker = type === 'narration' ? 'Narrator' : (base.speaker || 'Unknown'); + return { + speaker, + type, + emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '', + text: _abJoinSegmentText(a.text || '', b.text || ''), + }; + }; + const _abMergeByRow = (row, dir) => { + const active = _abActiveSegments(); + const idx = active.arr.indexOf(row?.__seg); + if (idx < 0) return; + const leftIdx = dir < 0 ? idx - 1 : idx; + const rightIdx = dir < 0 ? idx : idx + 1; + if (leftIdx < 0 || rightIdx >= active.arr.length) { + toast('No adjacent segment to merge', 'info'); + return; + } + _abPushEditState(active.key, active.arr); + const merged = _abMergedSegment(active.arr[leftIdx], active.arr[rightIdx]); + active.arr.splice(leftIdx, 2, merged); + if (active.key === 'segments') _audiobook.segments = active.arr; + else _audiobook.liveSegments = active.arr; + _abRedrawSegments(active.arr); + _abPersistManualEdit(); + toast('Segments merged', 'success'); + }; + + feed.addEventListener('dblclick', e => { + const txt = e.target.closest('.ab-cv-txt'); + if (txt) _abStartRowEdit(txt.closest('.ab-cv-row')); + }); + feed.addEventListener('click', e => { + const pageLabel = e.target.closest('.ab-cv-page-label[data-page]'); + if (pageLabel) { + e.preventDefault(); + e.stopPropagation(); + _abJumpToReaderPage(pageLabel.dataset.page); + return; + } + const editBtn = e.target.closest('.ab-cv-edit-text'); + if (editBtn) { + e.preventDefault(); + e.stopPropagation(); + _abStartRowEdit(editBtn.closest('.ab-cv-row')); + return; + } + const mergeBtn = e.target.closest('.ab-cv-row-tool'); + if (mergeBtn) { + e.preventDefault(); + e.stopPropagation(); + _abMergeByRow(mergeBtn.closest('.ab-cv-row'), mergeBtn.classList.contains('ab-cv-merge-prev') ? -1 : 1); + return; + } const spk = e.target.closest('.ab-cv-spk'); if (spk) { const row = spk.closest('.ab-cv-row'); @@ -1166,7 +2190,8 @@ STRIKTE FORMAT- UND TEXTREGELN: const row = txtSpan.closest('.ab-cv-row'); if (!row || !row.__seg) return; - const text = sel.toString().trim(); + const rawText = sel.toString(); + const text = rawText.trim(); if (text.length > 0) { // If we are in assign mode and they selected a short text, it's for assigning a name. Don't show split. if (assignModeSeg && text.length < 40 && !text.includes('\n')) { @@ -1178,7 +2203,7 @@ STRIKTE FORMAT- UND TEXTREGELN: splitBtn.style.display = 'block'; splitBtn.style.top = (rect.bottom + 8) + 'px'; splitBtn.style.left = Math.max(10, rect.left + (rect.width / 2) - 100) + 'px'; - currentSplitState = { row, text, seg: row.__seg }; + currentSplitState = { row, text, rawText, seg: row.__seg }; } else { splitBtn.style.display = 'none'; currentSplitState = null; @@ -1187,13 +2212,18 @@ STRIKTE FORMAT- UND TEXTREGELN: splitBtn.addEventListener('click', () => { if (!currentSplitState) return; - const { row, text, seg } = currentSplitState; + const { row, text, rawText, seg } = currentSplitState; const fullText = seg.text || ''; - const idx = fullText.indexOf(text); + let selectedText = rawText || text; + let idx = fullText.indexOf(selectedText); + if (idx === -1) { + selectedText = text; + idx = fullText.indexOf(selectedText); + } if (idx === -1) return; - const before = fullText.substring(0, idx).trim(); - const after = fullText.substring(idx + text.length).trim(); + const before = fullText.substring(0, idx); + const after = fullText.substring(idx + selectedText.length); // Try committed segments first, then live array (during active casting) let arr = _audiobook.segments || []; @@ -1202,32 +2232,25 @@ STRIKTE FORMAT- UND TEXTREGELN: arr = _audiobook.liveSegments; globalIdx = arr.indexOf(seg); } + if (globalIdx !== -1) _abPushEditState(arr === _audiobook.liveSegments ? 'liveSegments' : 'segments', arr); const newSegs = []; - if (before) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before }); - newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: text }); - if (after) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after }); + if (before.length) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before, page: seg.page }); + newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: selectedText, page: seg.page }); + if (after.length) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after, page: seg.page }); if (globalIdx !== -1) { arr.splice(globalIdx, 1, ...newSegs); + _abRedrawSegments(arr); + _abPersistManualEdit(); } else { // DOM-only split — will be reconciled once casting finishes console.warn('[audiobook] split during casting: DOM-only, segment not yet in array'); + const frag = document.createDocumentFragment(); + for (const s of newSegs) frag.appendChild(_abRowFromSegment(s)); + row.parentNode.insertBefore(frag, row); + row.remove(); } - - const frag = document.createDocumentFragment(); - for (const s of newSegs) { - const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; - const speakerName = isNarrator ? 'Narrator' : s.speaker; - const c = colorFor(speakerName); - const newRow = document.createElement('div'); - newRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); - newRow.__seg = s; - newRow.innerHTML = `${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; - frag.appendChild(newRow); - } - row.parentNode.insertBefore(frag, row); - row.remove(); - + splitBtn.style.display = 'none'; window.getSelection().removeAllRanges(); toast('Segment split! You can now assign a character to the Unknown block.', 'success'); @@ -1276,6 +2299,12 @@ STRIKTE FORMAT- UND TEXTREGELN: }); return { + rebuild(segs) { + this.clearProcessing(); + feed.querySelector('.ab-skel-feed')?.remove(); + chars.querySelector('.ab-skel-chars')?.remove(); + _abRedrawSegments(segs || []); + }, update(done) { const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = false; @@ -1307,7 +2336,7 @@ STRIKTE FORMAT- UND TEXTREGELN: if (icon) icon.className = full.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up'; }); } - feed.appendChild(this._procRow); + _abPage().appendChild(this._procRow); trim(); }, clearProcessing() { @@ -1319,15 +2348,10 @@ STRIKTE FORMAT- UND TEXTREGELN: chars.querySelector('.ab-skel-chars')?.remove(); const makeRow = (s) => { - const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; - const speakerName = isNarrator ? 'Narrator' : s.speaker; - const c = colorFor(speakerName); + const { speakerName } = _abRowSpeaker(s); + colorFor(speakerName); roster.get(speakerName).count++; - const row = document.createElement('div'); - row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); - row.__seg = s; - row.innerHTML = `${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; - return row; + return _abRowFromSegment(s); }; const CHUNK = 80; @@ -1335,7 +2359,7 @@ STRIKTE FORMAT- UND TEXTREGELN: const first = segs.slice(0, CHUNK); const frag0 = document.createDocumentFragment(); first.forEach(s => frag0.appendChild(makeRow(s))); - feed.appendChild(frag0); + _abPage().appendChild(frag0); renderRoster(); // Stream remaining chunks without blocking the main thread @@ -1346,7 +2370,7 @@ STRIKTE FORMAT- UND TEXTREGELN: const batch = document.createDocumentFragment(); const end = Math.min(i + CHUNK, segs.length); for (; i < end; i++) batch.appendChild(makeRow(segs[i])); - feed.appendChild(batch); + _abPage().appendChild(batch); renderRoster(); setTimeout(next, 0); }; @@ -1355,7 +2379,7 @@ STRIKTE FORMAT- UND TEXTREGELN: trim(); } }, - note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); }, + note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; _abPage().appendChild(r); trim(); }, // Visual gap marking that the passages before/after are not contiguous. // prevIdx / nextIdx are indices into _audiobook.segments for the gap boundaries. // Click expands the divider to show all segments in the gap inline. @@ -1411,14 +2435,14 @@ STRIKTE FORMAT- UND TEXTREGELN: } r.replaceWith(frag); }); - feed.appendChild(r); trim(); + _abPage().appendChild(r); trim(); }, // Page-break marker in the casting feed (from source PDF page boundaries). + // Starts a new "paper" card so each source page reads as its own block. pagemark(pageNum) { - const r = document.createElement('div'); - r.className = 'ab-cv-pagemark'; - r.innerHTML = ` Page ${pageNum}`; - feed.appendChild(r); trim(); + this.clearProcessing(); + _abNewPage('Page ' + pageNum, pageNum); + trim(); }, // Park the panel in a "done" state with a Review button instead of auto-popping // the preview — so it waits for you if you wandered off to do something else. @@ -1426,38 +2450,53 @@ STRIKTE FORMAT- UND TEXTREGELN: if (count) count.hidden = true; const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = true; - if (typeof allSegments !== 'undefined' && allSegments.length) { - feed.innerHTML = ''; - const frag = document.createDocumentFragment(); - for (const s of allSegments) { - const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; - const speakerName = isNarrator ? 'Narrator' : s.speaker; - const c = colorFor(speakerName); - const row = document.createElement('div'); - row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); - row.__seg = s; - row.innerHTML = `${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; - frag.appendChild(row); - } - feed.appendChild(frag); - } + const completedSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length + ? _audiobook.segments + : (typeof allSegments !== 'undefined' && allSegments.length ? allSegments : null); + if (completedSegments) _abRedrawSegments(completedSegments); const cancelBtn = panel.querySelector('#ab-cv-cancel'); if (cancelBtn) cancelBtn.hidden = true; const foot = panel.querySelector('#ab-cv-foot'); foot.hidden = false; - foot.innerHTML = ` ${escHtml(summary)}`; + // The "N characters · M segments" summary lives under the character + // sidebar (not the action-button footer) so it reads next to the list + // it's summarising. + const sideEl = panel.querySelector('.ab-cv-side'); + if (sideEl) { + let doneEl = sideEl.querySelector('.ab-cv-side-done'); + if (!doneEl) { + doneEl = document.createElement('div'); + doneEl.className = 'ab-cv-side-done'; + sideEl.appendChild(doneEl); + } + doneEl.innerHTML = ` ${escHtml(summary)}`; + } + // An interrupted cast (crash/reload) leaves completedChunks < completedTotal — + // offer to pick up from there instead of only full/partial recasts. + const resumable = _audiobook.completedChunks > 0 && _audiobook.completedTotal > 0 + && _audiobook.completedChunks < _audiobook.completedTotal; + const continueBtnHtml = resumable + ? `` + : ''; + foot.innerHTML = `${continueBtnHtml}`; const runVerificationPass = () => { - const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt und jedem Segment einen Sprecher zugewiesen. Deine Aufgabe ist es JEDEN Sprecher-Zuweisung kritisch zu überprüfen und zu korrigieren. + const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern. -AUFGABE (2. Qualitätslauf — Verifizierung): -1. ÜBERPRÜFE jede Sprecher-Zuweisung: Ist der angegebene Sprecher wirklich derjenige, der spricht? Nutze den Kontext (Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, das Ping-Pong-Prinzip zwischen Sprechern, und den Gesamtkontext der Szene). -2. KORRIGIERE falsch zugewiesene Sprecher mit dem korrekten Namen direkt aus dem Text. -3. LÖSE alle 'Unknown'-Segmente auf: Nutze umgebenden Kontext, die Reihenfolge der Sprecher und textliche Hinweise. 'Unknown' ist NUR dann erlaubt, wenn der Sprecher absolut nicht bestimmbar ist. -4. BEHALTE alle korrekten Zuweisungen exakt unverändert bei. +AUFGABE (2. Qualitätslauf): +1. LÖSE 'Unknown'-Segmente auf: Nutze umgebenden Kontext, Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, Reihenfolge der Sprecher, Ping-Pong-Wechsel in Dialogen und bekannte Figuren. +2. KORRIGIERE ein Segment zu 'Narrator' nur dann, wenn es eindeutig Erzählertext, Handlung, Beschreibung oder ein Sprecher-Tag ist. +3. BEHALTE vorhandene klare Sprecher-Zuweisungen im Kontext bei. Nutze sie als Anker für die Unknown-Zeilen. +4. 'Unknown' ist NUR erlaubt, wenn der Sprecher trotz Kontext absolut nicht bestimmbar ist. -DEFINITION VON DIALOG (KRITISCH): -Text ist NUR 'dialogue', wenn er in Anführungszeichen steht (»...«, „...", "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE ist 'narration' (speaker: 'Narrator'). +DIALOG-ERKENNUNG BEI PDF/OCR-TEXTEN: +Viele PDF-Extraktionen verlieren Anführungszeichen oder Guillemets. Ein kurzer Satz kann also trotzdem Dialog sein, auch wenn »...« oder „..." im übergebenen Segment fehlen. Entscheide nach Satzform, Antwortstruktur, Sprecherwechsel, Inquit-Formeln und Szene. Markiere eine Zeile NICHT allein deshalb als Narration, weil sichtbare Anführungszeichen fehlen. + +GRAMMATIK-CHECK FÜR NARRATION: +- Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name. +- Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration. +- Action Beats sind narration: blickte, ging, schwieg, lachte, hob die Hand, wandte sich ab, usw. +- Nur die tatsächlich gesprochenen Wörter innerhalb der Anführungszeichen bleiben dialogue; alle grammatischen Rahmen- und Berichtssätze sind narration. FÜR JEDES SEGMENT AUSGABE: - speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters. @@ -1467,46 +2506,44 @@ FÜR JEDES SEGMENT AUSGABE: ABSOLUTE REGELN: - Alle Segmente zusammen MÜSSEN den Originaltext exakt, lückenlos und wortgetreu rekonstruieren. +- PDF-/OCR-SCHUTZ: Ein fehlendes » oder « darf niemals bewirken, dass die restliche Passage als Dialog markiert wird. Bei einem offenen »-Zitat vor einer Inquit-Formel oder Erzählerhandlung endet der Dialog am ersten plausiblen Satzende (? ! .). Bei einem einzelnen schließenden « nach einem kurzen Satz ist dieser Satz davor der Dialog. - Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu. - Mische NIEMALS Narration und Dialog in einem Segment.`; - panel.querySelector('#ab-cv-prompt-text').value = verificationPrompt; - foot.querySelector('#ab-cv-recast-unk').click(); + const choice = audiobookCurrentCastLlm(panel); + const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); + closePanel(); + audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt }); }; foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass); foot.querySelector('#ab-cv-open-reh').addEventListener('click', async () => { - // Ensure the record is saved, then open it in Script Rehearser - if (!_audiobook.rehId) await audiobookSaveAsRehearsal({ silent: true }); - if (_audiobook.rehId && typeof window.rehDbGetById === 'function' && typeof window.rehLoadRecord === 'function') { - try { - const rec = await window.rehDbGetById(_audiobook.rehId); - if (rec) { - closePanel(); - window.rehLoadRecord(rec); - if (typeof navTo === 'function') navTo('s-rehearser'); - return; - } - } catch (_) {} - } - // Fallback: open rehearser via the old preview path - closePanel(); onOpen(); + closePanel(); + await audiobookOpenCurrentInRehearser(); }); + foot.querySelector('#ab-cv-export-md')?.addEventListener('click', audiobookExportCastMd); const applyPromptAndRun = (callback) => { const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; - const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); - const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; + const choice = audiobookCurrentCastLlm(panel); + const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model); if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) }) .finally(() => { closePanel(); - if (callback) callback(currentUrl, currentModel); + if (callback) callback(savedChoice.url, savedChoice.model); }); }; foot.querySelector('#ab-cv-recast').addEventListener('click', () => applyPromptAndRun(audiobookCast)); foot.querySelector('#ab-cv-recast-unk').addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown)); + foot.querySelector('#ab-cv-continue')?.addEventListener('click', () => applyPromptAndRun((u, m) => audiobookCast(u, m, { + startIndex: _audiobook.completedChunks, + segments: _audiobook.segments, + roster: _audiobook.roster, + narrationOnly: _audiobook.narratedPassages, + degraded: _audiobook.degraded + }))); foot.querySelector('#ab-cv-cast-chars').addEventListener('click', () => { if (typeof window.csForReader === 'function') window.csForReader(); else if (typeof csForReader === 'function') csForReader(); @@ -1527,8 +2564,8 @@ ABSOLUTE REGELN: chars.querySelector('.ab-skel-chars')?.remove(); const message = options.message || 'Casting stopped before any passage completed.'; - if (!feed.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-pagemark')) { - feed.innerHTML = ''; + if (!feed.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page')) { + feed.innerHTML = ''; _abCurPage = null; const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = message; @@ -1546,14 +2583,17 @@ ABSOLUTE REGELN: const foot = panel.querySelector('#ab-cv-foot'); const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0; foot.hidden = false; - foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; + foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel); - foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', audiobookShowPreview); + foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', async () => { + closePanel(); + await audiobookOpenCurrentInRehearser(); + }); foot.querySelector('#ab-cv-stopped-recast')?.addEventListener('click', () => { const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value; - const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); - const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; + const currentChoice = audiobookCurrentCastLlm(panel); + const choice = audiobookSaveLlmChoice(currentChoice.url, currentChoice.model); if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', @@ -1561,7 +2601,7 @@ ABSOLUTE REGELN: body: JSON.stringify({ audiobook_prompt: newPrompt || '' }) }).finally(() => { closePanel(); - audiobookCast(currentUrl, currentModel); + audiobookCast(choice.url, choice.model); }); }); @@ -1569,30 +2609,63 @@ ABSOLUTE REGELN: if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, // Show fresh "Ready to cast" state (used when server draft fetch returns empty) - setFreshState() { + loadingRestore() { feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); + feed.innerHTML = ''; + _abCurPage = null; + const r = document.createElement('div'); + r.className = 'ab-cv-note'; + r.textContent = 'Loading saved cast…'; + feed.appendChild(r); + chars.innerHTML = 'restoring…'; const statusMsg = panel.querySelector('#ab-cv-status-msg'); - if (statusMsg) statusMsg.style.display = 'inline-block'; + if (statusMsg) { + statusMsg.style.display = 'inline-block'; + statusMsg.textContent = 'Checking saved cast before starting a new one.'; + } + const castBtn = panel.querySelector('#ab-cv-start-cast'); + if (castBtn) castBtn.style.display = 'none'; + }, + setFreshState(message = 'Ready to cast.') { + feed.querySelector('.ab-skel-feed')?.remove(); + chars.querySelector('.ab-skel-chars')?.remove(); + if (!feed.querySelector('.ab-cv-row, .ab-cv-divider, .ab-cv-page')) { + feed.innerHTML = ''; + _abCurPage = null; + const r = document.createElement('div'); + r.className = 'ab-cv-note'; + r.textContent = message; + feed.appendChild(r); + } + if (!chars.querySelector('.ab-char-item')) chars.innerHTML = 'No saved cast found.'; + const statusMsg = panel.querySelector('#ab-cv-status-msg'); + if (statusMsg) { + statusMsg.style.display = 'inline-block'; + statusMsg.textContent = message; + } const castBtn = panel.querySelector('#ab-cv-start-cast'); if (castBtn) { castBtn.style.display = 'inline-block'; castBtn.addEventListener('click', () => { const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value; - const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); - const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; + const currentChoice = audiobookCurrentCastLlm(panel); + const choice = audiobookSaveLlmChoice(currentChoice.url, currentChoice.model); if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt; - audiobookCast(currentUrl, currentModel); + audiobookCast(choice.url, choice.model); }); } }, }; } -async function audiobookRecastUnknown(overrideUrl, overrideModel) { +async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) { if (_audiobook.running) return; const segs = _audiobook.segments; if (!segs || !segs.length) return; + const originalSegments = segs.map(s => ({...s})); + const countUnknownDialogue = arr => (arr || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length; + const beforeUnknownCount = countUnknownDialogue(segs); const unknownIdxs = []; for (let i = 0; i < segs.length; i++) { @@ -1612,8 +2685,10 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) { if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null; const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang(); - let model = overrideModel || audiobookLlmModel(); + let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel()); + const promptOverride = typeof options.prompt === 'string' ? options.prompt : null; + const groups = audiobookRecastGroups(unknownIdxs, segs); const view = audiobookCastView(unknownIdxs.length, llm_url, model); view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…'); @@ -1623,7 +2698,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) { body: JSON.stringify({ text: 'Wake up.', known_characters: [], recent: '', language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, - model: document.getElementById('ab-cv-llm-select')?.value || model, + model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS) }) }, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000); @@ -1640,26 +2715,56 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) { let done = 0; let prevIdx = -2; + let groupsSinceSave = 0; + const pendingReplacements = new Map(); + const normalizeRecastSegment = (seg, fallback) => { + const type = seg?.type === 'narration' ? 'narration' : 'dialogue'; + const speaker = type === 'narration' ? 'Narrator' : (seg?.speaker || fallback?.speaker || 'Unknown'); + return { + speaker, + type, + emotion: type === 'dialogue' ? (seg?.emotion || fallback?.emotion || '') : '', + text: seg?.text || fallback?.text || '' + }; + }; try { - for (let i of unknownIdxs) { + for (let group of groups) { if (_audiobook.cancel) break; - const targetSeg = segs[i]; + + const unresolvedGroup = []; + for (const idx of group) { + if (audiobookIsSpeechTagOnly(segs[idx]?.text)) { + segs[idx].type = 'narration'; + segs[idx].speaker = 'Narrator'; + segs[idx].emotion = ''; + } else { + unresolvedGroup.push(idx); + } + } + if (!unresolvedGroup.length) { + if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]); + prevIdx = group[group.length - 1]; + view.update(done, `Correcting narration tags ${done + 1}-${done + group.length} / ${unknownIdxs.length}…`); + for (const idx of group) { + view.addSegments([segs[idx]]); + done++; + } + continue; + } + group = unresolvedGroup; // These Unknown lines come from scattered places in the document. Mark a // gap with "⋯" whenever this passage isn't directly after the previous one. - if (i !== prevIdx + 1) view.divider(prevIdx, i); - prevIdx = i; + if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]); + prevIdx = group[group.length - 1]; - const start = Math.max(0, i - 12); - const end = Math.min(segs.length, i + 6); - const contextSegs = segs.slice(start, end); - - const passageText = contextSegs.map(s => { - if (s.type === 'narration') return s.text; - return `"${s.text}"`; - }).join(' '); + const recastCtx = audiobookRecastContext(segs, group); + const passageText = recastCtx.text; + const lineLabel = group.length > 1 + ? `Attributing lines ${done + 1}-${done + group.length} / ${unknownIdxs.length}…` + : `Attributing line ${done + 1} / ${unknownIdxs.length}…`; - view.update(done++, `Attributing line ${done} / ${unknownIdxs.length}…`); + view.update(done, lineLabel); view.processing(passageText.trim()); let data = null; @@ -1667,33 +2772,67 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) { const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, - body: JSON.stringify({ + body: JSON.stringify(audiobookAttributeBody({ text: passageText.trim(), known_characters: _audiobook.roster.slice(-40), - recent: '', + recent: recastCtx.recent || '', language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, - model: document.getElementById('ab-cv-llm-select')?.value || model, + model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS) - }) + }, promptOverride)) }, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); } data = await r.json(); } catch (err) { if (err.name === 'AbortError') { _audiobook.cancel = true; break; } - view.note(`API Error: ${err.message}`); + view.note(`API Error while checking ${group.length > 1 ? 'a group of Unknown lines' : 'Unknown line'}: ${err.message}`); + for (const idx of group) { + view.addSegments([segs[idx]]); + done++; + } continue; } if (data && data.segments) { - const match = data.segments.find(s => s.type === 'dialogue' && targetSeg.text.includes(s.text.slice(0, 15))); - if (match && match.speaker && !/^Unknown|Unbekannt/i.test(match.speaker)) { - targetSeg.speaker = match.speaker; - if (match.emotion) targetSeg.emotion = match.emotion; - if (!_audiobook.roster.includes(match.speaker)) _audiobook.roster.push(match.speaker); + const used = new Set(); + for (const idx of group) { + const targetSeg = segs[idx]; + const seqMatch = audiobookFindReturnedSegmentSequence(targetSeg, data.segments, used); + if (seqMatch && seqMatch.segs.length > 1) { + const repl = seqMatch.segs.map(s => normalizeRecastSegment(s, targetSeg)).filter(s => (s.text || '').trim()); + const hasResolved = repl.some(s => s.type === 'narration' || (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker))); + const hasUnresolvedDialogue = repl.some(s => s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))); + if (repl.length && hasResolved && !hasUnresolvedDialogue) { + seqMatch.idxs.forEach(i => used.add(i)); + pendingReplacements.set(idx, repl); + repl.forEach(s => { + if (s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker) && !_audiobook.roster.includes(s.speaker)) _audiobook.roster.push(s.speaker); + }); + continue; + } + } + const match = audiobookFindReturnedSegment(targetSeg, data.segments, used); + if (match && match.seg.speaker) { + const speaker = match.seg.type === 'narration' ? 'Narrator' : match.seg.speaker; + if (!speaker || /^Unknown|Unbekannt/i.test(speaker)) continue; + used.add(match.idx); + targetSeg.type = match.seg.type === 'narration' ? 'narration' : 'dialogue'; + targetSeg.speaker = speaker; + targetSeg.emotion = targetSeg.type === 'dialogue' ? (match.seg.emotion || targetSeg.emotion || '') : ''; + if (targetSeg.type === 'dialogue' && !_audiobook.roster.includes(speaker)) _audiobook.roster.push(speaker); + } } } - view.addSegments([targetSeg]); + for (const idx of group) { + view.addSegments(pendingReplacements.get(idx) || [segs[idx]]); + done++; + } + groupsSinceSave++; + if (groupsSinceSave >= 10 && _audiobook.lastText) { + groupsSinceSave = 0; + _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0); + } } view.update(_audiobook.cancel ? done : unknownIdxs.length); } catch (e) { @@ -1702,20 +2841,39 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) { _audiobook.running = false; _audiobook.abort = null; } + if (pendingReplacements.size) { + [...pendingReplacements.entries()] + .sort((a, b) => b[0] - a[0]) + .forEach(([idx, repl]) => segs.splice(idx, 1, ...repl)); + } + + const afterUnknownCount = countUnknownDialogue(segs); + if (!_audiobook.cancel && afterUnknownCount > beforeUnknownCount) { + segs.splice(0, segs.length, ...originalSegments); + view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`); + toast('Quality run rolled back because it increased Unknown speakers.', 'error'); + } + // Recast-unknown only fixes speakers on already-cast segments — it never adds + // new passages, so the original cast's real done/total must carry through + // unchanged rather than being overwritten with a fake "100% done" value. + const _rcDone = _audiobook.completedChunks || segs.length; + const _rcTotal = _audiobook.completedTotal || _rcDone; + if (_audiobook.cancel) { - if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1); + if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal); view.done({ stopped: true, message: 'Recast stopped. Existing cast preserved.' }); toast('Recast stopped. Existing cast preserved.', 'info'); return; } - + + if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal); const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); } -function audiobookOpenCastView() { +async function audiobookOpenCastView() { if (_audiobook.running) return; const text = audiobookScopeText(); if (!text) { toast('Import a document first', 'error'); return; } @@ -1739,33 +2897,40 @@ function audiobookOpenCastView() { // 1. In-memory restore — user navigated away and came back in the same session if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) { const view = audiobookCastView(chunks.length, llm_url, model, false); - view.addSegments(_audiobook.segments); + view.rebuild(_audiobook.segments); const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); - view.update(chunks.length); return; } // Helper: apply a draft object into the view const _applyDraft = (draft, view, source) => { + const draftDone = Number.isFinite(Number(draft.done)) ? Number(draft.done) : 0; + const draftTotal = Number.isFinite(Number(draft.total)) ? Number(draft.total) : 0; _audiobook.segments = draft.segments; _audiobook.roster = draft.roster || []; _audiobook.lastText = text; _audiobook.pageMarks = draft.pageMarks || []; _audiobook.rehId = draft.rehId || null; - view.addSegments(draft.segments); + // Always keep the raw progress the original cast reached, even once it's + // finished or a later edit re-saves the draft — later saves (manual speaker + // fixes, recast-unknown) must not clobber this with a "looks 100%" sentinel, + // or an interrupted cast becomes silently unresumable and misreported as done. + _audiobook.completedChunks = draftDone > 0 ? draftDone : 0; + _audiobook.completedTotal = draftTotal > 0 ? draftTotal : 0; + view.rebuild(draft.segments); const ageMs = Date.now() - (draft.savedAt || 0); const ageMins = Math.round(ageMs / 60000); const ageStr = ageMins < 1 ? 'gerade eben' : ageMins < 60 ? `vor ${ageMins} Min.` : `vor ${Math.round(ageMins/60)} Std.`; - const pct = draft.total > 0 ? Math.round((draft.done / draft.total) * 100) : 100; - const wasDone = draft.done === draft.total || draft.done < 0; + const pct = draftTotal > 0 ? Math.max(0, Math.min(100, Math.round((draftDone / draftTotal) * 100))) : 100; + const wasDone = draftTotal > 0 && draftDone >= draftTotal; + const canContinue = draftDone > 0 && draftTotal > 0 && draftDone < draftTotal; const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt'; - view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${!wasDone ? ' Casting wurde unterbrochen — „Recast all" für komplette Neuauswertung.' : ''}`); + view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Recast all" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`); const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); - view.update(draft.done > 0 ? draft.done : chunks.length); }; // 2. localStorage draft restore — fastest, no network @@ -1775,9 +2940,14 @@ function audiobookOpenCastView() { _applyDraft(_localDraft, view, 'local'); // Backfill server in case it's missing this draft if (_abBookId()) { + const serverCopy = { + ..._localDraft, + bookId: _abBookId(), + title: window.readerState?.title || _localDraft.title || '', + }; fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`, { method: 'PUT', headers: {'Content-Type':'application/json'}, - body: JSON.stringify(_localDraft) + body: JSON.stringify(serverCopy) }).catch(() => {}); } return; @@ -1787,16 +2957,19 @@ function audiobookOpenCastView() { const bookId = _abBookId(); if (bookId) { const view = audiobookCastView(chunks.length, llm_url, model, false); - _abLoadDraftServer(bookId).then(serverDraft => { + view.loadingRestore(); + try { + const serverDraft = await _abLoadDraftServer(bookId); if (serverDraft && serverDraft.segments && serverDraft.segments.length > 0) { // Cache locally so next open is instant try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch(_) {} _applyDraft(serverDraft, view, 'server'); } else { - // No draft anywhere — show fresh state - view.setFreshState(); + view.setFreshState('No saved cast was found for this saved book. Starting a new cast will create a new draft.'); } - }).catch(() => view.setFreshState()); + } catch (_) { + view.setFreshState('Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.'); + } return; } @@ -1804,27 +2977,46 @@ function audiobookOpenCastView() { audiobookCastView(chunks.length, llm_url, model, true); } -async function audiobookCast(overrideUrl, overrideModel) { +async function audiobookCast(overrideUrl, overrideModel, resume) { if (_audiobook.running) return; const text = audiobookScopeText(); if (!text) { toast('Import a document first', 'error'); return; } if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; } + _audiobook.running = true; + const hadServerAutosaveTarget = !!_abBookId(); + if (!hadServerAutosaveTarget) { + const ready = await _abEnsureLibraryBook(); + if (!ready && typeof toast === 'function') { + toast('Autosave will use this browser only until the book is saved to the library.', 'info'); + } + } + const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS) : [text]; - _audiobook.running = true; _audiobook.cancel = false; - _audiobook.rehId = null; // fresh cast creates a new rehearsal record - _abClearDraft(); + // Resuming an interrupted cast: pick up at the passage the autosave stopped + // on instead of reprocessing everything from passage 1. Chunking is a pure + // function of the text, so the same text always re-splits into the same + // chunk boundaries, making the saved passage index safe to resume at. + const startIndex = (resume && resume.startIndex > 0 && resume.startIndex < chunks.length) ? resume.startIndex : 0; + const isResume = startIndex > 0; + + _audiobook.cancel = false; + if (!isResume) { + _audiobook.rehId = null; // fresh cast creates a new rehearsal record + _abClearDraft(); + } if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true); const ac = new AbortController(); _audiobook.abort = () => ac.abort(); if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null; const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang(); - let model = overrideModel || audiobookLlmModel(); - const view = audiobookCastView(chunks.length, llm_url, model); + let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel()); + const view = audiobookCastView(chunks.length, llm_url, model, false); + if (isResume && resume.segments && resume.segments.length) view.rebuild(resume.segments); view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…'); try { @@ -1833,7 +3025,7 @@ async function audiobookCast(overrideUrl, overrideModel) { body: JSON.stringify({ text: 'Wake up.', known_characters: [], recent: '', language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, - model: document.getElementById('ab-cv-llm-select')?.value || model, + model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS) }) }, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000); @@ -1848,29 +3040,45 @@ async function audiobookCast(overrideUrl, overrideModel) { } } - const allSegments = []; + const allSegments = isResume ? resume.segments.slice() : []; _audiobook.liveSegments = allSegments; // expose so split works during casting - const roster = []; - let narrationOnly = 0; // passages with no quotes at all — legitimately all narration - let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted - let completedChunks = 0; - // Page-mark tracking for visual page-break rows in the casting feed. + const roster = isResume ? (resume.roster || []).slice() : []; + let narrationOnly = isResume ? (resume.narrationOnly || 0) : 0; // passages with no quotes at all — legitimately all narration + let degraded = isResume ? (resume.degraded || 0) : 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted + let completedChunks = startIndex; + const saveCheckpoint = () => { + if (allSegments.length) _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length); + }; + _abStartDraftAutosave(saveCheckpoint); + // Page-mark tracking for visual page-break rows in the casting feed. Segments + // are stamped with the resolved page number as they're created (see _curPageNum + // below) so the feed can be redrawn later purely from that stored field instead + // of re-guessing offsets from text — re-guessing is what used to silently merge + // pages after navigating away from the casting panel and back. const _pgMarks = (_audiobook.pageMarks || []).slice(); if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0 - let _pgMarkIdx = 0, _pgCharPos = 0; + let _pgMarkIdx = 0, _pgCharPos = 0, _curPageNum = 1; + if (isResume) { + // Fast-forward the page-mark cursor past the passages already cast so + // page-break rows aren't re-emitted into the feed on resume. + for (let k = 0; k < startIndex; k++) _pgCharPos += chunks[k].length + 1; + while (_pgMarkIdx < _pgMarks.length && _pgMarks[_pgMarkIdx].offset <= _pgCharPos) _pgMarkIdx++; + if (_pgMarkIdx > 0) _curPageNum = _pgMarks[_pgMarkIdx - 1].page + 1; + } try { - for (let i = 0; i < chunks.length; i++) { + for (let i = startIndex; i < chunks.length; i++) { if (_audiobook.cancel) break; view.update(i); // Insert page-break markers in the feed when the source PDF page changes. while (_pgMarkIdx < _pgMarks.length && _pgCharPos >= _pgMarks[_pgMarkIdx].offset) { - view.pagemark(_pgMarks[_pgMarkIdx].page + 1); + _curPageNum = _pgMarks[_pgMarkIdx].page + 1; + view.pagemark(_curPageNum); _pgMarkIdx++; } _pgCharPos += chunks[i].length + 1; // +1 for joining space between chunks // No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error) if (!audiobookHasDialogue(chunks[i])) { - const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' }; + const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '', page: _curPageNum }; allSegments.push(seg); narrationOnly++; view.addSegments([seg]); completedChunks = i + 1; _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length); @@ -1887,7 +3095,7 @@ async function audiobookCast(overrideUrl, overrideModel) { const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, - body: JSON.stringify({ text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model, timeout_seconds: audiobookTimeoutSeconds(timeoutMs) }), + body: JSON.stringify({ text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) }), }, timeoutMs + 5000); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); } const d = await r.json(); @@ -1898,109 +3106,130 @@ async function audiobookCast(overrideUrl, overrideModel) { } }; - // ── First attempt ── - let result = await attributeChunk(chunks[i], recent); - let data = null; + // The whole per-chunk attribution + cleanup pipeline is guarded so that any + // unexpected error for ONE passage (bad response shape, network hiccup mid + // retry, etc.) degrades gracefully to quote-splitting instead of throwing an + // uncaught error that kills the entire cast loop and strands the panel in a + // broken, un-navigable state. Abort (Stop Casting) still propagates normally. + let segs; + try { + // ── First attempt ── + let result = await attributeChunk(chunks[i], recent); + let data = null; - if (result && !result.error) { - data = result; - } else if (result && result.error) { - // ── Retry: split the failing chunk in half and run both halves ── - view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`); - const half = Math.floor(chunks[i].length / 2); - const splitAt = chunks[i].lastIndexOf(' ', half) || half; - const chunkA = chunks[i].slice(0, splitAt).trim(); - const chunkB = chunks[i].slice(splitAt).trim(); - const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); - const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); + if (result && !result.error) { + data = result; + } else if (result && result.error) { + // ── Retry: split the failing chunk in half and run both halves ── + view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`); + const half = Math.floor(chunks[i].length / 2); + const splitAt = chunks[i].lastIndexOf(' ', half) || half; + const chunkA = chunks[i].slice(0, splitAt).trim(); + const chunkB = chunks[i].slice(splitAt).trim(); + const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); + const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); - const segsA = (resA && !resA.error) ? resA : null; - const segsB = (resB && !resB.error) ? resB : null; + const segsA = Array.isArray(resA) ? resA : null; + const segsB = Array.isArray(resB) ? resB : null; - if (segsA || segsB) { - data = [...(segsA || audiobookSplitByQuotes(chunkA)), ...(segsB || audiobookSplitByQuotes(chunkB))]; - if (!segsA) { degraded++; view.note(`⚠️ Passage ${i + 1} (first half) — auto-detected (retry also failed)`); } - if (!segsB) { degraded++; view.note(`⚠️ Passage ${i + 1} (second half) — auto-detected (retry also failed)`); } + if (segsA || segsB) { + const arrA = segsA || audiobookSplitByQuotes(chunkA); + const arrB = segsB || audiobookSplitByQuotes(chunkB); + data = [...arrA, ...arrB]; + if (!segsA) { degraded++; view.note(`⚠️ Passage ${i + 1} (first half) — auto-detected (retry also failed)`); } + if (!segsB) { degraded++; view.note(`⚠️ Passage ${i + 1} (second half) — auto-detected (retry also failed)`); } + } else { + // Both halves also failed + view.note(`❌ Passage ${i + 1} — LLM error: ${result.error}`); + data = null; + } + } + + segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []); + const hasDialogue = segs.some(s => s.type === 'dialogue'); + if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) { + // LLM completely unavailable — extract dialogue from quotes without speaker attribution + segs = audiobookSplitByQuotes(chunks[i]); + degraded++; + const named = segs.filter(s => s.type === 'dialogue' && !/^Unknown|Unbekannt/i.test(s.speaker)).length; + view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`); } else { - // Both halves also failed - view.note(`❌ Passage ${i + 1} — LLM error: ${result.error}`); - data = null; - } - } + // Clean up LLM hallucinations where it outputs the same text twice or leaves quotes in narration + let cleanedSegs = []; + for (let s of segs) { + s.text = s.text || ''; - let segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []); - const hasDialogue = segs.some(s => s.type === 'dialogue'); - if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) { - // LLM completely unavailable — extract dialogue from quotes without speaker attribution - segs = audiobookSplitByQuotes(chunks[i]); - degraded++; - const named = segs.filter(s => s.type === 'dialogue' && !/^Unknown|Unbekannt/i.test(s.speaker)).length; - view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`); - } else { - // Clean up LLM hallucinations where it outputs the same text twice or leaves quotes in narration - let cleanedSegs = []; - for (let s of segs) { - s.text = s.text || ''; - - // Strict validation: if LLM claims it's dialogue, but the original text has NO quotes around it, it's a hallucination. - if (s.type === 'dialogue' && s.text.trim().length > 10) { - const tText = s.text.trim(); - // Try to find the text in the original chunk to check its surroundings - const idx = chunks[i].indexOf(tText); - if (idx !== -1) { - const surround = chunks[i].slice(Math.max(0, idx - 8), idx) + chunks[i].slice(idx + tText.length, idx + tText.length + 8); - if (!/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)) { - s.type = 'narration'; - s.speaker = 'Narrator'; - s.emotion = ''; + if (s.type === 'dialogue' && audiobookIsSpeechTagOnly(s.text)) { + s.type = 'narration'; + s.speaker = 'Narrator'; + s.emotion = ''; + } + + // Strict validation: if LLM claims it's dialogue, but the original text has NO quotes around it, it's a hallucination. + if (s.type === 'dialogue' && s.text.trim().length > 10) { + const tText = s.text.trim(); + // Try to find the text in the original chunk to check its surroundings + const idx = chunks[i].indexOf(tText); + if (idx !== -1) { + const surround = chunks[i].slice(Math.max(0, idx - 8), idx) + chunks[i].slice(idx + tText.length, idx + tText.length + 8); + if (!/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)) { + s.type = 'narration'; + s.speaker = 'Narrator'; + s.emotion = ''; + } } } + + if (cleanedSegs.length > 0) { + let last = cleanedSegs[cleanedSegs.length - 1]; + if (s.text.trim() === last.text.trim()) { + if (s.type === 'dialogue' && last.type !== 'dialogue') { + cleanedSegs[cleanedSegs.length - 1] = s; continue; + } else if (s.type !== 'dialogue' && last.type === 'dialogue') { + continue; + } else { continue; } + } + if (s.type === 'dialogue' && last.type === 'narration') { + const tS = s.text.trim(), tL = last.text.trim(); + if (tL.endsWith(tS)) { + last.text = tL.slice(0, -tS.length).trim(); + if (!last.text) cleanedSegs.pop(); + } + } + } + if (s.text.trim()) cleanedSegs.push(s); } - if (cleanedSegs.length > 0) { - let last = cleanedSegs[cleanedSegs.length - 1]; - if (s.text.trim() === last.text.trim()) { - if (s.type === 'dialogue' && last.type !== 'dialogue') { - cleanedSegs[cleanedSegs.length - 1] = s; continue; - } else if (s.type !== 'dialogue' && last.type === 'dialogue') { + // Merge consecutive narration segments to preserve paragraph flow + let mergedSegs = []; + for (let s of cleanedSegs) { + if (mergedSegs.length > 0) { + let last = mergedSegs[mergedSegs.length - 1]; + if (s.type === 'narration' && last.type === 'narration' && (s.speaker || 'Narrator').toLowerCase() === 'narrator' && (last.speaker || 'Narrator').toLowerCase() === 'narrator') { + // Only add a newline if they don't already flow perfectly (e.g. LLM split mid-sentence) + // But usually we just join with double newline to preserve paragraphs, or single space if it's mid-sentence. + // A safe heuristic: if it ends with punctuation, use double newline (paragraph break). + if (/[.!?]$/.test(last.text.trim())) { + last.text = last.text.trimEnd() + '\n\n' + s.text.trimStart(); + } else { + last.text = last.text.trimEnd() + ' ' + s.text.trimStart(); + } continue; - } else { continue; } - } - if (s.type === 'dialogue' && last.type === 'narration') { - const tS = s.text.trim(), tL = last.text.trim(); - if (tL.endsWith(tS)) { - last.text = tL.slice(0, -tS.length).trim(); - if (!last.text) cleanedSegs.pop(); } } + mergedSegs.push(s); } - if (s.text.trim()) cleanedSegs.push(s); + segs = mergedSegs; } - - // Merge consecutive narration segments to preserve paragraph flow - let mergedSegs = []; - for (let s of cleanedSegs) { - if (mergedSegs.length > 0) { - let last = mergedSegs[mergedSegs.length - 1]; - if (s.type === 'narration' && last.type === 'narration' && (s.speaker || 'Narrator').toLowerCase() === 'narrator' && (last.speaker || 'Narrator').toLowerCase() === 'narrator') { - // Only add a newline if they don't already flow perfectly (e.g. LLM split mid-sentence) - // But usually we just join with double newline to preserve paragraphs, or single space if it's mid-sentence. - // A safe heuristic: if it ends with punctuation, use double newline (paragraph break). - if (/[.!?]$/.test(last.text.trim())) { - last.text = last.text.trimEnd() + '\n\n' + s.text.trimStart(); - } else { - last.text = last.text.trimEnd() + ' ' + s.text.trimStart(); - } - continue; - } - } - mergedSegs.push(s); - } - segs = mergedSegs; + } catch (chunkErr) { + if (chunkErr.name === 'AbortError') throw chunkErr; // propagate Stop Casting + degraded++; + segs = audiobookSplitByQuotes(chunks[i]); + view.note(`❌ Passage ${i + 1} — unexpected error (${chunkErr.message}) — auto-detected dialogue instead`); } // harvest speaker names (from LLM or tag heuristic) into the running roster segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); }); - segs.forEach(s => allSegments.push(s)); + segs.forEach(s => { s.page = _curPageNum; allSegments.push(s); }); view.addSegments(segs); completedChunks = i + 1; _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length); @@ -2011,9 +3240,15 @@ async function audiobookCast(overrideUrl, overrideModel) { _audiobook.cancel = true; view.note('Casting stopped. Completed passages were preserved.'); } else { - throw err; + // Surface unexpected errors instead of throwing an uncaught rejection — + // that used to strand the panel mid-cast with no way to recover short of + // reloading the whole app. Treat it like a stop: keep whatever completed + // and let the normal "stopped early" / recast flow take over below. + _audiobook.cancel = true; + view.note(`❌ Casting stopped — unexpected error: ${err.message}`); } } finally { + _abStopDraftAutosave(); _audiobook.running = false; _audiobook.abort = null; } @@ -2034,7 +3269,9 @@ async function audiobookCast(overrideUrl, overrideModel) { _audiobook.roster = roster; _audiobook.narratedPassages = narrationOnly; _audiobook.degraded = degraded; - _abSaveDraft(allSegments, roster, text, _audiobook.cancel ? completedChunks : chunks.length, chunks.length); + _audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length; + _audiobook.completedTotal = chunks.length; + _abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length); audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); @@ -2048,7 +3285,35 @@ async function audiobookCast(overrideUrl, overrideModel) { // ── Editable attribution preview ───────────────────────────────────────────── +async function audiobookOpenCurrentInRehearser() { + const segs = _audiobook.segments || []; + if (!segs.length) { toast('No cast to open', 'error'); return false; } + + await audiobookSaveAsRehearsal({ silent: true }); + + if (_audiobook.rehId && typeof window.rehDbGetById === 'function' && typeof window.rehLoadRecord === 'function') { + try { + const rec = await window.rehDbGetById(_audiobook.rehId); + if (rec) { + document.getElementById('audiobook-preview')?.remove(); + if (typeof navTo === 'function') navTo('s-rehearser'); + window.rehLoadRecord(rec); + toast('Opened cast in Script Rehearser', 'success'); + return true; + } + } catch (err) { + console.warn('[audiobook] failed to open saved rehearser record:', err); + } + } + + const { script, emotions } = audiobookBuildScript(segs); + await audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions); + return true; +} + function audiobookShowPreview() { + audiobookOpenCurrentInRehearser(); + return; const segs = _audiobook.segments || []; const roster = _audiobook.roster || []; document.getElementById('audiobook-preview')?.remove(); diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index f33d84d..9d542f4 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -51,10 +51,30 @@ function csRehearserText() { // ── Generation (chunked + merged by character) ─────────────────────────────── -const CS_SCALAR_FIELDS = ['aliases', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note', +const CS_SCALAR_FIELDS = ['aliases', 'first_name', 'last_name', 'full_name', 'title', 'archetype', 'physical', 'clothing', 'alignment', 'arc_note', 'attribute_high', 'attribute_low', 'skills', 'capabilities', 'backstory', 'relationships', 'motivation', 'fears', 'mannerisms', 'voice_pattern', - 'secret', 'conflict_style', 'win_condition']; + 'secret', 'conflict_style', 'win_condition', 'voice_design_prompt', 'image_prompt']; +const CS_DETAIL_FIELDS = CS_SCALAR_FIELDS.filter(f => !['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(f)); +const CS_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; +const CS_ALIAS_MAX_TOKENS = 12; +const CS_ALIAS_MAX_CHARS = 500; +const CS_SOURCE_FIELDS = { + physical: ['physical', 'appearance', 'body', 'look'], + clothing: ['clothing', 'appearance', 'armour', 'armor', 'item'], + relationships: ['relationship', 'ally', 'rival', 'enemy', 'family'], + motivation: ['motivation', 'intention', 'goal', 'desire'], + fears: ['fear', 'dread'], + mannerisms: ['mannerism', 'habit', 'gesture', 'voice'], + voice_pattern: ['voice', 'speech', 'dialogue'], + backstory: ['backstory', 'origin', 'history'], + alignment: ['alignment', 'ethos', 'morality'], + skills: ['skill', 'capability', 'ability'], + capabilities: ['capability', 'ability', 'combat', 'magic'], + secret: ['secret', 'flaw'], + conflict_style: ['conflict', 'fight', 'flight', 'manipulate'], + win_condition: ['win', 'goal'], +}; function csExistingSummary(map) { if (!map.size) return ''; @@ -64,7 +84,8 @@ function csExistingSummary(map) { return [...map.values()].slice(0, 40).map(s => { const missing = KEY_FIELDS.filter(f => !(s[f] || '').trim()); const suffix = missing.length < KEY_FIELDS.length ? ` | needs: ${missing.join(', ')}` : ' | complete'; - return `- ${s.name}${s.archetype ? ` (${s.archetype})` : ''}${suffix}`; + const aliases = [s.full_name, s.title, s.aliases].filter(Boolean).join(', '); + return `- ${s.name}${aliases ? ` aka ${aliases}` : ''}${s.archetype ? ` (${s.archetype})` : ''}${suffix}`; }).join('\n'); } @@ -75,18 +96,110 @@ function _csStr(v) { return JSON.stringify(v); } +function csSplitIdentityTokens(v, opts = {}) { + const raw = _csStr(v); + const parts = raw + .split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i) + .map(s => s.trim()) + .filter(Boolean) + .filter(s => s.length <= 80 && !/^needs?:/i.test(s) && !/^complete$/i.test(s)); + if (opts.aliases && (raw.length > CS_ALIAS_MAX_CHARS || parts.length > CS_ALIAS_MAX_TOKENS)) return []; + return parts.slice(0, opts.aliases ? CS_ALIAS_MAX_TOKENS : undefined); +} + +function csCleanRoster(names) { + const out = []; + const seen = new Set(); + (names || []).forEach(n => { + const name = _csStr(n).trim(); + const key = name.toLowerCase(); + if (!name || seen.has(key) || key === 'narrator' || /^unknown|unbekannt$/i.test(name)) return; + seen.add(key); + out.push(name); + }); + return out; +} + +function csKnownReaderRoster() { + const names = []; + const add = (name) => { + name = _csStr(name).trim(); + const key = name.toLowerCase(); + if (!name || key === 'narrator' || /^unknown|unbekannt$/i.test(name)) return; + if (!names.some(n => n.toLowerCase() === key)) names.push(name); + }; + const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook; + if (ab) { + (ab.roster || []).forEach(add); + [ab.segments, ab.liveSegments].forEach(segs => { + (segs || []).forEach(s => { + if (s?.type === 'dialogue') add(s.speaker); + }); + }); + } + document.querySelectorAll('#ab-cv-chars .ab-char-item[data-name], #audiobook-roster option[value]').forEach(el => { + add(el.dataset.name || el.value || el.textContent); + }); + return csCleanRoster(names); +} + +function csBlankSheet(name) { + return { + name, aliases: '', first_name: '', last_name: '', full_name: '', title: '', + archetype: '', physical: '', clothing: '', alignment: '', arc_note: '', + attribute_high: '', attribute_low: '', skills: '', capabilities: '', + backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', voice_pattern: '', + secret: '', conflict_style: '', win_condition: '', voice_design_prompt: '', image_prompt: '', + inventory: [], sources: [], moral_alignment_score: 50, arc_direction: 'neutral', tier: 'supporting', + }; +} + +function csNameTokens(sheet) { + const out = new Set(); + for (const f of CS_IDENTITY_FIELDS) { + const raw = f === 'name' ? sheet?.name : sheet?.[f]; + csSplitIdentityTokens(raw, { aliases: f === 'aliases' }).forEach(s => out.add(s.toLowerCase())); + } + return out; +} + +function csFindMergeKey(map, sheet) { + const incoming = csNameTokens(sheet); + if (!incoming.size) return (sheet.name || '').trim().toLowerCase(); + for (const [key, existing] of map.entries()) { + const existingNames = csNameTokens(existing); + for (const n of incoming) if (existingNames.has(n)) return key; + } + return (sheet.name || '').trim().toLowerCase(); +} + +function csMergeAliases(existing, incoming) { + const names = new Map(); + const add = (v) => csSplitIdentityTokens(v, { aliases: true }).forEach(s => names.set(s.toLowerCase(), s)); + add(existing.aliases); + add(incoming.aliases); + if (incoming.name && incoming.name !== existing.name) add(incoming.name); + return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', '); +} + function csMerge(map, sheets) { + const changed = []; for (const s of sheets) { const name = (s.name || '').trim(); if (!name) continue; - const key = name.toLowerCase(); + s.aliases = csMergeAliases({ name, aliases: '' }, s); + const key = csFindMergeKey(map, s); if (!map.has(key)) { const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }; CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); }); map.set(key, copy); + changed.push({ key, name: copy.name, status: 'new' }); continue; } const e = map.get(key); + const before = JSON.stringify(e); + const oldAliases = e.aliases; CS_SCALAR_FIELDS.forEach(f => { const sv = _csStr(s[f]); if (sv.length > (e[f] || '').length) e[f] = sv; }); + e.aliases = csMergeAliases({ ...e, aliases: oldAliases }, s); if (s.tier === 'main') e.tier = 'main'; if (s.moral_alignment_score != null) { e.moral_alignment_score = e.moral_alignment_score != null @@ -96,9 +209,26 @@ function csMerge(map, sheets) { if (s.arc_direction && s.arc_direction !== 'neutral') e.arc_direction = s.arc_direction; (s.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); }); (s.sources || []).forEach(src => { - if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); + if (src && src.quote && e.sources.length < 12 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); }); + if (JSON.stringify(e) !== before) changed.push({ key, name: e.name, status: 'updated' }); } + return changed; +} + +function csProgressSnapshot(map, changed = []) { + const changedByKey = new Map(changed.map(x => [x.key, x.status])); + return [...map.entries()].map(([key, s]) => { + const filled = CS_DETAIL_FIELDS.filter(f => _csStr(s[f]).trim()).length; + return { + key, + name: s.name || key, + alias: [s.full_name, s.title].filter(Boolean).join(' · '), + status: changedByKey.get(key) || '', + filled, + main: s.tier === 'main', + }; + }).sort((a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name)).slice(0, 18); } async function csGenerate(text, cacheKey, initialRoster) { @@ -108,10 +238,11 @@ async function csGenerate(text, cacheKey, initialRoster) { _cs.running = true; _cs.cancel = false; const prog = csProgress(chunks.length); const llm_url = csLlmUrl(), model = csLlmModel(); - let language = csLang(); - if ((!language || /^auto$/i.test(language)) && typeof detectLang === 'function') { + const selectedLanguage = csLang(); + let language = selectedLanguage; + if (typeof detectLang === 'function') { const detected = detectLang(text); - if (detected) { + if (detected && (!language || /^auto$/i.test(language) || (language.toLowerCase() === 'english' && detected.toLowerCase() !== 'english'))) { language = detected; const sel = $('reh-design-lang'); if (sel) { const o = [...sel.options].find(x => x.value.toLowerCase() === detected.toLowerCase() || x.textContent.toLowerCase() === detected.toLowerCase()); if (o) sel.value = o.value; } @@ -119,22 +250,23 @@ async function csGenerate(text, cacheKey, initialRoster) { } const map = new Map(); // Pre-seed the roster with known characters from the casting run so the LLM - // knows all character names from passage 1 and won't rediscover them. - const roster = initialRoster ? [...initialRoster] : []; - const knownTotal = roster.length || null; + // fills those profiles instead of treating the cast list as aliases to invent. + const roster = csCleanRoster(initialRoster); + const targetMode = roster.length > 0; + const knownTotal = targetMode ? roster.length : null; + roster.forEach(name => map.set(name.toLowerCase(), csBlankSheet(name))); try { for (let i = 0; i < chunks.length; i++) { if (_cs.cancel) break; - const detailed = [...map.values()].filter(s => CS_SCALAR_FIELDS.some(f => (s[f] || '').trim())).length; - const total = knownTotal || map.size; + const detailed = [...map.values()].filter(s => CS_DETAIL_FIELDS.some(f => (s[f] || '').trim())).length; const charLabel = knownTotal - ? `${detailed} / ${knownTotal} characters detailed` + ? `${knownTotal} cast characters queued · ${detailed} profiles with details` : `${map.size} characters found`; - prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel); + prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel, csProgressSnapshot(map)); try { const r = await fetch('/api/character-sheets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(0, 60), existing: csExistingSummary(map), language, llm_url, model }), + body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model }), }); if (!r.ok) { let detail = `HTTP ${r.status}`; @@ -145,8 +277,21 @@ async function csGenerate(text, cacheKey, initialRoster) { let data; try { data = JSON.parse(raw); } catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } - csMerge(map, data.sheets || []); - (data.characters || []).forEach(n => { if (!roster.includes(n)) roster.push(n); }); + const changed = csMerge(map, data.sheets || []); + if (!targetMode) { + (data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); + (data.sheets || []).forEach(s => { + csNameTokens(s).forEach(n => { + const display = [s.name, s.full_name, s.title, s.aliases].filter(Boolean).join(', ').split(/[,;/|]/).map(x => x.trim()).find(x => x.toLowerCase() === n) || n; + if (display && !roster.some(r => r.toLowerCase() === display.toLowerCase())) roster.push(display); + }); + }); + } + const newDetailed = [...map.values()].filter(s => CS_DETAIL_FIELDS.some(f => (s[f] || '').trim())).length; + const newCharLabel = knownTotal + ? `${knownTotal} cast characters queued · ${newDetailed} profiles with details` + : `${map.size} characters found`; + prog.update(i + 1, `Passage ${i + 1} / ${chunks.length}…`, newCharLabel, csProgressSnapshot(map, changed)); } catch (e) { console.error('Character sheets passage', i + 1, 'failed:', e); toast('Passage ' + (i + 1) + ' failed: ' + (e.message || String(e)), 'error'); @@ -179,10 +324,30 @@ function csProgress(total) { const msg = ov.querySelector('#cs-progress-msg'); const chars = ov.querySelector('#cs-progress-chars'); return { - update(d, label, charLabel) { + update(d, label, charLabel, roster = []) { if (fill) fill.style.width = (d / total * 100) + '%'; if (msg && label) msg.textContent = label; - if (chars && charLabel) chars.textContent = charLabel; + if (chars) { + const list = Array.isArray(roster) ? roster : []; + chars.innerHTML = ` +
${escHtml(charLabel || '')}
+ ${list.length ? `
+ ${list.map(c => ` +
+ ${escHtml((c.name || '?')[0].toUpperCase())} + + ${escHtml(c.name || 'Unknown')} + ${c.alias ? `${escHtml(c.alias)}` : ''} + + + + + ${c.status ? `${escHtml(c.status)}` : ''} +
+ `).join('')} +
` : ''} + `; + } }, done() { ov.style.display = 'none'; }, }; @@ -218,9 +383,9 @@ function csAlignmentBar(score, arcDirection, arcNote) { // ── Image & Voice prompt generators ────────────────────────────────────────── function csBuildImagePrompt(s) { + if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim(); const parts = []; if (s.archetype) parts.push(s.archetype); - if (s.gender) parts.push(`${s.gender} character`); if (s.physical) parts.push(s.physical); if (s.clothing) parts.push(s.clothing); if (s.alignment) parts.push(s.alignment); @@ -228,15 +393,15 @@ function csBuildImagePrompt(s) { parts.push(pct >= 70 ? 'benevolent expression' : pct <= 30 ? 'dark and menacing presence' : 'ambiguous expression'); if (s.arc_direction === 'bad-to-good') parts.push('redemptive aura'); if (s.arc_direction === 'good-to-bad') parts.push('ominous aura, turning to darkness'); - return `portrait of ${escHtml(s.name)}, ${parts.filter(Boolean).join(', ')}, fantasy character art, detailed face, dramatic lighting, high detail`; + return `Portrait of ${s.name}, ${parts.filter(Boolean).join(', ')}, fantasy character art, detailed face, dramatic lighting, high detail.`; } function csBuildVoicePrompt(s) { + if (_csStr(s.voice_design_prompt).trim()) return _csStr(s.voice_design_prompt).trim(); const parts = []; if (s.voice_pattern) parts.push(s.voice_pattern); if (s.mannerisms) parts.push(s.mannerisms); if (s.archetype) parts.push(`archetype: ${s.archetype}`); - if (s.gender) parts.push(`gender: ${s.gender}`); const pct = s.moral_alignment_score ?? 50; if (pct >= 70) parts.push('warm, trustworthy tone'); else if (pct <= 30) parts.push('cold, threatening or sinister tone'); @@ -306,9 +471,25 @@ async function csDeepAnalysis(sheet, sourceText) { // ── Card renderer ───────────────────────────────────────────────────────────── -function csField(label, value) { +function csSourcesForField(s, field) { + const keys = (CS_SOURCE_FIELDS[field] || [field]).map(x => x.toLowerCase()); + return (s.sources || []).filter(src => { + const hint = _csStr(src?.line_hint).toLowerCase(); + return hint && keys.some(k => hint.includes(k)); + }); +} + +function csSourceBadgeHtml(s, field) { + const src = csSourcesForField(s, field)[0]; + if (!src) return ''; + const idx = (s.sources || []).findIndex(x => x === src); + const n = idx >= 0 ? idx + 1 : 1; + return ``; +} + +function csField(label, value, sheet, field) { if (!value) return ''; - return `
${label}
${escHtml(String(value))}
`; + return `
${label}${sheet && field ? csSourceBadgeHtml(sheet, field) : ''}
${escHtml(String(value))}
`; } function csAvatarHtml(s) { @@ -327,6 +508,11 @@ function csAvatarHtml(s) { function csCardHtml(s) { const inv = (s.inventory || []).filter(Boolean); + const identityHtml = [ + csField('Full name', s.full_name, s, 'full_name'), + csField('Title', s.title, s, 'title'), + csField('First / Last', [s.first_name, s.last_name].filter(Boolean).join(' '), s, 'full_name'), + ].join(''); const invHtml = inv.length ? `
Signature Items
    ${inv.map(i => `
  • ${escHtml(i)}
  • `).join('')}
` : ''; @@ -335,13 +521,13 @@ function csCardHtml(s) { : ''; const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null)); const srcHtml = sources.length - ? `
${ - sources.map(x => - `${x.line_hint ? `[${escHtml(x.line_hint)}] ` : ''}` + - `${x.page != null ? `p.${escHtml(String(x.page))} ` : ''}` + - `${x.quote ? `"${escHtml(x.quote)}"` : ''}` - ).join(' · ') - }
` + ? `
Quellen / Evidence
${ + sources.map((x, i) => + `` + ).join('') + }
` : ''; return `
@@ -363,22 +549,25 @@ function csCardHtml(s) {
- ${csField('Physical', s.physical)} - ${csField('Clothing & Appearance', s.clothing)} - ${csField('Alignment & Ethos', s.alignment)} + ${identityHtml} + ${csField('Physical', s.physical, s, 'physical')} + ${csField('Clothing & Appearance', s.clothing, s, 'clothing')} + ${csField('Alignment & Ethos', s.alignment, s, 'alignment')} ${attrs} - ${csField('Trained Skills', s.skills)} - ${csField('Capabilities', s.capabilities)} + ${csField('Trained Skills', s.skills, s, 'skills')} + ${csField('Capabilities', s.capabilities, s, 'capabilities')} ${invHtml} - ${csField('Backstory & Origin', s.backstory)} - ${csField('Relationships', s.relationships)} - ${csField('Motivation', s.motivation)} - ${csField('Fears', s.fears)} - ${csField('Mannerisms & Habits', s.mannerisms)} - ${s.voice_pattern ? `
Voice & Speech
${escHtml(String(s.voice_pattern))}
` : ''} - ${csField('Dark Secret / Fatal Flaw', s.secret)} - ${csField('Conflict Style', s.conflict_style)} - ${csField('Win Condition', s.win_condition)} + ${csField('Backstory & Origin', s.backstory, s, 'backstory')} + ${csField('Relationships', s.relationships, s, 'relationships')} + ${csField('Motivation', s.motivation, s, 'motivation')} + ${csField('Fears', s.fears, s, 'fears')} + ${csField('Mannerisms & Habits', s.mannerisms, s, 'mannerisms')} + ${s.voice_pattern ? `
Voice & Speech${csSourceBadgeHtml(s, 'voice_pattern')}
${escHtml(String(s.voice_pattern))}
` : ''} + ${csField('Voice Design Prompt', s.voice_design_prompt, s, 'voice_pattern')} + ${csField('Image Prompt', s.image_prompt, s, 'physical')} + ${csField('Dark Secret / Fatal Flaw', s.secret, s, 'secret')} + ${csField('Conflict Style', s.conflict_style, s, 'conflict_style')} + ${csField('Win Condition', s.win_condition, s, 'win_condition')}
${srcHtml}
`; @@ -394,6 +583,9 @@ function csToMarkdown(sheets) { for (const s of list) { md += `\n### ${s.name}${s.archetype ? ' — ' + s.archetype : ''}\n`; if (s.aliases) md += `*aka ${s.aliases}*\n`; + if (s.full_name) md += `- **Full name:** ${s.full_name}\n`; + if (s.title) md += `- **Title:** ${s.title}\n`; + if (s.first_name || s.last_name) md += `- **Name parts:** ${[s.first_name, s.last_name].filter(Boolean).join(' ')}\n`; const mas = s.moral_alignment_score ?? 50; const masLabel = mas >= 70 ? 'Good' : mas <= 30 ? 'Evil' : 'Neutral'; md += `- **Moral alignment:** ${mas}/100 (${masLabel}) — Arc: ${s.arc_direction || 'neutral'}\n`; @@ -406,6 +598,8 @@ function csToMarkdown(sheets) { + f('Backstory & Origin', s.backstory) + f('Relationships', s.relationships) + f('Motivation', s.motivation) + f('Fears', s.fears) + f('Mannerisms & Habits', s.mannerisms) + f('Voice & Speech', s.voice_pattern) + + f('Voice Design Prompt', s.voice_design_prompt) + + f('Image Generation Prompt', s.image_prompt) + f('Signature Items', (s.inventory || []).join(', ')) + f('Dark Secret / Fatal Flaw', s.secret) + f('Conflict Style', s.conflict_style) + f('Win Condition', s.win_condition); @@ -521,6 +715,15 @@ async function csShow(sheets, title, sourceText, book) { .then(() => toast('Voice prompt copied — paste into Design a Voice', 'success'), () => toast('Copy failed', 'error')); }); }); + + ov.querySelectorAll('.cs-source-link, .cs-source-mark').forEach(btn => { + btn.addEventListener('click', e => { + e.stopPropagation(); + const pg = parseInt(btn.dataset.page, 10); + if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg); + else if (pg && typeof toast === 'function') toast(`Source: page ${pg}`, 'info'); + }); + }); } // ── Entry points (reader + rehearser) ──────────────────────────────────────── @@ -545,8 +748,7 @@ async function csForReader() { const book = readerState.title || 'Untitled book'; const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0); if (_cs.cache[key]) { csGoToLibrary(); return; } - const knownRoster = (window._audiobook && _audiobook.roster && _audiobook.roster.length) - ? _audiobook.roster.slice() : null; + const knownRoster = csKnownReaderRoster(); const sheets = await csGenerate(text, key, knownRoster); if (!sheets) return; if (!sheets.length) { toast('No characters found', 'error'); return; } diff --git a/static/js/characters-library.js b/static/js/characters-library.js index 7abaa6d..91bfdda 100644 --- a/static/js/characters-library.js +++ b/static/js/characters-library.js @@ -6,13 +6,14 @@ // Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics. const CL_EDIT_FIELDS = [ - ['name', 'Name'], ['aliases', 'Aliases'], ['archetype', 'Archetype'], + ['name', 'Name'], ['aliases', 'Aliases / also known as'], ['first_name', 'First name'], ['last_name', 'Last name'], ['full_name', 'Full name'], ['title', 'Title / role'], + ['archetype', 'Archetype'], ['physical', 'Physical'], ['clothing', 'Clothing & Appearance'], ['alignment', 'Alignment & Ethos'], ['arc_note', 'Arc note'], ['skills', 'Trained Skills'], ['capabilities', 'Capabilities'], ['backstory', 'Backstory & Origin'], ['relationships', 'Relationships'], ['motivation', 'Motivation'], ['fears', 'Fears'], ['mannerisms', 'Mannerisms & Habits'], - ['voice_pattern', 'Voice & Speech'], ['secret', 'Dark Secret / Fatal Flaw'], + ['voice_pattern', 'Voice & Speech'], ['voice_design_prompt', 'Voice Design Prompt'], ['image_prompt', 'Image Generation Prompt'], ['secret', 'Dark Secret / Fatal Flaw'], ['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'], ]; @@ -33,6 +34,10 @@ async function clGet(id) { } async function clPut(rec) { + if (rec?.color) { + rec.sheet = rec.sheet || {}; + rec.sheet.color = clNormalizeColor(rec.color, rec.name); + } const r = await fetch('/api/characters/' + encodeURIComponent(rec.id), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -51,6 +56,66 @@ function clKey(book, name) { return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase(); } +function clHslToHex(h, s, l) { + s /= 100; l /= 100; + const k = n => (n + h / 30) % 12; + const a = s * Math.min(l, 1 - l); + const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + return '#' + [f(0), f(8), f(4)].map(x => Math.round(255 * x).toString(16).padStart(2, '0')).join(''); +} + +function clNameHue(name) { + return Math.abs((name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0)); +} + +function clNormalizeColor(color, name) { + const c = String(color || '').trim(); + if (/^#[0-9a-f]{6}$/i.test(c)) return c; + if (/^#[0-9a-f]{3}$/i.test(c)) return '#' + c.slice(1).split('').map(ch => ch + ch).join(''); + return clHslToHex(clNameHue(name), 58, 43); +} + +const CL_IDENTITY_FIELDS = ['name', 'aliases', 'first_name', 'last_name', 'full_name', 'title']; +const CL_ALIAS_MAX_TOKENS = 12; +const CL_ALIAS_MAX_CHARS = 500; + +function clSplitIdentityTokens(v, opts = {}) { + const raw = _clStr(v); + const parts = raw + .split(/[,;/|]|\baka\b|\baka\.\b|\balias(?:es)?\b|\bgenannt\b|\bnamens\b|\bcalled\b|\bknown as\b/i) + .map(x => x.trim()) + .filter(Boolean) + .filter(x => x.length <= 80 && !/^needs?:/i.test(x) && !/^complete$/i.test(x)); + if (opts.aliases && (raw.length > CL_ALIAS_MAX_CHARS || parts.length > CL_ALIAS_MAX_TOKENS)) return []; + return parts.slice(0, opts.aliases ? CL_ALIAS_MAX_TOKENS : undefined); +} + +function clIdentityNames(recOrSheet) { + const s = recOrSheet?.sheet || recOrSheet || {}; + const out = new Set(); + const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x.toLowerCase())); + add(recOrSheet?.name || s.name); + CL_IDENTITY_FIELDS.filter(k => k !== 'name').forEach(k => add(s[k], { aliases: k === 'aliases' })); + return out; +} + +function clMergeAliases(existing, incoming) { + const names = new Map(); + const add = (v) => clSplitIdentityTokens(v, { aliases: true }).forEach(x => names.set(x.toLowerCase(), x)); + add(existing.aliases); + add(incoming.aliases); + if (incoming.name && incoming.name !== existing.name) add(incoming.name); + return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', '); +} + +function clSameIdentity(rec, book, sheet) { + if (String(rec?.book || '').trim().toLowerCase() !== String(book || '').trim().toLowerCase()) return false; + const a = clIdentityNames(rec); + const b = clIdentityNames(sheet); + for (const n of b) if (a.has(n)) return true; + return false; +} + // Coerce a sheet field value to string, matching _csStr in character-sheets.js. function _clStr(v) { if (v == null) return ''; @@ -74,7 +139,9 @@ function clMergeSheet(existing, incoming) { const inc = _clSanitize(incoming); const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS : CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name'); + const oldAliases = e.aliases; scalars.forEach(f => { if ((inc[f] || '').length > (e[f] || '').length) e[f] = inc[f]; }); + e.aliases = clMergeAliases({ ...e, aliases: oldAliases }, incoming); if (incoming.tier === 'main') e.tier = 'main'; if (incoming.moral_alignment_score != null) { e.moral_alignment_score = e.moral_alignment_score != null @@ -86,7 +153,7 @@ function clMergeSheet(existing, incoming) { (incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); }); e.sources = [...(existing.sources || [])]; (incoming.sources || []).forEach(src => { - if (src && src.quote && e.sources.length < 5 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); + if (src && src.quote && e.sources.length < 12 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src); }); return e; } @@ -103,14 +170,21 @@ async function clUpsert(book, sheet) { const name = (sheet.name || '').trim(); if (!name) return null; const bk = (book || '').trim() || 'Unsorted'; - const id = clKey(bk, name); + const all = await clGetAll().catch(() => []); + const aliasPrev = all.find(r => clSameIdentity(r, bk, sheet)); + const id = aliasPrev?.id || clKey(bk, name); const now = new Date(); - const prev = await clGet(id).catch(() => null); + const prev = aliasPrev || await clGet(id).catch(() => null); const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name }; + const canonicalName = prev?.name || name; + merged.name = canonicalName; const tags = clMergeTags(prev?.tags, sheet.tags, bk); + const color = clNormalizeColor(prev?.color || prev?.sheet?.color || sheet.color, canonicalName); + merged.color = color; const rec = { - id, book: bk, name, tags, + id, book: bk, name: canonicalName, tags, sheet: merged, + color, analysis: prev?.analysis || null, voice: prev?.voice || sheet.voice || null, image: prev?.image || sheet.image || null, @@ -238,6 +312,10 @@ function clApplyFilter() { if (q) recs = recs.filter(r => (r.name || '').toLowerCase().includes(q) || (r.sheet?.aliases || '').toLowerCase().includes(q) || + (r.sheet?.first_name || '').toLowerCase().includes(q) || + (r.sheet?.last_name || '').toLowerCase().includes(q) || + (r.sheet?.full_name || '').toLowerCase().includes(q) || + (r.sheet?.title || '').toLowerCase().includes(q) || (r.sheet?.archetype || '').toLowerCase().includes(q) || (r.tags || '').toLowerCase().includes(q) || (r.book || '').toLowerCase().includes(q)); @@ -310,6 +388,8 @@ function clEdit(id) {
+
' + '
' + '
' + tierBadge + escHtml(rec.name) + '
' + + (_libStr(sh.title) ? '
' + escHtml(_libStr(sh.title)) + '
' : '') + + (_libStr(sh.aliases) ? '
aka ' + escHtml(_libStr(sh.aliases)) + '
' : '') + (sh.archetype ? '
' + escHtml(_libStr(sh.archetype)) + '
' : '') + (snippet ? '
' + escHtml(snippet) + '
' : '') + _charAlignHtml(sh) @@ -429,6 +431,8 @@ async function _charDetailPage(rec, allChars) { + avatarHtml + '
' + '
' + escHtml(rec.name) + '
' + + (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '
' + escHtml(_libStr(sh.full_name)) + '
' : '') + + (_libStr(sh.title) ? '
' + escHtml(_libStr(sh.title)) + '
' : '') + '
' + escHtml(_libStr(sh.aliases)) + '
' + '
' + escHtml(_libStr(sh.archetype)) + '
' + '
' @@ -448,6 +452,13 @@ async function _charDetailPage(rec, allChars) { + '
' + alignHtml + '
' + + _lcdSection('mdi-card-account-details-outline', 'Identität', [ + _lcdFieldEdit('Voller Name', sh.full_name, 'full_name'), + _lcdFieldEdit('Vorname', sh.first_name, 'first_name'), + _lcdFieldEdit('Nachname', sh.last_name, 'last_name'), + _lcdFieldEdit('Titel / Rolle', sh.title, 'title'), + _lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases'), + ]) + _lcdSection('mdi-account-outline', 'Erscheinung', [ _lcdFieldEdit('Körperlich', sh.physical, 'physical'), _lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing'), @@ -646,6 +657,8 @@ function _charDetailModal(rec, allChars) { + avatarHtml + '
' + '
' + escHtml(rec.name) + '
' + + (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '
' + escHtml(_libStr(sh.full_name)) + '
' : '') + + (_libStr(sh.title) ? '
' + escHtml(_libStr(sh.title)) + '
' : '') + (_libStr(sh.aliases) ? '
auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '
' : '') + (_libStr(sh.archetype) ? '
' + escHtml(_libStr(sh.archetype)) + '
' : '') + '
' diff --git a/static/js/library.js b/static/js/library.js index 3c10200..5d3d84b 100644 --- a/static/js/library.js +++ b/static/js/library.js @@ -175,12 +175,14 @@ async function libraryRenderPlays() { async function openPlayInRehearser(id) { try { - const db = await rehDbOpen(); - const rec = await new Promise(function (res, rej) { - const r = db.transaction(REH_STORE, 'readonly').objectStore(REH_STORE).get(id); - r.onsuccess = function (e) { res(e.target.result); }; r.onerror = function (e) { rej(e.target.error); }; - }); + // rehDbOpen()/REH_STORE were the pre-migration raw-IndexedDB API and no + // longer exist — rehearsals are server-backed now (rehDbGetById below). + // This call used to throw a ReferenceError immediately, caught silently + // by this try/catch, which is why clicking a card looked like it did + // nothing (or just flashed an error toast). + const rec = (typeof rehDbGetById === 'function') ? await rehDbGetById(id) : null; if (rec && typeof loadRecord === 'function') loadRecord(rec); + else toast('Rehearsal not found', 'error'); } catch (err) { toast('Could not open rehearsal', 'error'); } } diff --git a/static/js/reader.js b/static/js/reader.js index cb3832d..63b2287 100644 --- a/static/js/reader.js +++ b/static/js/reader.js @@ -48,13 +48,44 @@ const readerState = { savedId: null, // server library doc id when this doc came from / was saved to the library savedAudioIdx: new Set(), // unit indices whose audio is already on the server (incremental save / lazy fetch) sourceUploaded: false, // the source document has been uploaded to the server + pdfParsing: false, + pdfParsedPages: 0, + pdfParseTotal: 0, + ocrWorker: null, // lazily-created Tesseract.js worker, reused across imports + synthStarted: false, // true once any unit leaves 'pending' — gates the red "not synthesised" tint + textExtracted: false, // PDF pages are loaded/visible but getTextContent()+OCR hasn't run yet until this flips true }; window.readerState = readerState; +// Default the "Voice & synthesis settings" card to collapsed the first time +// anyone visits — it's secondary until you actually have a document loaded, +// and was one of several boxes making the empty-state reader feel cluttered. +// Uses the same localStorage-key convention as the generic card-collapse +// mechanism (ai-backends.js, which runs later in a background batch) — only +// sets a default, never overrides a choice you've already made. +(function () { + try { + const KEY = 'card-collapse-v1'; + const s = JSON.parse(localStorage.getItem(KEY) || '{}'); + if (s['card-voice-synthesis-settings'] === undefined) { + s['card-voice-synthesis-settings'] = false; + localStorage.setItem(KEY, JSON.stringify(s)); + } + } catch (_) {} +})(); + const READER_RESUME_KEY = 'reader-resume'; const READER_FMT = 'mp3'; // synthesise + store as MP3 (compact; decodeAudioData plays it) const READER_BUF_WINDOW = 3; // keep decoded PCM only for ±N units around the playhead const READER_CANVAS_MARGIN = 2200; // px; raster canvases beyond this from view are released +const READER_IMPORT_YIELD_EVERY = 350; +const READER_PDF_INITIAL_PAGES = 16; +const READER_PDF_INITIAL_MAX_WITHOUT_TEXT = 64; +const READER_OCR_LANGS = 'deu+eng'; // headline OCR: fixed dual-pack (covers German prose + digits/Latin chapter numerals) +const READER_OCR_MIN_GAP_RATIO = 0.08; // treat a text-free band at the top of a page as a possible image heading once it's this tall... +const READER_OCR_MIN_GAP_PX = 40; // ...or at least this many px (whichever is larger) +const READER_OCR_RENDER_SCALE = 2.5; // rasterization DPI multiplier fed to Tesseract +const READER_OCR_MIN_CONFIDENCE = 40; // discard OCR guesses below this confidence (0-100) function readerCtx() { if (!readerState.audioCtx) readerState.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); @@ -62,6 +93,10 @@ function readerCtx() { return readerState.audioCtx; } +function readerYield() { + return new Promise(resolve => setTimeout(resolve, 0)); +} + // ── Section enter / leave hooks (wired from nav.js) ───────────────────────── window.readerOnShow = async function () { @@ -79,6 +114,7 @@ window.readerOnShow = async function () { } } readerUpdateBackendHint(); + if (window.VoicePicker) VoicePicker.upgrade('reader-voice-select'); readerRenderLibrary(); }; @@ -96,9 +132,16 @@ async function readerFetchVoices() { const ids = (Array.isArray(raw) ? raw : []).map(v => (typeof v === 'string' ? v : (v.id || v.name || v.voice_id))).filter(Boolean); const sel = $('reader-voice-select'); if (!sel) return; const prev = sel.value; - sel.innerHTML = ''; - ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); }); - if (prev && ids.includes(prev)) sel.value = prev; + if (window.VoicePicker) { + VoicePicker.upgrade('reader-voice-select'); + VoicePicker.populate('reader-voice-select', ids); + if (prev && ids.includes(prev)) VoicePicker.setValue('reader-voice-select', prev); + else if (ids.length) VoicePicker.setValue('reader-voice-select', ids[0]); + } else { + sel.innerHTML = ''; + ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); }); + if (prev && ids.includes(prev)) sel.value = prev; + } toast('Fetched ' + ids.length + ' voices', 'success'); } catch (e) { toast('Fetch failed: ' + (e.message || e), 'error'); } finally { if (btn) btn.disabled = false; } @@ -119,6 +162,49 @@ function readerUpdateBackendHint() { // ── Import ────────────────────────────────────────────────────────────────── +function readerTitleFromText(text) { + const first = String(text || '').split(/\r?\n/).map(x => x.trim()).find(Boolean) || 'Pasted text'; + return first.replace(/\s+/g, ' ').slice(0, 80) || 'Pasted text'; +} + +function readerUpdateToolbarVisibility(loaded = !!readerState.sentences.length) { + const toolbar = $('reader-toolbar'); + if (toolbar) toolbar.hidden = !loaded; + const pdfOnly = readerState.mode === 'pdf'; + const zoom = $('reader-zoom'); if (zoom) zoom.hidden = !pdfOnly; + const legend = document.querySelector('.reader-legend'); if (legend) legend.hidden = !pdfOnly; +} + +function readerFinishLoadedDocument({ resume = true, successPrefix = 'Loaded' } = {}) { + if (!readerState.sentences.length) { toast('No readable text found in this document', 'error'); return false; } + if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending')); + $('reader-transport').hidden = false; + $('reader-dropzone').hidden = true; + const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true; + readerUpdateToolbarVisibility(true); + $('reader-synthbar').hidden = false; + const pr = $('reader-page-range'); + if (pr) { + pr.hidden = readerState.mode !== 'pdf'; + if (readerState.mode === 'pdf') { + const n = readerState.pages.length; + const pf = $('reader-page-from'), pt = $('reader-page-to'); + if (pf) { pf.max = n; pf.value = 1; } + if (pt) { pt.max = n; pt.value = n; } + } + } + readerUpdateScopeLabel(); + const resumed = resume && readerLoadResume(); + readerUpdateProgress(); + readerHighlightSentence(readerState.sentences[readerState.idx]); + const parsingNote = readerState.pdfParsing ? ' · indexing continues in background' : ''; + toast(resumed + ? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length + : successPrefix + ' · ' + readerState.sentences.length + ' sentences' + parsingNote + ' — press play', 'success'); + if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source'); + return true; +} + async function readerImportFile(file) { if (!file) return; const name = (file.name || '').toLowerCase(); @@ -128,39 +214,76 @@ async function readerImportFile(file) { const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title; try { if (name.endsWith('.pdf')) { - toast('Reading PDF…', 'success'); - await readerLoadPdf(file); + toast('Loading PDF…', 'success'); + const loaded = await readerLoadPdfSkeleton(file); + if (!loaded) return; + readerShowExtractPrompt(loaded); } else { const text = await file.text(); readerState.docText = text; - readerLoadText(text); + await readerLoadText(text); + readerFinishLoadedDocument({ successPrefix: 'Loaded' }); } - if (!readerState.sentences.length) { toast('No readable text found in this document', 'error'); return; } - if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending')); - $('reader-transport').hidden = false; - $('reader-dropzone').hidden = true; - $('reader-toolbar').hidden = readerState.mode !== 'pdf'; - $('reader-synthbar').hidden = false; - const pr = $('reader-page-range'); - if (pr) { - pr.hidden = readerState.mode !== 'pdf'; - if (readerState.mode === 'pdf') { - const n = readerState.pages.length; - const pf = $('reader-page-from'), pt = $('reader-page-to'); - if (pf) { pf.max = n; pf.value = 1; } - if (pt) { pt.max = n; pt.value = n; } - } - } - readerUpdateScopeLabel(); - const resumed = readerLoadResume(); - readerUpdateProgress(); - readerHighlightSentence(readerState.sentences[readerState.idx]); - toast(resumed - ? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length - : 'Loaded · ' + readerState.sentences.length + ' sentences — press play', 'success'); } catch (e) { toast('Import failed: ' + (e.message || e), 'error'); } } +// Shown after a fresh PDF import: pages are already visible (rasterized from +// the skeleton stage) but nothing is readable/searchable/castable yet until +// the user explicitly extracts text — this is the pipeline's "PDF → Text" +// step, and it's where the OCR-headings choice belongs since it only matters +// once you're about to extract. +function readerShowExtractPrompt(loaded) { + const dz = $('reader-dropzone'); if (dz) dz.hidden = true; + const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true; + readerUpdateToolbarVisibility(true); + const banner = $('reader-extract-banner'); + if (banner) { + banner.hidden = false; + const pc = $('reader-extract-pagecount'); if (pc) pc.textContent = String(loaded.nPages); + } + const btn = $('reader-extract-btn'); + if (btn) btn.onclick = () => readerRunExtraction(loaded); + if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source'); + toast('PDF loaded · ' + loaded.nPages + ' pages — click "Extract Text" when ready', 'success'); +} + +async function readerRunExtraction(loaded) { + const btn = $('reader-extract-btn'); + const original = btn ? btn.innerHTML : ''; + if (btn) { btn.disabled = true; btn.innerHTML = ' Extracting…'; } + try { + await readerExtractPdfText(loaded); + const banner = $('reader-extract-banner'); if (banner) banner.hidden = true; + readerFinishLoadedDocument({ resume: false, successPrefix: 'Extracted' }); + } catch (e) { + toast('Text extraction failed: ' + (e.message || e), 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = original; } + } +} + +async function readerImportPastedText() { + const ta = $('reader-paste-text'); + const text = (ta?.value || '').trim(); + if (!text) { toast('Paste some text first', 'error'); ta?.focus(); return; } + const btn = $('reader-paste-load'); + if (btn) btn.disabled = true; + readerResetDoc(); + readerState.title = readerTitleFromText(text); + readerState.docText = text; + readerState.fileBlob = null; + readerState.sourceUploaded = false; + const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title; + try { + await readerLoadText(text); + readerFinishLoadedDocument({ resume: false, successPrefix: 'Loaded pasted text' }); + } catch (e) { + toast('Import failed: ' + (e.message || e), 'error'); + } finally { + if (btn) btn.disabled = false; + } +} + function readerResetDoc() { readerStopPlayback(); readerState._seq++; @@ -182,15 +305,26 @@ function readerResetDoc() { readerState.savedId = null; readerState.savedAudioIdx = new Set(); readerState.sourceUploaded = false; + readerState.pdfParsing = false; + readerState.pdfParsedPages = 0; + readerState.pdfParseTotal = 0; + readerState.textExtracted = false; + const extractBanner = $('reader-extract-banner'); if (extractBanner) extractBanner.hidden = true; if (readerState.io) { readerState.io.disconnect(); readerState.io = null; } readerState.synthRunning = false; readerState.synthCancel = false; + readerState.synthStarted = false; readerSetSelecting(false); readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false; const sb = $('reader-synthbar'); if (sb) sb.hidden = true; + const tr = $('reader-transport'); if (tr) tr.hidden = true; + readerUpdateToolbarVisibility(false); const sp = $('reader-synth-prog'); if (sp) sp.hidden = true; const si = $('reader-sel-info'); if (si) si.hidden = true; - const doc = $('reader-doc'); if (doc) { doc.innerHTML = ''; doc.classList.remove('two-page', 'reader-selecting'); } + const doc = $('reader-doc'); if (doc) { doc.innerHTML = ''; doc.classList.remove('two-page', 'reader-selecting', 'reader-synth-started'); } + if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source'); + const importArea = $('reader-import-area'); if (importArea) importArea.hidden = false; + readerClearSearch({ clearInput: true }); } // Build sentence list from a flat array of word objects (each carries geometry). @@ -244,23 +378,104 @@ function readerGroupUnits(base, mode) { return units; } -// ── PDF import: build scale-1 geometry; render lazily at current scale ─────── +// ── Headline OCR: recover chapter titles baked into the PDF as images ─────── +// pdf.js getTextContent() only ever returns real text glyphs, so a headline +// drawn as a raster/vector graphic (common with stylised chapter title pages) +// is otherwise invisible to the reader. When a page's real text starts well +// below its top edge, we assume the gap holds such an image, rasterize just +// that band, and OCR it with a locally-vendored Tesseract.js worker. -async function readerLoadPdf(file) { +async function loadTesseract() { + if (window.Tesseract) return; + await new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = '/static/js/tesseract/tesseract.min.js'; + s.onload = resolve; s.onerror = reject; + document.head.appendChild(s); + }); +} + +async function readerGetOcrWorker() { + if (readerState.ocrWorker) return readerState.ocrWorker; + await loadTesseract(); + readerState.ocrWorker = await Tesseract.createWorker(READER_OCR_LANGS, 1, { + workerPath: '/static/js/tesseract/worker.min.js', + corePath: '/static/js/tesseract/tesseract-core-simd-lstm.wasm.js', + langPath: '/static/js/tesseract/lang', + gzip: true, + }); + return readerState.ocrWorker; +} + +// Rasterize the top `gapPx` (scale-1 px) of `page` and OCR it, returning +// synthetic word entries (same shape as real getTextContent words) spread +// across the band so they slot into the normal sentence/highlight pipeline. +async function readerOcrPageHeading(page, base, pageIdx, gapPx) { + let full, crop; + try { + const worker = await readerGetOcrWorker(); + const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE }); + full = document.createElement('canvas'); + full.width = viewport.width; + full.height = viewport.height; + await page.render({ canvasContext: full.getContext('2d'), viewport }).promise; + + const cropH = Math.max(Math.round(gapPx * READER_OCR_RENDER_SCALE), 1); + crop = document.createElement('canvas'); + crop.width = full.width; + crop.height = cropH; + crop.getContext('2d').drawImage(full, 0, 0, full.width, cropH, 0, 0, full.width, cropH); + + const { data } = await worker.recognize(crop); + const text = (data.text || '').replace(/\s+/g, ' ').trim(); + if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return []; + + const tokens = text.split(' ').filter(Boolean); + const bandH = Math.max(gapPx - 4, 10); + const avgCharW = Math.max((base.width - 16) / text.length, 4); + const words = []; + let x = 8; + for (const tok of tokens) { + const w = Math.max(tok.length * avgCharW, 10); + words.push({ page: pageIdx, x, top: 4, w, h: bandH, text: tok, ocr: true }); + x += w + avgCharW; + } + return words; + } catch (e) { + console.warn('Heading OCR failed', e); + return []; + } finally { + if (full) { full.width = 0; full.height = 0; } + if (crop) { crop.width = 0; crop.height = 0; } + } +} + +// ── PDF import: build scale-1 geometry; render lazily at current scale ─────── +// Loading a PDF is two real stages so the pipeline stepper's "PDF" and "Text" +// stops mean something: readerLoadPdfSkeleton() opens the file and rasterizes +// pages (fast, no text layer yet — you can already scroll/zoom/look at it), +// then readerExtractPdfText() is a separate, explicit action that runs +// getTextContent() + heading OCR + sentence-building. A freshly-imported PDF +// stops after the skeleton and shows an "Extract Text" prompt; reopening an +// already-known document (library restore) runs both stages back to back via +// readerLoadPdf() since there's nothing to "decide" on a document you've +// already extracted before. + +async function readerLoadPdfSkeleton(file) { await loadPdfJs(); const seq = readerState._seq; const ab = await file.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: ab }).promise; - if (seq !== readerState._seq) return; + if (seq !== readerState._seq) return null; readerState.mode = 'pdf'; readerState.pdfDoc = pdf; + readerState.textExtracted = false; const doc = $('reader-doc'); const nPages = pdf.numPages; - const progress = readerShowParseProgress(nPages); // Pre-fetch page 1 geometry to define scale/zoom quickly for skeletons const page1 = await pdf.getPage(1); - if (seq !== readerState._seq) { progress.done(); return; } + if (seq !== readerState._seq) return null; const base1 = page1.getViewport({ scale: 1 }); // Render empty skeleton pages instantly so the user can scroll @@ -276,11 +491,21 @@ async function readerLoadPdf(file) { doc.appendChild(pageDiv); // Use base1 as placeholder geometry until actual page is parsed readerState.pages.push({ pageDiv, overlay, page: null, base: base1, rendered: false, renderTask: null }); + if (p % 50 === 0) await readerYield(); } // Apply zoom and rasterize immediately so the user sees the book! readerApplyZoom('fit-width'); readerSetupLazyRaster(); + return { pdf, page1, nPages }; +} + +async function readerExtractPdfText(loaded) { + const seq = readerState._seq; + const pdf = loaded.pdf, page1 = loaded.page1, nPages = loaded.nPages; + const progress = readerShowParseProgress(nPages); + + const ocrHeadingsEnabled = $('reader-ocr-headings')?.checked !== false; // Sentences/units are built incrementally per page (page-local) and appended. // We yield on every page to keep the browser buttery smooth for scrolling. @@ -296,13 +521,17 @@ async function readerLoadPdf(file) { // Update the skeleton with actual page data pgState.page = page; pgState.base = base; - // We do NOT change pageDiv.style.width here, it will be updated by readerApplyZoom / readerRenderPage + pgState.pageDiv.style.width = (base.width * readerState.scale) + 'px'; + pgState.pageDiv.style.height = (base.height * readerState.scale) + 'px'; + if (readerIsPageNearView(pgState)) readerRenderPage(pageIdx); // Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later const content = await page.getTextContent(); if (seq !== readerState._seq) return; + await readerYield(); const pageWords = []; - for (const item of content.items) { + for (let itemIdx = 0; itemIdx < content.items.length; itemIdx++) { + const item = content.items[itemIdx]; const str = (item.str || ''); if (!str.trim()) continue; const m = pdfjsLib.Util.transform(base.transform, item.transform); @@ -318,7 +547,20 @@ async function readerLoadPdf(file) { if (tok.trim()) pageWords.push({ page: pageIdx, x: offX, top, w: Math.max(tw, 2), h, text: tok }); offX += tw; } + if (itemIdx && itemIdx % READER_IMPORT_YIELD_EVERY === 0) await readerYield(); } + + // A tall text-free band at the top of the page likely holds an image + // heading (chapter title graphics) that getTextContent() can't see. + if (ocrHeadingsEnabled) { + const gapPx = pageWords.length ? Math.min(base.height, ...pageWords.map(w => w.top)) : base.height; + if (gapPx > Math.max(base.height * READER_OCR_MIN_GAP_RATIO, READER_OCR_MIN_GAP_PX)) { + const ocrWords = await readerOcrPageHeading(page, base, pageIdx, gapPx); + if (seq !== readerState._seq) return; + if (ocrWords.length) pageWords.unshift(...ocrWords); + } + } + // Build + append this page's sentences and units const pageBase = readerBuildSentences(pageWords); const pageUnits = readerGroupUnits(pageBase, readerState.chunkMode); @@ -334,6 +576,16 @@ async function readerLoadPdf(file) { await new Promise(r => setTimeout(r, 0)); } progress.done(); + readerState.textExtracted = true; +} + +// Convenience wrapper used by restore paths (library reopen) where extraction +// should just happen automatically — there's nothing to decide on a document +// you've already read and extracted before. +async function readerLoadPdf(file) { + const loaded = await readerLoadPdfSkeleton(file); + if (!loaded) return; + await readerExtractPdfText(loaded); } // Lightweight floating parse-progress line shown in the document pane for big PDFs. @@ -395,6 +647,7 @@ function readerApplyZoom(mode) { readerState.pages.forEach(pg => pg.overlay.querySelectorAll('.reader-stat').forEach(el => { const i = parseInt(el.dataset.si); if (!isNaN(i)) readerPaintStatus(i); })); + readerPaintSearchHighlights(); if (readerState.idx < readerState.sentences.length) readerHighlightSentence(readerState.sentences[readerState.idx]); const pct = $('reader-zoom-pct'); if (pct) pct.textContent = Math.round(scale * 100) + '%'; readerRenderVisible(); @@ -414,12 +667,18 @@ function readerSetupLazyRaster() { function readerRenderVisible() { const doc = $('reader-doc'); if (!doc) return; - const dr = doc.getBoundingClientRect(); readerState.pages.forEach((pg, i) => { - const r = pg.pageDiv.getBoundingClientRect(); - if (r.bottom > dr.top - 800 && r.top < dr.bottom + 800) readerRenderPage(i); + if (readerIsPageNearView(pg, 800)) readerRenderPage(i); }); - readerEvictCanvases(dr); + readerEvictCanvases(); +} + +function readerIsPageNearView(pg, margin = 800) { + const doc = $('reader-doc'); + if (!doc || !pg?.pageDiv) return false; + const dr = doc.getBoundingClientRect(); + const r = pg.pageDiv.getBoundingClientRect(); + return r.bottom > dr.top - margin && r.top < dr.bottom + margin; } // Release canvas bitmaps for pages far outside the viewport so a long book's @@ -441,7 +700,7 @@ function readerEvictCanvases(dr) { async function readerRenderPage(idx) { const pg = readerState.pages[idx]; - if (!pg || pg.rendered) return; + if (!pg || pg.rendered || !pg.page) return; pg.rendered = true; // claim immediately to avoid double render const scale = readerState.scale; const viewport = pg.page.getViewport({ scale }); @@ -460,7 +719,7 @@ async function readerRenderPage(idx) { // ── Text import: render word spans, highlight via classes ──────────────────── -function readerLoadText(text) { +async function readerLoadText(text) { readerState.mode = 'text'; readerState.docText = text; // keep the source so it can be saved to the library const doc = $('reader-doc'); @@ -470,24 +729,35 @@ function readerLoadText(text) { const words = []; const paras = text.replace(/\r\n/g, '\n').split(/\n{2,}/); - paras.forEach((para, pi) => { + const frag = document.createDocumentFragment(); + let renderedWords = 0; + for (let pi = 0; pi < paras.length; pi++) { + const para = paras[pi]; const pEl = document.createElement('p'); pEl.className = 'reader-text-para'; - para.split('\n').forEach((line, li, arr) => { - line.trim().split(/\s+/).forEach(tok => { - if (!tok) return; + const lines = para.split('\n'); + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + const toks = line.trim().split(/\s+/); + for (const tok of toks) { + if (!tok) continue; const span = document.createElement('span'); span.className = 'reader-word'; span.textContent = tok; pEl.appendChild(span); pEl.appendChild(document.createTextNode(' ')); words.push({ el: span, text: tok, page: 0, para: pi }); - }); - if (li < arr.length - 1) pEl.appendChild(document.createElement('br')); - }); - pane.appendChild(pEl); - }); + renderedWords++; + if (renderedWords % READER_IMPORT_YIELD_EVERY === 0) await readerYield(); + } + if (li < lines.length - 1) pEl.appendChild(document.createElement('br')); + } + frag.appendChild(pEl); + if (pi && pi % 20 === 0) await readerYield(); + } + pane.appendChild(frag); readerState.baseSentences = readerBuildSentences(words); + await readerYield(); readerState.sentences = readerGroupUnits(readerState.baseSentences, readerState.chunkMode); pane.addEventListener('click', e => { const span = e.target.closest('.reader-word'); if (!span) return; @@ -547,6 +817,10 @@ function readerEffectiveStatus(s) { return s.status; } function readerSetStatus(idx, status) { const s = readerState.sentences[idx]; if (!s) return; s.status = status; + if (status !== 'pending' && !readerState.synthStarted) { + readerState.synthStarted = true; + $('reader-doc')?.classList.add('reader-synth-started'); + } readerPaintStatus(idx); } @@ -1208,12 +1482,15 @@ async function readerOpenLibraryDoc(id) { return; } readerState.fileBlob = sourceBlob; - toast('Opening book…', 'success'); + // No "Opening book…" toast here — the "Reading PDF… page N / total" + // progress pill (readerShowParseProgress, inside readerLoadPdf) appears + // within the same instant and says the same thing with more detail, so + // the two used to visibly stack on top of each other. await readerLoadPdf(sourceBlob); } else { const text = sourceBlob ? await sourceBlob.text() : (rec.text || ''); readerState.docText = text; - readerLoadText(text); + await readerLoadText(text); } } catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; } if (!readerState.sentences.length) { toast('Document had no readable text', 'error'); return; } @@ -1230,7 +1507,8 @@ async function readerOpenLibraryDoc(id) { readerState.idx = Math.max(0, Math.min(rec.idx || 0, readerState.sentences.length - 1)); $('reader-transport').hidden = false; $('reader-dropzone').hidden = true; - $('reader-toolbar').hidden = readerState.mode !== 'pdf'; + const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true; + readerUpdateToolbarVisibility(true); $('reader-synthbar').hidden = false; const pr = $('reader-page-range'); if (pr) { @@ -1255,6 +1533,7 @@ function readerSetSelectValue(id, value) { const o = document.createElement('option'); o.value = o.textContent = value; sel.appendChild(o); } sel.value = value; + if (id === 'reader-voice-select' && window.VoicePicker) VoicePicker.setValue(id, value); } async function readerRenderLibrary() { @@ -1389,10 +1668,21 @@ $('reader-file-input')?.addEventListener('change', function () { const f = this.files?.[0]; this.value = ''; readerImportFile(f); }); +$('reader-paste-load')?.addEventListener('click', readerImportPastedText); +$('reader-paste-text')?.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + readerImportPastedText(); + } +}); $('reader-fetch-voices-btn')?.addEventListener('click', readerFetchVoices); $('reader-backend-select')?.addEventListener('change', () => { const sel = $('reader-voice-select'); if (sel) sel.innerHTML = ''; + if (window.VoicePicker) { + VoicePicker.upgrade('reader-voice-select'); + VoicePicker.populate('reader-voice-select', []); + } readerUpdateBackendHint(); }); $('reader-chunk-mode')?.addEventListener('change', function () { readerRechunk(this.value); }); @@ -1417,41 +1707,178 @@ $('reader-zoom-in') ?.addEventListener('click', () => { readerState.scale = Mat $('reader-zoom-out') ?.addEventListener('click', () => { readerState.scale = Math.max(0.2, readerState.scale / 1.2); readerApplyZoom('custom'); }); // ── Document search ────────────────────────────────────────────────────────── -let _readerSearchIdx = -1; // last hit sentence index -function readerSearchGo(term, direction) { - if (!term) { const st = $('reader-search-status'); if (st) st.textContent = ''; return; } - const lc = term.toLowerCase(); - const sents = readerState.sentences; - if (!sents || !sents.length) { const st = $('reader-search-status'); if (st) st.textContent = 'No text loaded'; return; } - const dir = direction === -1 ? -1 : 1; - const start = _readerSearchIdx < 0 ? 0 : (_readerSearchIdx + dir + sents.length) % sents.length; - for (let i = 0; i < sents.length; i++) { - const idx = (start + i * dir + sents.length * sents.length) % sents.length; - const s = sents[idx]; - const txt = (s.words || []).map(w => w.text).join(' ').toLowerCase(); - if (txt.includes(lc)) { - _readerSearchIdx = idx; - const pageIdx = s.words?.[0]?.page ?? 0; - const pgDiv = readerState.pages[pageIdx]?.pageDiv; - if (pgDiv) pgDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); - const st = $('reader-search-status'); - const total = sents.filter(s2 => (s2.words||[]).map(w=>w.text).join(' ').toLowerCase().includes(lc)).length; - if (st) st.textContent = total ? `p.${pageIdx + 1}` : ''; - return; +let _readerSearchTerm = ''; +let _readerSearchHits = []; +let _readerSearchCurrent = -1; +let _readerSearchTimer = null; +const READER_SEARCH_MAX_HITS = 2500; + +function readerSearchTextMap(words) { + let text = ''; + const spans = []; + for (let i = 0; i < words.length; i++) { + const raw = String(words[i]?.text || '').trim(); + if (!raw) continue; + if (text) text += ' '; + const start = text.length; + text += raw.toLowerCase(); + spans.push({ wordIndex: i, start, end: text.length }); + } + return { text, spans }; +} + +function readerFindSearchGroups(sentence, term) { + const words = sentence?.words || []; + const needle = String(term || '').trim().replace(/\s+/g, ' ').toLowerCase(); + if (!needle) return []; + const mapped = readerSearchTextMap(words); + const groups = []; + let pos = 0; + while ((pos = mapped.text.indexOf(needle, pos)) !== -1) { + const end = pos + needle.length; + const idxs = mapped.spans + .filter(sp => sp.end > pos && sp.start < end) + .map(sp => sp.wordIndex); + if (idxs.length) groups.push(idxs); + pos = Math.max(pos + 1, end); + } + return groups; +} + +function readerRemoveSearchHighlights() { + document.querySelectorAll('.reader-search-hit').forEach(el => el.remove()); + document.querySelectorAll('.reader-word.search-hit, .reader-word.search-current').forEach(el => { + el.classList.remove('search-hit', 'search-current'); + }); +} + +function readerSetSearchStatus(text) { + const st = $('reader-search-status'); + if (st) st.textContent = text || ''; +} + +function readerClearSearch({ clearInput = false } = {}) { + _readerSearchTerm = ''; + _readerSearchHits = []; + _readerSearchCurrent = -1; + readerRemoveSearchHighlights(); + readerSetSearchStatus(''); + if (clearInput) { + const inp = $('reader-search'); + if (inp) inp.value = ''; + } +} + +function readerCollectSearchHits(term) { + const hits = []; + const sents = readerState.sentences || []; + for (let si = 0; si < sents.length; si++) { + const s = sents[si]; + const groups = readerFindSearchGroups(s, term); + for (const wordIndexes of groups) { + const page = s.words?.[wordIndexes[0]]?.page ?? 0; + hits.push({ sentenceIndex: si, page, wordIndexes, nodes: [] }); + if (hits.length >= READER_SEARCH_MAX_HITS) return hits; } } - const st = $('reader-search-status'); - if (st) st.textContent = 'Not found'; + return hits; +} + +function readerPaintSearchHighlights() { + readerRemoveSearchHighlights(); + if (!_readerSearchTerm || !_readerSearchHits.length) return; + const scale = readerState.scale || 1; + _readerSearchHits.forEach((hit, hi) => { + const s = readerState.sentences[hit.sentenceIndex]; + if (!s) return; + hit.nodes = []; + for (const wi of hit.wordIndexes) { + const w = s.words?.[wi]; + if (!w) continue; + if (readerState.mode === 'pdf') { + const pg = readerState.pages[w.page]; + if (!pg?.overlay) continue; + const el = document.createElement('div'); + el.className = 'reader-search-hit' + (hi === _readerSearchCurrent ? ' search-current' : ''); + Object.assign(el.style, { + left: (w.x * scale) + 'px', + top: (w.top * scale) + 'px', + width: (Math.max(w.w, 2) * scale) + 'px', + height: (Math.max(w.h, 2) * scale) + 'px', + }); + pg.overlay.appendChild(el); + hit.nodes.push(el); + } else if (w.el) { + w.el.classList.add('search-hit'); + if (hi === _readerSearchCurrent) w.el.classList.add('search-current'); + hit.nodes.push(w.el); + } + } + }); +} + +function readerUpdateSearchStatus() { + if (!_readerSearchTerm) { readerSetSearchStatus(''); return; } + if (!readerState.sentences?.length) { readerSetSearchStatus('No text loaded'); return; } + if (!_readerSearchHits.length) { readerSetSearchStatus('0/0'); return; } + const current = _readerSearchCurrent >= 0 ? _readerSearchCurrent + 1 : 1; + const hit = _readerSearchHits[Math.max(0, _readerSearchCurrent)]; + const capped = _readerSearchHits.length >= READER_SEARCH_MAX_HITS ? '+' : ''; + const page = readerState.mode === 'pdf' ? ' · p.' + ((hit?.page ?? 0) + 1) : ''; + readerSetSearchStatus(current + '/' + _readerSearchHits.length + capped + page); +} + +function readerJumpToSearchHit(idx) { + if (!_readerSearchHits.length) return; + _readerSearchCurrent = (idx + _readerSearchHits.length) % _readerSearchHits.length; + readerPaintSearchHighlights(); + readerUpdateSearchStatus(); + const hit = _readerSearchHits[_readerSearchCurrent]; + const node = hit?.nodes?.[0]; + if (node) { + readerEnsureVisible(node); + } else if (readerState.mode === 'pdf') { + const pg = readerState.pages[hit.page]; + if (pg?.pageDiv) { + readerRenderPage(hit.page); + pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } +} + +function readerSearchApply(term, { jump = true } = {}) { + term = String(term || '').trim(); + if (!term) { readerClearSearch(); return; } + _readerSearchTerm = term; + _readerSearchHits = readerCollectSearchHits(term); + _readerSearchCurrent = _readerSearchHits.length ? 0 : -1; + readerPaintSearchHighlights(); + readerUpdateSearchStatus(); + if (jump && _readerSearchHits.length) readerJumpToSearchHit(0); +} + +function readerSearchStep(direction) { + const term = $('reader-search')?.value.trim() || _readerSearchTerm; + if (!_readerSearchHits.length || term !== _readerSearchTerm) { + readerSearchApply(term, { jump: true }); + return; + } + readerJumpToSearchHit(_readerSearchCurrent + (direction === -1 ? -1 : 1)); } $('reader-search')?.addEventListener('keydown', e => { - if (e.key === 'Enter') { e.preventDefault(); readerSearchGo(e.target.value.trim(), e.shiftKey ? -1 : 1); } - if (e.key === 'Escape') { e.target.value = ''; _readerSearchIdx = -1; const st = $('reader-search-status'); if (st) st.textContent = ''; } + if (e.key === 'Enter') { e.preventDefault(); readerSearchStep(e.shiftKey ? -1 : 1); } + if (e.key === 'Escape') { readerClearSearch({ clearInput: true }); } }); $('reader-search')?.addEventListener('input', e => { - _readerSearchIdx = -1; - if (!e.target.value) { const st = $('reader-search-status'); if (st) st.textContent = ''; } + clearTimeout(_readerSearchTimer); + const term = e.target.value.trim(); + if (!term) { readerClearSearch(); return; } + _readerSearchTimer = setTimeout(() => readerSearchApply(term, { jump: true }), 160); }); +$('reader-search-prev')?.addEventListener('click', () => readerSearchStep(-1)); +$('reader-search-next')?.addEventListener('click', () => readerSearchStep(1)); +$('reader-search-clear')?.addEventListener('click', () => readerClearSearch({ clearInput: true })); // Library + export $('reader-save-lib')?.addEventListener('click', readerSaveLibrary); @@ -1512,7 +1939,11 @@ window.showReaderView = function (view) { if (main) main.hidden = (view !== 'main'); if (cast) { cast.hidden = (view !== 'cast'); - if (view !== 'cast') cast.style.display = ''; + if (view !== 'cast') { + cast.style.display = ''; + cast.style.flexDirection = ''; + cast.style.minHeight = ''; + } if (view === 'cast' && !cast.innerHTML.trim()) { cast.innerHTML = '

No active casting session

Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.


'; cast.className = 'ab-castpanel-inline card'; @@ -1528,13 +1959,57 @@ window.showReaderView = function (view) { readerRenderLibrary(); // Re-fetch on open } } + if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs(view === 'cast' ? 'cast' : 'source'); }; -window.readerOnShow = function () { +window.navReaderView = function (view) { + window.showReaderView(view); +}; + +window.readerJumpToPage = function (pageNum) { + const pg = parseInt(pageNum, 10); + if (!pg) return Promise.resolve(false); + if (typeof navTo === 'function') navTo('s-reader'); + window.showReaderView('main'); + let tries = 0; + const waitFrame = () => new Promise(resolve => requestAnimationFrame(resolve)); + const jump = async () => { + await waitFrame(); + const pageState = readerState.pages?.[pg - 1]; + const pageDiv = pageState?.pageDiv; + if (pageDiv && !pageDiv.hidden) { + await readerRenderPage(pg - 1); + pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' }); + readerRenderVisible(); + return true; + } + if (tries++ < 20) { + await new Promise(resolve => setTimeout(resolve, 100)); + return jump(); + } + if (typeof toast === 'function') toast('Source page is not loaded in Reader yet', 'info'); + return false; + }; + return jump(); +}; + +window.readerOnShow = async function () { + const sel = $('reader-backend-select'); + if (sel && (!sel.value || sel.options.length <= 1)) { + if (typeof availableTtsBackends === 'function' && !availableTtsBackends().length && + typeof refreshTtsBackendAvailability === 'function') { + try { await refreshTtsBackendAvailability(); } catch (_) {} + } + if (typeof availableTtsBackends === 'function' && availableTtsBackends().length) { + sel.innerHTML = ttsBackendOptions(sel.value); + sel.disabled = false; + } + } + readerUpdateBackendHint(); + if (window.VoicePicker) VoicePicker.upgrade('reader-voice-select'); + readerRenderLibrary(); if (window._readerStartView) { window.showReaderView(window._readerStartView); window._readerStartView = null; } }; - - diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 27d8a22..54d36dd 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -571,6 +571,11 @@ async function importFromFile(file) { } function loadRecord(rec) { + // Opening a record from the Library (s-library) must bring the Rehearser + // section into view — without this, showPhase(2) below just swaps a phase + // div inside #s-rehearser while that whole section stays display:none, + // so nothing visibly happens and the click looks like it did nothing. + if (typeof navTo === 'function') navTo('s-rehearser'); if ($('reh-script-text')) $('reh-script-text').value = rec.script || ''; if ($('reh-script-title')) $('reh-script-title').value = rec.title || ''; rehState.savedId = rec.id || null; @@ -700,16 +705,18 @@ async function renderLibraryList() { list.querySelectorAll('.reh-book').forEach(book => book.addEventListener('click', async e => { if (e.target.closest('.reh-book-act')) return; const id = parseInt(book.dataset.id); - const db = await rehDbOpen(); - const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); }); - loadRecord(rec); + // rehDbOpen()/REH_STORE were the pre-migration raw-IndexedDB API and no + // longer exist — rehearsals are server-backed now (rehDbGetById). + const rec = await rehDbGetById(id); + if (rec) loadRecord(rec); + else toast('Rehearsal not found', 'error'); })); list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); const id = parseInt(btn.dataset.id); - const db = await rehDbOpen(); - const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); }); + const rec = await rehDbGetById(id); + if (!rec) { toast('Rehearsal not found', 'error'); return; } rec.version = 1; rec.clips = await clipsToJson(rec.clips || []); const json = JSON.stringify(rec, null, 2); diff --git a/static/js/seed-finder.js b/static/js/seed-finder.js index ea7732c..7e3e900 100644 --- a/static/js/seed-finder.js +++ b/static/js/seed-finder.js @@ -23,7 +23,7 @@ function _seedFinderDefaultText(voiceId) { // ── Sample cache (IndexedDB) — generated WAVs persist so reopening a voice or // re-running doesn't regenerate seeds that already succeeded ────────────────── -const SF_DB = 'seed-finder', SF_STORE = 'samples'; +const SF_DB = 'seed-finder', SF_STORE = 'samples', SF_CACHE_VERSION = 'wav-seed-v2'; function _sfHash(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h.toString(36); } function _sfDbOpen() { return new Promise((res, rej) => { @@ -268,7 +268,7 @@ function attachSeedFinder(voiceId, body) { } const seedRows = new Map(); // seed → row element - const _keyFor = (seed, text, backend) => `${voiceId}|${backend}|${_sfHash(text)}|${seed}`; + const _keyFor = (seed, text, backend) => `${SF_CACHE_VERSION}|${voiceId}|${backend}|${_sfHash(text)}|${seed}`; async function _useSeed(seed, row) { if (_currentAudio) { _currentAudio.pause(); _currentAudio = null; } @@ -285,6 +285,15 @@ function attachSeedFinder(voiceId, body) { } } + function _seedMetricText(data) { + const parts = []; + if (data.dur) parts.push(`audio ${data.dur.toFixed(1)}s${data.clipped ? ' clipped' : ''}`); + if (data.genSec) parts.push(`gen ${data.genSec.toFixed(1)}s`); + if (data.rtf) parts.push(`${data.rtf.toFixed(2)}x`); + if (data.cached) parts.push('saved'); + return parts.join(' · '); + } + function _renderSeed(seed, data) { const row = document.createElement('div'); row.className = 'seed-finder-result-row' + (data.err ? ' seed-finder-result-error' : ''); @@ -295,9 +304,9 @@ function attachSeedFinder(voiceId, body) { `; row.querySelector('.sfr-retry-btn').addEventListener('click', () => _retrySeed(seed)); } else { - const durStr = data.dur ? `${data.dur.toFixed(1)}s` : ''; + const durStr = _seedMetricText(data); row.innerHTML = `Seed ${seed} - ${durStr}${data.cached ? ' · saved' : ''} + ${escHtml(durStr)} `; @@ -309,14 +318,31 @@ function attachSeedFinder(voiceId, body) { seedRows.set(seed, row); } + async function _fetchSeedSample(seed, text, backend) { + const body = { text, voice: voiceId, response_format: 'wav', instruct: '', backend, seed, seed_finder: true }; + const t0 = performance.now(); + const r = await fetch('/api/tts-preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const genSec = Math.max(0, (performance.now() - t0) / 1000); + if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } + const blob = await r.blob(); + const headerDur = parseFloat(r.headers.get('X-TTS-Audio-Duration') || ''); + const dur = Number.isFinite(headerDur) && headerDur > 0 ? headerDur : Math.max(0, (blob.size - 44) / (24000 * 2)); + const clipped = String(r.headers.get('X-TTS-Audio-Clipped') || '').toLowerCase() === 'true'; + const rtf = dur > 0 ? genSec / dur : 0; + return { blob, dur, genSec, rtf, clipped }; + } + // Generate one seed — reuses the cached WAV unless force, and caches new ones. async function _generateSeed(seed, text, backend, force) { const key = _keyFor(seed, text, backend); - if (!force) { const c = await _sfDbGet(key); if (c && c.blob) return { blob: c.blob, dur: c.dur || 0, cached: true }; } - const blob = await _fetchWithRetry(() => fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed })); - const dur = Math.max(0, (blob.size - 44) / (24000 * 2)); // WAV bytes → seconds (24 kHz, 16-bit) - _sfDbPut({ key, voiceId, backend, textHash: _sfHash(text), seed, blob, dur, ts: Date.now() }); - return { blob, dur, cached: false }; + if (!force) { const c = await _sfDbGet(key); if (c && c.blob) return { blob: c.blob, dur: c.dur || 0, genSec: c.genSec || 0, rtf: c.rtf || 0, clipped: !!c.clipped, cached: true }; } + const data = await _fetchWithRetry(() => _fetchSeedSample(seed, text, backend)); + _sfDbPut({ key, cacheVersion: SF_CACHE_VERSION, voiceId, backend, textHash: _sfHash(text), seed, ...data, ts: Date.now() }); + return { ...data, cached: false }; } async function _retrySeed(seed) { @@ -329,11 +355,11 @@ function attachSeedFinder(voiceId, body) { // Show any cached samples for the current text+backend without regenerating. async function _loadCached() { const text = textEl.value.trim(), backend = backendEl.value, th = _sfHash(text); - const all = (await _sfDbAllForVoice(voiceId)).filter(r => r.backend === backend && r.textHash === th && r.blob); + const all = (await _sfDbAllForVoice(voiceId)).filter(r => r.cacheVersion === SF_CACHE_VERSION && r.backend === backend && r.textHash === th && r.blob); if (!all.length) { clearBtn.style.display = 'none'; return; } all.sort((a, b) => a.seed - b.seed); resultsEl.innerHTML = ''; seedRows.clear(); - all.forEach(r => _renderSeed(r.seed, { blob: r.blob, dur: r.dur || 0, cached: true })); + all.forEach(r => _renderSeed(r.seed, { blob: r.blob, dur: r.dur || 0, genSec: r.genSec || 0, rtf: r.rtf || 0, clipped: !!r.clipped, cached: true })); clearBtn.style.display = ''; statusEl.textContent = `${all.length} saved sample${all.length !== 1 ? 's' : ''} loaded — Run to fill the rest.`; } @@ -495,13 +521,25 @@ async function _seedBatchRun(ids, from, to, box, ov) { const th = _sfHash(text); for (let seed = from; seed <= to && !cancel; seed++) { msg.textContent = `Voice ${vi + 1}/${ids.length} · seed ${seed} — ${voiceId}`; - const key = `${voiceId}|${backend}|${th}|${seed}`; + const key = `${SF_CACHE_VERSION}|${voiceId}|${backend}|${th}|${seed}`; const existing = await _sfDbGet(key); if (existing && existing.blob) { cached++; done++; fill.style.width = (done / totalJobs * 100) + '%'; continue; } try { - const blob = await fetchTtsPreviewBlob(voiceId, text, 'wav', '', backend, false, { seed }); - const dur = Math.max(0, (blob.size - 44) / (24000 * 2)); - await _sfDbPut({ key, voiceId, backend, textHash: th, seed, blob, dur, ts: Date.now() }); + const body = { text, voice: voiceId, response_format: 'wav', instruct: '', backend, seed, seed_finder: true }; + const t0 = performance.now(); + const resp = await fetch('/api/tts-preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const genSec = Math.max(0, (performance.now() - t0) / 1000); + if (!resp.ok) throw new Error(resp.statusText); + const blob = await resp.blob(); + const headerDur = parseFloat(resp.headers.get('X-TTS-Audio-Duration') || ''); + const dur = Number.isFinite(headerDur) && headerDur > 0 ? headerDur : Math.max(0, (blob.size - 44) / (24000 * 2)); + const clipped = String(resp.headers.get('X-TTS-Audio-Clipped') || '').toLowerCase() === 'true'; + const rtf = dur > 0 ? genSec / dur : 0; + await _sfDbPut({ key, cacheVersion: SF_CACHE_VERSION, voiceId, backend, textHash: th, seed, blob, dur, genSec, rtf, clipped, ts: Date.now() }); made++; } catch (_) { failed++; } done++; fill.style.width = (done / totalJobs * 100) + '%'; diff --git a/static/js/settings.js b/static/js/settings.js index cfe1c41..fedecc5 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -203,6 +203,7 @@ async function refreshTtsBackendAvailability(selected = '') { updateBackendHelp(); updateStyleBackendHelp(); updateBackendDependentTabs(); + if (typeof window.updateStatusBar === 'function') window.updateStatusBar(); // Call any post-refresh hooks registered by sub-sections (e.g. conversation panel) (window._ttsRefreshHooks || []).forEach(fn => { try { fn(); } catch(_) {} }); return _ttsBackends; @@ -211,6 +212,8 @@ async function refreshTtsBackendAvailability(selected = '') { async function _patchSettings(patch) { try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }); + if (_appSettings) Object.assign(_appSettings, patch || {}); + if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true }); } catch (e) { console.warn('[settings] patch failed', e); } } @@ -455,6 +458,7 @@ async function loadSettings() { sv('s-xtts-url', s.xtts_url || ''); sv('s-llm-url', s.llm_url || ''); _appSettings = s; + if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true }); // Restore engine URL inputs from server (overrides localStorage fallback) const savedEngineUrls = s.engine_local_urls || {}; diff --git a/static/js/stt.js b/static/js/stt.js index 874363b..0cd3d57 100644 --- a/static/js/stt.js +++ b/static/js/stt.js @@ -47,6 +47,8 @@ async function refreshSttBackends(selected = '') { sel.disabled = !_sttBackends.some(b => b.available); }); updateSttBackendHelp(); + if (typeof window.updateStatusBar === 'function') window.updateStatusBar(); + if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines(); } function sttTtsSelectedBackend() { @@ -286,4 +288,3 @@ $('stt-tts-save-wav-btn')?.addEventListener('click', () => { sttTtsDownload(sttTtsOutputBlob, ($('stt-tts-voice-select').value || 'stt_tts') + '_stt_tts.wav'); }); - diff --git a/static/js/tesseract/lang/deu.traineddata.gz b/static/js/tesseract/lang/deu.traineddata.gz new file mode 100644 index 0000000000000000000000000000000000000000..124684d7658f34a662f81f1db9f21de790bfa4ed GIT binary patch literal 854318 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

{-oOv(1 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%0D!&wsS^5-i0w(_p|9BG{ugA^fd z?R1q&7??p2`qp$@^&qpDm*~XqE-?0y>($l&*I;N--<~!a7v)P{Ee`@1ZipY;+^oti z1=d-ujZieAOCvhHecjb8q-3%EO?fN3B=dGPtEx~eld1vbYIUiuDso|HuCxl2ahT0^ z?jzZWp*#zVuz1NWc_kIC?Z}cj7!&a8$uLbssZ=JNO|6tV?`$hl`r0Bdt}HY-EJCkh zvQ#E0kp@dx9%!;*DM!?7uSnGpf(6AINfhO9K|m%V-~zAE@UcL)3&~IrTyPMgTD_`F zH!v!=1vU$1E8WBds8B>DnsKIusx*DlxX)M>vkMSA7UCE=9o?!j_;4YQHCSv5(oR;C z3=PXe8?vSU-Ph`;)KZRC1DI6Jv)#a&*JdM?loDq-QYk^EIaw0whiWVyK=HZqRAQ@{ z54XaFx>v1pre(tiLBYY+tr>kKx`RTiFkG$8G~#oFG=^;&2&<~c!RaWe7pgR2n7T;)`6_=hDy$gEG4fgnf6_0x5S`3 z?|smNKm~R+exuUolIMe};zG6x9mOr}x7Srw=ye{KQZ-~LKK)AePox^XBq#K`?@2d!a(FME7?em zOC;lWJy~Ya7P>RX1C3Qxg)amNB|r02_)>4`IXNfN;lQ|Fg{0RLoy4Tm-2RT`ODspu zV_1u06@A~p1>Py5L7-rvqxMj*&zy?J8{Q$abH1(T^b?2lP2RD(#J>>9@fe}2VXWF{ z@>F}!>s!~VialfwT{g*hCXcXhR*pG}*D%}JRgdu-rfu;|*sS;$`}`WET9~UNkDcG1 zFQhe3@%xvbjZSpG@=YUc_;q|iC1gQ8mqz^3@>b>3%_qM2@ReZrLOFPfk{9+=$9_EeRJm;{%Y7gyI`z+V=8*h;Wr+JY0ZF^+X}O?QU~*{FkjZNc^xkmN zD3*IdYSJaLldxiCjKB|=#bG%YHZsN5GnG15KkLVD5Y7ZQ{ueS&;V%pADwl81CdJ7H zLc{edJvPl7v(TK%(73v5dypL7SmTy6cX?Ou#0rohLEMYIUP_Jm^`OJo*ranzNzf&di%_Q4GIkn@^lV_%vhcDNUBs>obU@o`?mHwiIQnX$T zn$eKe3vzAgRji%PeT2mWEwfLY zM0fA;GWo;G=+3ilXKUj@yC@pFMrERpwKvSCZifO2YmiK#o81Hbj%)Ylhi0~i79#OZ zdZ|Qet{Z8wF_Pos~63k;o8{l$BF*fIn|f; zP764wbhG3f<(Vk@@r3{udC*$-@%Y)1?!+k%yS?&*NdczP4AeBC`8{vqJ=xCZn2~&ZQ!Svok@#G9e^DI;o1QU|ceUq@i`K!2 zB>eCk=bcM4iXAnUv8n)XfZ{P7uI-nbgq{{jUN6MFzG3dqy}jq-{IMfa5Gk57pac=@e==pB|>YaO5E-;Xwt4HfAN-0df7g8Miu@hnZGM2aIUGCayfMI zfLUk%4BI&SO4xx)r{>Knb$d^<{3U#v&L}wZc+W5TgY~ez#^)wG!mb4kY`QH1Rt;5^ z7@i!02!oL-$@VocZ5&F=YHrU!OCVkfsofY(meL$<7#PITV7Xk1q^L%t#2w>E`I@f0 ztO;`p$yf@^$4RCz zu$<}lrsJs>=EfBp^0fbhY&KWfJ3i)v&+&a&#(3j;dQN&-RYws!RC zZlz$pbvOWPv?5ij|FS6_*tdu7!!klNm)u{?CmbweMqXJehTaZqw>U&j{8R{? z^VY3Ui!-TuAgR>4Fs$-PC9eCo*jsX2sWJg3&sev1ZXW5vs~|7oNt@c9UGDy>i6d9KDpkArqoy`QFWh~b|Z2j(m;KldPO4IETEsbim-09yiY9H9p9?@L8WF-tjGp>gB; z*3AiX*l%yqaRSno)mktnADz z^-QZ~7Fo)W!jy<}{)7}RbD91mX^YdWrVM2>=|#j|4AxRgg_2z^0cD%glzu;lL7-@t zucc5*hY(&4tmI7GTApI0F$NVPLS9qwR#Og=!GtkSff4NNyWy0#r zqfUAh?!~>TTM<5G?xPKVtDK9xr=(mqzLzXiY7*o?Qy6LpuULVSmL(A)%RvhZ^%=&* z8>~DL4Y!v(Q_Jzo8BUcpWVRa|F;C`EOJ!LQb&A$dMM$jU%M>g+pKm+ES8@$3DJKUq zGa$zkHJn+fb6QrB!qMXH@J4^?NEYp{#6Gt3`Fd`-Og5g#WFz796h<{c z0GHGo31ldl`8}90HBmM_jPPm(7Zxj*>2%)_DaIZKZLJQyic(*d86ob@H|e zXq8MLYu-bMj zI6S?dOgp}S7qgU3$U#z&?rLIT{Sa}Ve?~5@!aQYQ@w4!~reIiH=7Mm(9_nENpFT#k;EPsE~)T*d4B1He$3u^_s{?uT228>Lw@=~Ss%8@A}n z2GO0{q2onx>G49gf(`aiWAEU(;RaRUhSn|DRc82LfW?`nI>E-vS%dl<+9Y5TiPEy} zhIqAv*xYRPrW4i(&I(H@cq=37XfZ3*xTOUD*Ezri-m?fQeMk9Vhe@1}H{ow}sw+i+i9ySI{jGKlDM*?Z=9hslB@>OO^bFfDn!70zjt7?L8w-*(yLz zA>8TN=}Aa>pZv1)<-2LEtm}QzKnVJ2VEIGbn%7thx|VE)`AL$Ev$QvC0hu}79Yz9U zRu&_SG>SE=tkzb_$YFH1*_mJMe!}!Ij(GFza^2GzgdWVx@n<9{d@vCssAlYp$A1!S z7^U-th`bOcRwh5?4o`43;o_SQ23%;ONj>;Bo6`jxIStF#TC1fU^r1}3#==!CQ{UI0 zRb9pl%X{TvXtw5P?*1cPNhO5EDaA5(PozCg32*r6ZuRC40Z3|@wlS19sG&Yg7Rx8` zRHenc)+Zy&vGZ-GpHg5*NDkR%_ZD$kEdx9nJ2J~zZ4@-_$=WR@-(gJhdAMA3D<^LT z+o=F0sgiAL95z_k7_fko&y>rc{7k~3rWtaTzsAJoST!uaTjtbY{_P%YI#1giWD&|% z{PJlV8)nNq%j`Nklmt?&X?l|a`R%Yz4#wJi$%^~f)bKi)bIpCN-kn? z(u1jd6J3;6o6TTM;9{etq9QFgxfVPAHaR0Zo&dGetxDp}{4JX{oNw&niOf?~G6(-O z>)r_7sLQ4GHl&7{EBgUa{b18k`opDuWk1q}ZRz|u8`yG1%}WI1sqJZ9>##a#-dcrq?2VKpRe8$~S&b8$phBcNk>*Vf1O611jDfsA%zDP=gK^zRjx49vaY4fA;}TD@iuC2d zsU|CMgiM?=ixegD#X>cJal&QI3kzgoGm?i{muFg;y$vzM$o26+sz>H7md#vIUeQhH z3TNL8G$JQ`?vFms8=u+PNxaY|F(_Rx_8u%jk|0;hy_Qx*-RoT0EU$uRtHCD2;TiyA zeWzh&p?>dxVku$a$gA~0FLwyM+bqo7cIp|T5b@YhwqL-$=~>sSSxul!<g}_ zYJJpE&|$Q;^SQ0Ybg@IMVhi*ud}AWn%v2KGY|K|#wQ<+l|K#mHIL7)op{q=KDs9P5 zi&)gd;0i1hZ)$nlsqle9z}q2d6ufbh<1l>ru&tVcJNHN_k@m}n<4{1g#CYB?hHwta zH5daI`0QR%Q_L)=rnhm|EpaGygRD6Z%CogT1jM3^ zCemBvCk)PJq+W~SU{0XHp-h|8sH0e6jxOoQrx|IMKY;gnvYk;>A5J}#X=VI!%?$<* zG%8qjtH>g?58%QMkiL&K=`^mxilk3Rt5}#-q>DBokqqiX&Zt89KC(I644d3;D)UIm z6>XZ1-x;cBbh?0fj8KXmQ?23W*}IB&xaSi~k>>uU6yq^970JP;TGWX|_b7a1*Nslm zmTpPHPW8G-Xi&`QJdh#H=0CiT>n)1Vr?7RW5L6R2#Rf|{T*$665hl3_6|3yH;6V`r zlL}ZsQW||i9cE4_p=(6Sq0D$dpE4I5tsO^D7jL^dOgGuhT4cLPb!HMQI|w1%G)uUn z>I3V$Yb9|hgyi`dZR?S!Y&2_Kc0MEmM9;EdJwOGfI!+F5?RNo7%b6XaOg8|tTbaf? zubo68@-Vl(dB$CTRh+Y3FCEyGZC%r$fNQbf-K^Dob&q@bv(>rk_vz|^@K!YHalqY>c{q6S`U za55r6r3n$Ksw=_eW}2~LQp7IE8!)SlbDIQOm6`>%jkAd~uGS)=+Ny6@9S+0Fg9S9fu>BV|P zTwP|-Ryo-**QhqHSA@wTG^8N~Q2+>)6)aG?$r9IP3wabO%GF65qe*)(cc!c^w7I() z;fQy4jBOX_4@gyY9SoqR6ziI;qJm^alc~ao{bJEUTS7$lk4F_a+T_#XclFR#mG4}O z^ZtJny>*xz>3!f|%+<^c!!w$ZMvSw9WispCYTN`|2DSiW0QJ@C}SYY}$e>C*HAO7>?w;un; zssDz2?LFVAuTK5@##ZyG<*!VYE?Tn-wVzi zIQtJ%#Gh@Z&%E~!Qy*?WyM+An-%gGEkK21!|LO0izO`4sI47&XaDwBQ$J~a(|z{yUz_^yxiEF(p+BD@$8t*_A-+8I7r!4H&HvlqO`V8?2mkTA zU!FQE+yCUZvwuES|HBpe#b5l*)a?5|wb^fdW9q;E!Ikf7zxT~4>z^iKXFl}hsn@YD z{6~24FQ-D!f7kVQ_x^rr`S?$1>I+|;dib+3l6v;*Q}h3T{CI`^+SEtCmExXz>Kjw$ z|0OqX-1_F!<6n5$_mQoCo|^so$dlyUUr#;zk>a0TA-_HaP(S^bzj$u?glE_0(Uz`IYCM z`pVRmuiq{mI3&k>Dqt&iKlnzk1@Yr>+?bdq4QWuT1^?`@g&AZ+7?ln;)G0(r4fM>eP=%)_$1y zkFQMqZtdQmJ@curP5r~S{w@A{&;8Yu_Uccs9r};InR;semc?&=bE-P|PorwQy0GYN${;7|Hagmt<=vSAO5?k zUqn}GkEgyqb^jl}_^%fq|Cgyx-AwoY>+k;U)Ia^6{bAc5e`o5CKKJv#`t$T(P3^xI z*OhO7eG1&of%K=29tDuyg(p7z%##3kX?N*2sqvdn;J0A_`^}c${qOsZP9}Eu{;S`t zPX6Yw1+$Vk&5MxiFTiCET_UTW zwrE@Rb@!fcUN@QHHuzE1C?wWegX>Sd)p5T}XnAmXb$wiU)6*Nxl+WFZO6@{@CZ+8? zW#4&&b>REz`eV`cb?ML8MW0~Xr@A@*fZ;V)p|3quOh2?>Yl@Hm8pJ!XC0fpj7DpX{ zv^8<4zM!EO4n4%=jUt2t5uQtWC*aEt*Oe4C+1HTtUtYv;GYn{3zkh zt@ffLG)GhlA9c5DBkl5g?(41R)q=ZYcO9^(DNm<*_1(~H)yl?BPC(5){qc)dHdTAT zcOWy0wSR(Qvp$_fUPp5D-7MXfFj}8(;(CkNp|@z=o5=>>3WtNK!~KeW#y`mx9x58O8miHP_SPb#C}ND9;ulzaPRrgG(Wos|L%>cWc*ss zAo0GcS2k*7r_Uz+`Un)_^IZ>@+$rZG*BARMaa++d569cQ79pb>p_EaB_lo@|y`7^- zGiAzDtJzyF)Fs8LkE~tHF6D~Ko#uRpw}%l)s&=KJiHe5SK#KM5W#=<)&b2=e>f`Z% z(AM&AxQaI7c?P1bS)taex4J=fi=rLWz74B#-p~YZhun-gC&Sw`JYT)7M3gFd+JTmV zl!Hj<$znz(da~xEM&a5@?nSqs(;_mInGD|LvV+TM=v^qj&@&_;?0o1m`L zN@k$iaX})l1AP@3aWGoeWlv##ZU+c=5JYGw0WRy3eXzkbAm=iUTza+=qAM~LYlOh= zb*sY2>BqtK_M1*Y@QgU*r-}Sn)q0~3q?Kb_AWi*oUbRh=wOGNZRDj~G{2t^7cLJt+ zW1S$sL-yD8YvPqhKk)Rg^lbaWf}>d8?{zK?R)KW=)G?%mwi3r>a`fF$+2*~3?=Kod zr>f!5{hjyu)l=7=YVFk!ZF#wWE)?!=3Dm_ip#o2O+RBI^+SpZdsm=pqEPh3AQahQkSCmhvJNlhXi2TT z>phoi z2!Ni$*$2u)9pnbYuR?Bjj1C}9SCNFeN+;T7t|x`mm2P#AG{t~noVE(iquCKNWC=_q zEp=m5#uQ~;S8ybR)NFS#NY0fs$y|}bdZmLjEltkYJicnL;;E6CJyFIHV~K`20&UYx zwXLJFiS^)dij&AjS74${cB?phm=l6pR$6r-$%3?8G*!H4GiT#tLij#(dJJ#*{Pmp( zJ|MPKa$+6PLA^O!S;@kaT@8fE*2hpq6>WKWAQcCFPNn3hl?%nD6F|12EK67t}fuIN~U&Ek{jOfe~$!^w-nxQhCdm zcnNB56(!S73q8Z3tYx|#%ui4VrdKIW{+R*Fj`q4oj$;yAV?41H>9{H?WLg{U#l2Z@ zu(L#5F@AoWi|-ugh8s<<*w=VNplm_6Jz3HedMjjn!TaOol`c)M8oXSa18hRw#9#qE z0jW9^99RU!k-moGSZ}};;9HfN)X1DFI69gOhk>P(W6ij@uQOXO#xwo*))%45PMfb~ zr??9ea|ZxJywSRZs~xCGY&acUr$@J(bXA<$UUyy`l#W0=R>`71@jeW9 z-`|;UYE~AY*>ivs8KSf;pJ$@d>~v$gK4|lS0O{Ioo?8`}&Do?iA%GxyK_4uo2O7K& zcasJxss8zOoYE!}!>O62lCU-O|6m>6qT|~Wp*KOA{ z7xfE}5rct&(%YGUwzs^6z zMy93=?n!$y-CfW)+v3UvPBoQA9wOs;=(x{Rct%sCIgX8GNhu;4bB))Hdy{{Ay4 zpS>^_(@j*Pftxws1A}^N!G8MmJ*XgA(6axXQS3Rkl_2_WjzEYFn=_O^C=&L*UYzm` zOfR3U=?aj2S2e6m%kWKnd`B4It3d10er>EZzHk=O`;tUaYP~U3mFkw73y(A1yr&h- zNnDqGYeZP)h+(*7)h@rmj~%=_t=zq6o#_A}7qykVqb=3s@*mzV>0UGh`|W{HbQKyf zT-ar`sy3K{=fV963A4RYaM1z5dfPR5OL{@a)%L&um{9_-@xEqi zBi7w7A1j4!51iNg!;xn`cbDych~1EyekA1MaglIhqF5;fHAFAD6gwn_Oc!ivylml(j#n2Mio+TzO?KPb zZjD71tgJ#{SET4JereQx@rC2zS?qxj!=i9cI(EJ z?N)5WCiaKIZ+^K(HT9JLg{HLQhdh{k57Gi-Tb3Ax*Vm^zMc8P{&ZOHxe%S&!Yo~>& z8;%4oYz*Mtm*hCG^K&<%t$oYpn2adYb+@<`X|D=6(5&L?s(*+erM)%uQG*^u+i6R- z?rJC-#FLssm+e-wS6-gJDJV|lCI*8sey2@@_y45=%tI!DX_g8**(|_@coM!vH#f(<)3T6~>p}rWZZ`#R#|M*Q{;o zoJhx>XcvI00-B5!?}7@n5VLExK4n8jAzu;Fy>fd0DnerWaM|Vq)(d{P_pnq-<)oWc z*rr2{UA(zGLF461S;KXNepY0HVJ~4R`h8zjY`)aX7#@EUwwWhO4Z8J+;{uCI@8O8x zv%}Fe0%koXF8THvgx!5f9SUGjuY0rbtD3kM|n`H=aIaED5h$yqR5*@pchQS znX)HQ4TD=Y+ZxrZg3~gaNsKFxQPM6IS5VjA`}?*cH3&?bP` zKUtvr>jR{G`@mnHHB1<`W!J9i7Pekv?94V3M)tG^w}$;c>igwmf>>oqd}lsI2uF1A z!RUV88RURAWNjet$NRu^$E{rQAD~vLYjQy7siCW8tPK5B9&Hc3f>G_O@2^} zA;Sz24tyWg-d>qLT!Vi1h|r_L?ngc&efTNNnb{YoI0&{E2jwMXqN?kY!p7?eF9O{G z9~ZokFUgGDd|70iZ46)R2*tN8*ne%ed8&bTdd|tQ2}<%21ae5DR(3(Y49KQ2Z7Y>w z_qn2k9;0%#HM&$5sv@*=tQ=>;2}k+C5{W&M0D+1v8+IsKqnfX;qqNDYqF@=8 zSGm1qG7J*pRZ2j%P+rHHn&KDjG-#lj5v3sxZD1~_hh1?dcxZJ)wt5ypP z`@{yR-;_|Msxmx0klc;dUnoh4bpaKBkArxM8PM$C?vIX zenkdR1(2S^&7NP_sdr7LqwPbosNV-Q*8>!*vxpR?N(!=DYGfxmQ4u@aQd6y|24Je& z1`;HYIxksu7EzN(q_bJXCRoLBISfc+@^KPIR|ZW1cpYqP|aJHF)}FzL13o@ zcPz-KOAeHB@38?um+VzGnNld!0I-e#xSLKTF|E{a5eTi{Hc;3o_yJ>Br+`Q#5UP@> zOJ#D92eLJST-Pf0w=vO_^xxb;$(tC!b_~8WEPhg_LW0p(B#JDB+D;SIgWSxx=UZN8 z$UR#XzFoaKL9@s_8+V?&+5kuGx;jmu=eu<$lkvyf)>`)6NBXw3j^04vpF54g)N1x) zj?JZsBgL1;Z^5_S3-jQoT4(Gi+D@GKP}2E7p?4D5%erEtzPHR_YhRx96f{UO#)2mT zP(~2a|D`?STWaqSH(%D=oWTE!5hds8V^-+x)>pa-`<;bFzAXEF-KdROy!N!Bv~1*y zF*?5wua}3|&V&=bu4M;{sNI!!FIS4PU_;tAD^qQ*Y9Zp#ZsuYoKz)QlyDOKAmBe}1 z;s)1s41^chyIV8E6|Gk%{KG?FmmvYK+AN+3V4#`nyExDLS%U~U=X^Q{v!_l{Pi*_) zpS-d2(r*n2v6so02)V?hDD418{W{)u4QEaQcRCgF z&CKEwfQ>o}?ya5LtMZRHUv%)FMsMSJYcww*2O7|PckWF`K-x@juIh9!cQyI6`4KWW zc&h2XR=;Y4emg?^Xs&<8(|_Vk63XB95_xCv2B6k1XCo`=8+WegM?b;AleUmUY#i}h zO6)3e^FzPSRei@J3yCRgeB=)x-Dg}YR2nMPB9W~k1oV*b{)T@r>nf_Z&C)9ql+S)A z+|0@k^xzb>N%zZcu|P-br$Sre5u-v8cbJ;~ecnFm=h=Nqw_1J=IpNLJH+5l+Kb~zm zuhPIz(wNZiRD-`>>X1(8aLiwt7`?5*H;Ai~S~7FMxuib0;I)o~Z=jtGYhg%{DsdN^ z^eQ3Gs4RYVqcDOo-DPd;ea>2X90zu2yQMml`_NabV!L|+U|9pHcY z=6mQXdi}oId}#S;aw+-#`Oh?Z>%)HyT=FCyKLLMch4>CrZX?ZOe$H{SW8RPPuc6uZ zr!QfozW=85f{oj@6R%BQbN`Iz5~!Z4^Y(%(;T3R&wo49Tjdt}EskOd1V1pr%5!%+U z)^&$pquX~_|LP>sZ;T;O(b~jug3-LVm=aB+7bCZuz(yaR>4xkzrVe(kkw+G#_3=b1 zoOQmE(4cyNOL2^tj-T1)U+_jFxSEqoY##;3GP7Q`M^!2Vk^{tqb29k+Z+0e`T%h=R-3kNpc{I+2m;Rz+HMidUH^h(G3 zDq7fSy_ZsJh8{gP`X1L0Q z6=C1~c33JMTFoww}m0r3s98=^znhFy&C;Vr2{DHPX;xg>d)P-LOpwsY%NFf%@JmhAyD zS&s>)94pZzmfgisj8yuPJFPU*`EE|I-7i4XqxI|SMPU%WKy_Ev7cviY8bqMFl7W3I zewrzZfhVvDI^YaxuD~9KTQr@*c%4v1Xhoe)HE9)adpNG0!RzRbXOXXG{lD58nddA& zVl0w9mINH7%wF!lIs!pQZ&V401~3K-p+|HN$S-vS!!t@#WLGE zR^^FtTTN@7fI@eBWPJ1EO7>WMvYR7-i7Ps}b2dp6eQwaQ?5-n#p+5lIwlVypJC6*g z`T%vdJDJ?uhjawZy|=}N>}=&Ge+OOK*}OY~brj!M_~j{^H&U1$Xog{IV#%10UE`)4 zb`oA1IWM@^z-&HaBjkkDsY0<%-JjfQcLU1S7n5)H_Uxf1f)R}9*p%8}CEx3J2x_|G zuFhiA_MJnF)J`8g5PXVd7hStOpJF;{vzqQc^mp5De%Dn-N`!kzL**jL=B}xS#vru!e}!90+;Q66E&=r4hWGwhsb)FEG;&j zJr>!X6wzQrH=6{Z2fgjKLTc_-7lPJ2e9H9gN>-aVafb!;goPfI_+0~@J&w@*}PrQZQ~=4C#D`DIvJJv2C5ZqdEul)+Cpp2#9dF%AfMyaSp9q7oge<8@N8|a*T6e zj~M1q!?&l+&x6-XR=Rjzphmf6_aqhjc4w}&t$EHJkPXIEGR1W8q`Ta&=7mmorM^)0 ztSEKYnDL6PY708n{W5DNvQ4nm2=kp3j_D0iS6Y7C2I>mmF$2SpSr~G6y@gfdNLb$9 z+}g9axm{>H;^>Dj4&59;#}Tb{lO zY0CkzY^bFQuQrh5ghQu(ExT;hY$kDhc;E=? ztaVcU=F-|Pp-p#v1F}%mE9`M9-u7V4<00_E9R%w0&9mLe9`ilATHAQ2(*2Z)QvX{z1RY+ws11$abS+k_xPsj^CJFYc5o@vQr+bd(dbNoi zEc)^bH3i3?Q5Zg7sxs%Gom26B|p=!mIx_x0xdJM`xh z+`I0ETg*QPE?Dlh3G0ao-(=6{|NEo2edh;Vi~ttwQ8f4N#p zCKl7o-L7{HiF*p-C>=wL+U1p!|A7Q$Kp!4dKd?;U?hVi3Z8x+ZJxjH_L68oF<#7FN zSJ&n;Kt`n|3vMzRO0zB-4nhMvYP@M0<_;RIiYZzGH!7859Z~4ks$|dDQYCDAeuu{- z>pEn%gT`X?!0wj`AG9>QO42#2Nst{CyYLaH15bRdUU~hb5BjPI79II#rf{%@=q3u8x4zu+MZIN?_Tla zRqwEM`FBz(tZ~)8d2{OK?Zq!vs-ya~{EhLdIxw+j^f}f}wkI~^2A@oJ|GlHUkXTZk z+~AHrIym(0SIHkmUz!Flm7<75-N@d3SagBdrw)Hp0 z24nN1m;I-mF`R5jvBAFZ%IyxpbT+$*2u1x+Y^}{pH?Ut_c!c_A?CK8opz$d6>bPL) zHCON3~Eh(v81u?7lPUds3S?$p)c0=!1;?Jt}h>e4F??7nR^QTnBx1JgF}(+ ztnKCOGe+O6;Nx zCFO=e5a@DtaF_3WMHWrEFdh5NefbAc_@QelQFZN{_xf~)iXyRJhw;ro0Xt#XJB~i# z(#_c*s{O-jOHdQAs5!t9QXMAgU@P6#bG3B$Y@7t~M>Bu1QNHWUP!APPbuuJ9o*J|} z=iZwM7T{irgq&`mW78OiNExO%Waea}P334?Rgo=!1}0k~;Lx3zno+E((@^a+mL@TY zQbdSq_jFZcTt!~jHhR`3D{VPw!v>34a28;j(ylM2+dXu*NC_fc*GSo?i=JKmb19-~ zZ_1?Cu;)yj1PTJFTOBT85iqF)`-~FmDIr9|4Pa*Vh$dmONDyt!>4`BbL-XCxs zIPicmXF+rJ7xH@+gpR0+sRz^B4{S=_6Gs&7ai9SI=|=5j`|>Dw^qJeHZ9Q0JN>;8z z>sA0)EJW`agz~D;vc5~hw!vn183kR6r_A7R074PX+DltVj`NUZL5kSe4%iBV zRHui?gpxGG8<@+nS}>F};$~riZzssCB1MLD$QVdd%>8BXNk-tqN?^C_H#o*&i?%E~ z-(^)TQdUMJzWLCoy)JDiZ3Niu#!g2LMPN0+IyWIZH^3gtXYAwtj)=~J!yDdqs+CK$9W=(%`*x%?{rz)pEU8D|r=hgypl^

+ +
@@ -37,38 +39,76 @@
-
-
- +
+
+ + Playback speed while reading.
-
- +
+ + Blank uses auto; a fixed value can make repeated synthesis more consistent.
-
- +
+ + Lower values, for example 0.3, keep cloned voices steadier.
-
- +
+ + Backend voice speed before Read Aloud playback control.
- +
-
- -
- - Drag & drop a document here - .pdf · .txt · .md — or use the Import button above + +
+
+
+ Source + Paste text, drop a document, or import a file. +
+
+
+
+
+ +
Paste textPlain text, Markdown, chapters, notes, or draft prose.
+
+ +
+ +
+
+
+ + Drop a document + PDF, TXT, or Markdown + +
+
+ + + + +
diff --git a/static/sections/s-voices.html b/static/sections/s-voices.html index db0e9cb..d60db09 100644 --- a/static/sections/s-voices.html +++ b/static/sections/s-voices.html @@ -92,7 +92,7 @@
- + diff --git a/static/style.css b/static/style.css index 7c8b8f8..846211a 100644 --- a/static/style.css +++ b/static/style.css @@ -4,11 +4,11 @@ line-height: 1; display: inline-block; } -button .mdi, a .mdi { font-size: 15px; } -.nav-icon .mdi { font-size: 15px; line-height: 1; } +button .mdi, a .mdi { font-size: 22px; } +.nav-icon .mdi { font-size: 22px; line-height: 1; } .section-icon .mdi { font-size: 22px; line-height: 1; } .llm-card-icon .mdi { font-size: inherit; } -.llm-local-icon.mdi { font-size: 20px; } +.llm-local-icon.mdi { font-size: 22px; } .backend-metric-tag .mdi, .llm-metric-chip .mdi, .dc-metric-chip .mdi { font-size: 11px; vertical-align: -0.05em; } .link-icon { font-size: 11px; opacity: .7; margin-left: 1px; } @@ -1019,7 +1019,34 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- #toast.info { border-color: var(--accent); color: var(--accent); } /* ── Status bar ─────────────────────────────────────────────────────────── */ -#status-bar { background: var(--surface); border-top: 1px solid var(--border); padding: 5px 18px; font-size: 12px; color: var(--subtext); min-height: 24px; flex-shrink: 0; } +#status-bar { + background: var(--surface); border-top: 1px solid var(--border); + padding: 4px 18px; font-size: 12px; color: var(--subtext); + min-height: 24px; flex-shrink: 0; + display: flex; align-items: center; gap: 16px; min-width: 0; +} +#status-message { flex: 0 1 auto; min-width: 120px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#status-engines { + margin-left: auto; min-width: 0; display: flex; align-items: center; + justify-content: flex-end; gap: 16px; overflow: hidden; +} +.status-engine { + min-width: 0; display: inline-flex; align-items: center; gap: 5px; + white-space: nowrap; color: var(--subtext); +} +.status-dot { + width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; + background: var(--red); box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 14%, transparent); +} +.status-engine.ok .status-dot { background: var(--green); box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 16%, transparent); } +.status-engine-label { font-weight: 800; color: var(--text); } +.status-engine-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; max-width: min(34vw, 420px); } +.status-engine.bad .status-engine-value { color: var(--red); } +@media (max-width: 820px) { + #status-bar { align-items: flex-start; flex-direction: column; gap: 4px; } + #status-engines { margin-left: 0; width: 100%; justify-content: flex-start; flex-wrap: wrap; gap: 8px 14px; } + .status-engine-value { max-width: 72vw; } +} .info-box { background: var(--panel); border-radius: var(--radius); padding: 12px 16px; font-family: monospace; font-size: 13px; color: var(--accent); word-break: break-all; } hr { border: none; border-top: 1px solid var(--border); } .note { font-size: 13px; color: var(--subtext); line-height: 1.5; } @@ -1582,6 +1609,42 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .voices-empty-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; } .voices-empty-action-btn { width: 100%; } +/* ── Cross-workflow pipeline stepper (Reader/Casting/Characters/Rehearser) ── + PDF → Text → Cast Audiobook → Cast Characters (optional) → Script Rehearser + → Generate MP3s → Audiobook. Every reachable stop is clickable in either + direction; state lives wherever it was created (readerState/_audiobook/ + rehState), so jumping around never destroys it. */ +.wf-stepper { display: flex; align-items: flex-start; gap: 0; padding: 16px 20px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; overflow-x: auto; } +.wf-stepper[hidden] { display: none; } +.wf-stepper-title { align-self: center; font-weight: 700; color: var(--text); font-size: 14px; white-space: nowrap; margin-right: 18px; padding-right: 18px; border-right: 1px solid var(--border); flex-shrink: 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; } +.wf-step { + display: flex; flex-direction: column; align-items: center; gap: 5px; + background: none; border: none; padding: 2px 16px; border-radius: 10px; cursor: pointer; + white-space: nowrap; color: var(--subtext); font-size: 11.5px; font-weight: 600; + transition: background .15s, color .15s; flex-shrink: 0; +} +.wf-step:hover:not(:disabled) { background: rgba(37,99,235,.08); color: var(--accent); } +.wf-step:disabled { opacity: .4; cursor: not-allowed; } +.wf-step-num { + display: inline-flex; align-items: center; justify-content: center; + width: 30px; height: 30px; border-radius: 50%; + background: var(--surface); border: 2px solid var(--border); color: var(--subtext); + font-size: 13px; font-weight: 800; flex-shrink: 0; + transition: background .15s, border-color .15s, color .15s, transform .15s; +} +.wf-step.is-done { color: var(--text); } +.wf-step.is-done .wf-step-num { background: rgba(37,99,235,.12); border-color: var(--accent); color: var(--accent); } +.wf-step.is-current { color: var(--accent); } +.wf-step.is-current .wf-step-num { + background: var(--accent); border-color: var(--accent); color: #fff; + transform: scale(1.15); box-shadow: 0 0 0 4px rgba(37,99,235,.16); +} +.wf-step.is-optional .wf-step-num { border-style: dashed; } +.wf-step small { display: block; font-weight: 500; font-size: 9.5px; opacity: .7; margin-top: -2px; } +/* Connecting line between steps, like a wizard progress rail. */ +.wf-step-arrow { color: var(--border); font-size: 20px; flex-shrink: 0; margin: 0 -3px; align-self: flex-start; padding-top: 4px; } +.wf-step-arrow.is-done { color: var(--accent); opacity: .5; } + /* ── Voices workbench ────────────────────────────────────────────────────── */ .voices-workbench { display: flex; height: calc(100vh - 148px); min-height: 420px; @@ -3268,7 +3331,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami } .vp-trigger:hover, .vp-trigger.open { border-color: var(--accent); } .vp-trigger-content { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; overflow: hidden; } -.vp-trigger-name { font-size: 13px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vp-trigger-name { font-size: 13px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.vp-trigger-meta { + margin-left: auto; flex-shrink: 0; color: var(--subtext); font-size: 12px; + font-variant-numeric: tabular-nums; letter-spacing: 0; +} .vp-trigger-placeholder { font-size: 13px; color: var(--subtext); } .vp-chevron { font-size: 16px; color: var(--subtext); flex-shrink: 0; transition: transform .15s; } .vp-trigger.open .vp-chevron { transform: rotate(180deg); } @@ -3291,7 +3358,12 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami } .vp-item:hover, .vp-item:focus { background: rgba(37,99,235,.08); outline: none; } .vp-item.selected { background: rgba(37,99,235,.13); font-weight: 600; } -.vp-item-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vp-item-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.vp-item-meta { + margin-left: auto; flex: 0 0 auto; min-width: 42px; text-align: right; + color: var(--subtext); font-size: 12px; font-weight: 600; letter-spacing: 0; + font-variant-numeric: tabular-nums; +} .vp-empty { padding: 12px; text-align: center; font-size: 12px; color: var(--subtext); } /* ── Generation History ─────────────────────────────────────────────────── */ @@ -3438,7 +3510,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .seed-finder-result-row.sfr-active { border-color: var(--accent); background: rgba(var(--accent-rgb, 124,77,255),.07); } .seed-finder-result-row.seed-finder-result-error { opacity: .6; } .sfr-seed { font-weight: 700; min-width: 58px; color: var(--text); } -.sfr-dur { color: var(--subtext); font-size: 12px; min-width: 36px; } +.sfr-dur { color: var(--subtext); font-size: 12px; min-width: 170px; flex: 1 1 210px; } .sfr-play-btn { font-size: 12px; padding: 4px 10px; } .sfr-use-btn { font-size: 12px; padding: 4px 10px; } .sfr-saved { font-size: 12px; color: var(--green); font-weight: 600; } @@ -4652,15 +4724,202 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .reader-ctl input[type=range] { width: 100%; accent-color: var(--accent); } .reader-import-btn { cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } +#s-reader .reader-tuning-panel { + margin-top: 12px; + display: grid; + grid-template-columns: minmax(520px, 680px) minmax(260px, 1fr); + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + align-items: stretch; +} +#s-reader .reader-tuning-row { + grid-column: 1; + display: grid; + grid-template-columns: 136px minmax(130px, 220px) minmax(220px, 1fr); + gap: 12px; + align-items: center; +} +#s-reader .reader-tuning-row label { + margin: 0; + color: var(--subtext); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .02em; +} +#s-reader .reader-tuning-row input[type="number"], +#s-reader .reader-tuning-row input[type="range"] { + width: 100%; + min-width: 0; +} +#s-reader .reader-tuning-row input[type="range"] { accent-color: var(--accent); } +#s-reader .reader-tuning-help { + color: var(--subtext); + font-size: 12px; + line-height: 1.35; +} +#s-reader .reader-tuning-panel .reader-backend-hint { + grid-column: 2; + grid-row: 1 / span 4; + align-self: stretch; + margin: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + min-width: 0; +} +#s-reader .reader-tuning-panel .reader-backend-hint:empty { display: none; } +#s-reader .reader-tuning-panel:has(.reader-backend-hint:empty) { + grid-template-columns: 1fr; +} + +#s-reader .reader-source-panel { + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: linear-gradient(180deg, var(--surface), var(--bg)); +} +#s-reader .reader-source-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +#s-reader .reader-source-head div { + min-width: 0; + display: flex; + align-items: baseline; + gap: 10px; + flex-wrap: wrap; +} +#s-reader .reader-source-head strong { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text); + font-size: 13px; +} +#s-reader .reader-source-head span { + color: var(--subtext); + font-size: 12px; +} +#s-reader .reader-source-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(260px, .8fr); + gap: 12px; + align-items: stretch; +} +#s-reader .reader-paste-panel { + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} +#s-reader .reader-source-method { + display: flex; + align-items: flex-start; + gap: 8px; + color: var(--subtext); +} +#s-reader .reader-source-method > .mdi { + color: var(--accent); + font-size: 20px; + line-height: 1; +} +#s-reader .reader-source-method div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +#s-reader .reader-source-method strong { + color: var(--text); + font-size: 13px; +} +#s-reader .reader-source-method span { + color: var(--subtext); + font-size: 12px; + line-height: 1.35; +} +#s-reader .reader-paste-text { + width: 100%; + min-height: 138px; + resize: vertical; + box-sizing: border-box; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + padding: 11px 12px; + font: inherit; + line-height: 1.45; +} +#s-reader .reader-paste-text:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(37,99,235,.12); +} +#s-reader .reader-paste-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + .reader-dropzone { margin-top: 14px; padding: 30px 16px; border: 2px dashed var(--border); border-radius: 12px; text-align: center; color: var(--subtext); display: flex; flex-direction: column; align-items: center; gap: 5px; transition: border-color .15s, background .15s; } +#s-reader .reader-source-panel .reader-dropzone { + margin-top: 0; + min-height: 100%; + padding: 18px; + border-radius: 8px; + background: var(--surface); + justify-content: center; + gap: 8px; +} .reader-dropzone-icon { font-size: 34px; opacity: .5; } +/* "Extract Text" prompt — PDF pages are already visible, but text/OCR extraction + is a separate explicit action (pipeline stage 2) rather than automatic. */ +.reader-extract-banner { + display: flex; align-items: center; gap: 14px; margin: 12px 0; padding: 14px 16px; + border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); +} +.reader-extract-banner[hidden] { display: none; } +.reader-extract-icon { font-size: 30px; color: var(--accent); flex-shrink: 0; } +.reader-extract-info { flex: 1; min-width: 160px; } +.reader-extract-info strong { display: block; color: var(--text); font-size: 14px; } +.reader-extract-info p { margin: 2px 0 0; color: var(--subtext); font-size: 12.5px; } .reader-dropzone.dragover { border-color: var(--accent); background: rgba(37,99,235,.06); color: var(--accent); } .reader-dropzone.dragover .reader-dropzone-icon { opacity: 1; } +#s-reader .reader-file-pick { + margin-top: 4px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; +} + +@media (max-width: 860px) { + #s-reader .reader-source-grid { grid-template-columns: 1fr; } + #s-reader .reader-tuning-panel { grid-template-columns: 1fr; } + #s-reader .reader-tuning-row { grid-template-columns: 1fr; gap: 5px; } + #s-reader .reader-tuning-panel .reader-backend-hint { grid-column: 1; grid-row: auto; } +} .reader-transport { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; @@ -4736,14 +4995,28 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 16px 48px rgba(0,0,0,.28); overflow: hidden; resize: both; min-height: 300px; min-width: 400px; } -.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg); cursor: move; } +.ab-castpanel-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--bg); cursor: move; flex-shrink: 0; } .ab-castpanel-head > .mdi { color: var(--accent); font-size: 18px; } .ab-castpanel-title { font-weight: 800; font-size: 14px; } .ab-castpanel-count { font-size: 12px; color: var(--subtext); } .ab-castpanel-btn { border: none; background: none; color: var(--subtext); cursor: pointer; padding: 2px 5px; border-radius: 6px; line-height: 1; } .ab-castpanel-btn:hover { background: var(--panel); color: var(--text); } -.ab-castpanel-bar { margin: 0; border-radius: 0; flex-shrink: 0; } -.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-top: 1px solid var(--border); background: var(--bg); flex-shrink: 0; z-index: 10; position: relative; } +.ab-castpanel-bar { + position: relative; flex: 0 1 760px; min-width: 180px; height: 30px; min-height: 30px; max-height: 30px; + align-self: center; margin: 0 12px 0 0; border-radius: 6px; + background: var(--surface); border: 1px solid var(--border); overflow: hidden; +} +.ab-castpanel-bar .reader-synth-fill { + height: 100%; +} +#ab-cv-fill-text { + position:absolute; inset:0; display:flex; align-items:center; justify-content:center; + font-size:12px; font-weight:800; color:var(--text); + text-shadow:0 1px 2px var(--bg), 0 -1px 2px var(--bg); + pointer-events:none; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding:0 8px; +} +.ab-castpanel-foot { display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 10px 12px; border-top: 1px solid var(--border); background: var(--bg); flex-shrink: 0; z-index: 10; position: relative; } +.ab-castpanel-foot #ab-cv-cancel { flex: 0 0 auto; margin-right: 0 !important; } .ab-cv-done { font-size: 13px; font-weight: 700; display: inline-flex; align-items: center; gap: 6px; } .ab-cv-done .mdi { color: #10b981; } /* minimised → just the header chip in the corner */ @@ -4754,15 +5027,104 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .ab-castpanel.minimized .ab-castpanel-head { cursor: pointer; border-bottom: none; } .ab-castpanel-inline { - height: calc(100vh - 140px); - min-height: 400px; display: flex; flex-direction: column; + background: var(--panel); + /* Bound to the viewport so the feed scrolls internally instead of pushing + the action bar (Continue casting / Edit in Rehearser / ...) down the page + — without this, .ab-cv-body's flex:1 has no height to divide up against. */ + height: calc(100vh - 230px); + min-height: 420px; + overflow: hidden; } +.ab-castpanel-inline[hidden] { display: none !important; } -.ab-cv-body { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; min-width: 0; } -.ab-cv-feed-wrap { position: relative; min-height: 0; min-width: 0; display: flex; flex-direction: column; } -.ab-cv-feed { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; background: var(--panel); display: flex; flex-direction: column; gap: 3px; } +.ab-cv-body { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 12px; padding: 12px; flex: 1; min-height: 0; min-width: 0; transition: grid-template-columns .15s; } +.ab-cv-body.side-collapsed { grid-template-columns: minmax(0, 1fr) 44px; } +.ab-cv-feed-wrap { position: relative; min-height: 0; min-width: 0; display: flex; flex-direction: column; background: var(--panel); } +.ab-cv-topbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 0 6px 8px; + flex: 0 0 auto; + min-width: 0; +} +.ab-edit-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + flex: 0 0 auto; + margin-left: auto; +} +.ab-edit-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--subtext); + cursor: pointer; +} +.ab-edit-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); background: var(--panel); } +.ab-edit-btn:disabled { opacity: .42; cursor: default; } +.ab-page-nav { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + flex: 0 0 auto; +} +.ab-page-select { + min-width: 112px; + height: 28px; + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font-size: 12px; +} +.ab-page-nav-btn { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--subtext); + cursor: pointer; +} +.ab-page-nav-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--panel); } +.ab-cv-feed { flex: 1; min-height: 0; min-width: 0; overflow-y: auto; overflow-x: hidden; border: 0px solid var(--border); border-radius: 0px; padding: 0px 10px 10px 0px; background: none; display: flex; flex-direction: column; gap: 18px; } +/* Each source page renders as its own "paper" card — white surface, thin + border, soft shadow — sitting on the feed's canvas background, so a long + book reads like a paginated document instead of one unbroken stream. */ +.ab-cv-page { + /* Merged "paper" look: Casting's stacked-card border/shadow + Rehearser's + off-white paper background/font/colour (kept a fixed light paper even in + dark mode, same as .reh-a4-page, since it's meant to read as a printed page). */ + background: #fff; + color: #1a1a2e; + font-family: 'Courier New', Courier, monospace; + font-size: calc(12pt * var(--reh-stage-scale, 1)); + border-top: 1px solid var(--border); + border-left: 1px solid var(--border); + border-right: 1px solid #ccc; + border-bottom: 1px solid #ccc; + border-radius: 0px; + box-shadow: 5px 5px 10px rgba(0, 0, 0, .08); + padding: 14px 16px; + display: flex; flex-direction: column; gap: 3px; +} +[data-theme="dark"] .ab-cv-page { background: #f8f8f2; color: #1a1a2e; } /* Long unbroken passage strings must wrap, never widen the layout (it was pushing the sidebar out of frame). */ .ab-cv-row { min-width: 0; overflow-wrap: anywhere; word-break: break-word; } @@ -4782,10 +5144,46 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami font-family: 'Courier New', Courier, monospace; font-size: 13.5px; line-height: 1.5; - padding: 8px 12px; + padding: 20px 50px; margin-bottom: 4px; + position: relative; } +.ab-cv-row-tools { + position: absolute; + top: 4px; + right: 4px; + display: inline-flex; + gap: 2px; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} +.ab-cv-row:hover .ab-cv-row-tools, +.ab-cv-row:focus-within .ab-cv-row-tools { + opacity: 1; + pointer-events: auto; +} +.ab-cv-row-tool { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--subtext); + cursor: pointer; + font-size: 15px; + box-shadow: 0 2px 6px rgba(0,0,0,.12); +} +.ab-cv-row-tool:hover { color: var(--accent); border-color: var(--accent); background: var(--panel); } .ab-cv-row.is-narr { opacity: .85; } +.ab-cv-edit-ta { + width: 100%; min-height: 60px; resize: vertical; font: inherit; color: inherit; + background: rgba(37,99,235,.06); border: 1px solid var(--accent); border-radius: 4px; + padding: 4px 6px; outline: none; +} .ab-cv-spk { display: block; font-weight: 700; @@ -4815,7 +5213,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .ab-cv-row:not(.is-narr):not(.is-processing) .ab-cv-txt { display: block; padding-left: 60px; - max-width: 500px; + /*max-width: 500px;*/ } .ab-cv-row.is-narr .ab-cv-spk { display: block; @@ -4855,21 +5253,43 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami border-radius: 6px; padding: 8px 10px; margin: 0; max-height: 260px; overflow-y: auto; } -.ab-cv-pagemark { +.ab-cv-page-label { + width: calc(100% + 4px); + border: 0; + background: transparent; display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--subtext); text-transform: uppercase; - padding: 6px 4px 2px; - border-top: 1px dashed var(--border); - margin: 4px 0 2px; + padding: 0 0 8px; + border-bottom: 1px dashed var(--border); + margin: -2px -2px 10px; + text-align: left; + cursor: pointer; } -.ab-cv-pagemark .mdi { font-size: 13px; opacity: .7; } +.ab-cv-page-label .mdi { font-size: 13px; opacity: .7; } +.ab-cv-page-label:hover { color: var(--accent); } +.ab-cv-page-back { margin-left: auto; } .ab-cv-txt { color: var(--text); white-space: pre-wrap; word-wrap: break-word; } .ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); font-family: sans-serif; } -.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; min-height: 0; } -.ab-cv-side-head { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-bottom: 6px; } -.ab-cv-chars { display: flex; flex-direction: column; gap: 2px; } +.ab-cv-side { + border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); + min-height: 0; display: flex; flex-direction: column; overflow: hidden; +} +.ab-cv-side-head { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-bottom: 6px; } +.ab-cv-side-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ab-cv-side-collapse { border: none; background: none; color: var(--subtext); cursor: pointer; padding: 2px; border-radius: 4px; line-height: 1; flex-shrink: 0; } +.ab-cv-side-collapse:hover { background: var(--surface); color: var(--text); } +.ab-cv-side-done { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); flex: 0 0 auto; } +.ab-cv-chars { display: flex; flex-direction: column; gap: 2px; min-height: 0; overflow-y: auto; padding-right: 2px; } .ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; } +/* Collapsed sidebar: shrink to just the avatar dots, hide name/count/labels. + The grid column itself is narrowed via .ab-cv-body.side-collapsed. */ +.ab-cv-side.is-collapsed { padding: 8px 4px; } +.ab-cv-side.is-collapsed .ab-cv-side-title, +.ab-cv-side.is-collapsed .ab-char-name, +.ab-cv-side.is-collapsed .ab-char-count { display: none; } +.ab-cv-side.is-collapsed .ab-char-item { justify-content: center; padding: 4px 0; } +.ab-cv-side.is-collapsed .ab-char-dot { margin: 0; } /* Skeleton loading placeholders */ .ab-skel-feed { padding:8px 0; } .ab-skel-chars { padding:4px 0; } @@ -4971,11 +5391,25 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .ab-cd-body .lcd-voice-top .ab-cd-pick:hover { border-color:var(--accent); color:var(--accent); } .ab-cd-body .lcd-voice-top .ab-cd-auto:hover { border-color:var(--green,#22c55e); color:var(--green,#22c55e); } .ab-cd-body .lcd-voice-top .ab-cd-online:hover { border-color:#0ea5e9; color:#0ea5e9; } +.ab-cd-color { + display:inline-flex; align-items:center; gap:5px; + padding:3px 7px; border:1px solid var(--border); border-radius:4px; + color:var(--subtext); cursor:pointer; line-height:1; +} +.ab-cd-color:hover { border-color:var(--accent); color:var(--accent); } +.ab-cd-color input { + width:22px; height:18px; padding:0; border:0; background:transparent; cursor:pointer; +} +.cl-edit-color input[type="color"] { + width:48px; height:30px; padding:2px; cursor:pointer; +} /* Character selection bar (compact bar above the casting feed) */ +.ab-char-bar[hidden] { display: none; } .ab-char-bar { - display:flex; align-items:center; gap:8px; flex-shrink:0; - padding:6px 10px; margin-bottom:6px; - background:var(--surface); border:1px solid var(--border); border-radius:8px; + display:flex; align-items:center; gap:8px; + flex:1 1 430px; min-width:280px; max-width:100%; + padding:6px 10px; margin:0; + background:var(--panel); border:1px solid var(--border); border-radius:8px; } .ab-char-bar-dot { width:32px; height:32px; border-radius:50%; flex-shrink:0; @@ -4994,10 +5428,10 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .ab-char-bar-nav button:hover { background:var(--panel); border-color:var(--accent); } .ab-char-bar-pos { font-size:11px; opacity:.6; min-width:44px; text-align:center; } .ab-char-bar-search { - flex:1; min-width:70px; max-width:160px; + flex:1; min-width:140px; max-width:none; padding:4px 8px; font-size:12px; border:1px solid var(--border); border-radius:6px; - background:var(--bg,var(--panel)); color:var(--text); outline:none; + background:var(--bg,var(--surface)); color:var(--text); outline:none; } .ab-char-bar-search:focus { border-color:var(--accent); } .ab-char-bar-profile, .ab-char-bar-close { @@ -5018,6 +5452,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .ab-chip-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); flex-shrink: 0; } .ab-chip b { margin-left: auto; color: var(--subtext); font-weight: 700; } .audiobook-narr-note { color: var(--subtext); } +@media (max-width: 900px) { + .ab-cv-topbar { flex-wrap: wrap; } + .ab-char-bar { flex-basis: 100%; } + .ab-edit-toolbar { margin-left: 0; } +} @media (max-width: 560px) { .ab-cv-body { grid-template-columns: 1fr; } .ab-cv-side { height: auto; max-height: 22vh; } } /* Audiobook casting overlay */ @@ -5042,15 +5481,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami #reader-charsheets-btn .mdi, #reh-charsheets-btn .mdi { color: var(--accent); } /* Character sheets overlay */ -.cs-box { width: min(900px, 95vw); max-height: 90vh; display: flex; flex-direction: column; } +.cs-box { width: min(1120px, 96vw); max-height: 90vh; display: flex; flex-direction: column; } .cs-titlebar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } -.cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 12px; align-content: start; } +.cs-list { overflow-y: auto; padding-right: 4px; display: grid; grid-template-columns: repeat(auto-fill, minmax(430px, 1fr)); gap: 14px; align-content: start; } .cs-group-label { grid-column: 1 / -1; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-top: 4px; } -.cs-card { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--panel); } -.cs-card.cs-main { box-shadow: inset 4px 0 0 var(--accent); } -.cs-card.cs-supp { box-shadow: inset 4px 0 0 var(--border); } +.cs-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px; background: color-mix(in srgb, var(--panel) 88%, var(--surface)); box-shadow: 0 8px 22px rgba(0,0,0,.08); } +.cs-card.cs-main { box-shadow: inset 4px 0 0 var(--accent), 0 8px 22px rgba(0,0,0,.08); } +.cs-card.cs-supp { box-shadow: inset 4px 0 0 var(--border), 0 8px 22px rgba(0,0,0,.08); } .cs-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.cs-name { font-weight: 800; font-size: 15px; } +.cs-name { font-weight: 900; font-size: 17px; letter-spacing: .01em; } .cs-archetype { font-size: 12px; font-weight: 700; color: var(--accent); } .cs-tier { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; } .cs-head-btns { margin-left: auto; display: flex; gap: 4px; flex-shrink: 0; } @@ -5107,15 +5546,52 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami text-shadow: 0 1px 3px rgba(0,0,0,.8); } .cs-arc-note { font-size: 11px; color: var(--subtext); margin-top: 3px; font-style: italic; } -.cs-progress-chars { font-size: 11px; color: var(--subtext); text-align: center; min-height: 16px; margin-bottom: 8px; } -.cs-fields { margin: 0; display: flex; flex-direction: column; gap: 5px; } -.cs-row { display: grid; grid-template-columns: 120px 1fr; gap: 8px; font-size: 12.5px; } -.cs-row dt { font-weight: 700; color: var(--subtext); } -.cs-row dd { margin: 0; color: var(--text); } +.cs-progress-chars { font-size: 11px; color: var(--subtext); min-height: 16px; margin-bottom: 8px; } +.cs-progress-summary { text-align: center; margin-bottom: 8px; color: var(--subtext); } +.cs-progress-list { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; + max-height: 210px; overflow: auto; padding: 2px 2px 4px; +} +.cs-progress-chip { + display: grid; grid-template-columns: 22px minmax(0, 1fr) 44px auto; align-items: center; gap: 6px; + min-height: 34px; padding: 5px 6px; border: 1px solid var(--border); border-radius: 7px; + background: var(--surface); color: var(--text); +} +.cs-progress-chip.is-new { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); } +.cs-progress-chip.is-updated { border-color: rgba(59,130,246,.45); background: rgba(59,130,246,.08); } +.cs-progress-dot { + width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; + background: var(--accent); color: #fff; font-size: 10px; font-weight: 800; +} +.cs-progress-meta { min-width: 0; display: flex; flex-direction: column; gap: 1px; } +.cs-progress-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; } +.cs-progress-alias { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--subtext); font-size: 10px; } +.cs-progress-fillmeta { height: 5px; border-radius: 999px; background: var(--panel); overflow: hidden; border: 1px solid var(--border); } +.cs-progress-fillmeta span { display: block; height: 100%; background: var(--accent); } +.cs-progress-badge { font-size: 9px; text-transform: uppercase; color: var(--subtext); font-weight: 800; } +.cs-fields { margin: 0; display: grid; grid-template-columns: 1fr; gap: 6px; } +.cs-row { display: grid; grid-template-columns: 136px 1fr; gap: 10px; font-size: 12.5px; padding: 6px 8px; border: 1px solid color-mix(in srgb, var(--border) 55%, transparent); border-radius: 6px; background: color-mix(in srgb, var(--surface) 72%, transparent); } +.cs-row dt { font-weight: 800; color: var(--subtext); text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:flex; align-items:center; gap:5px; } +.cs-row dd { margin: 0; color: var(--text); line-height: 1.45; } .cs-row ul { margin: 0; padding-left: 16px; } -.cs-sources { margin-top: 8px; padding-top: 7px; border-top: 1px dashed var(--border); font-size: 11px; color: var(--subtext); } +.cs-source-mark { + width: 17px; height: 17px; border-radius: 50%; border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent); + font-size: 9px; font-weight: 900; cursor: pointer; line-height: 1; padding: 0; +} +.cs-sources { margin-top: 10px; padding-top: 9px; border-top: 1px dashed var(--border); font-size: 11px; color: var(--subtext); } +.cs-sources-title { display:flex; align-items:center; gap:5px; font-weight:800; text-transform:uppercase; letter-spacing:.04em; margin-bottom:6px; } .cs-sources b { color: var(--text); } .cs-sources .mdi { color: var(--accent); } +.cs-source-list { display:flex; flex-wrap:wrap; gap:5px; } +.cs-source-link { + display:inline-flex; align-items:center; gap:5px; max-width:100%; + border:1px solid var(--border); border-radius:999px; background:var(--surface); color:var(--subtext); + padding:3px 8px; font-size:10.5px; cursor:pointer; +} +.cs-source-link:hover { border-color:var(--accent); color:var(--accent); } +.cs-source-link span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:150px; } +.cs-source-link em { font-style:normal; opacity:.8; } .cs-row-voice dt .mdi { color: var(--accent); } .cs-row-voice dd { font-style: italic; } @@ -5150,7 +5626,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .ab-cv-divider-hint { font-size: 11px; font-weight: 500; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; vertical-align: middle; } .ab-cv-ctx-row { opacity: .7; background: color-mix(in srgb, var(--panel) 60%, transparent); border-left: 2px solid var(--border); } .reader-synth-prog { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; } -.reader-synth-track { width: 130px; height: 6px; border-radius: 4px; background: var(--panel); overflow: hidden; } +.reader-synth-track { width: 130px; height: 38px; border-radius: 4px; background: var(--panel); overflow: hidden; } .reader-synth-fill { height: 100%; width: 0; background: rgba(234,179,8,.9); transition: width .15s; } .reader-synth-label { font-size: 12px; color: var(--subtext); min-width: 52px; } @@ -5178,7 +5654,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .reader-zoom-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; } .reader-zoom-pct { font-size: 12px; font-weight: 700; min-width: 44px; text-align: center; color: var(--subtext); } /* PDF Search */ -.reader-search-wrap { display: flex; align-items: center; gap: 6px; } +.reader-search-wrap { display: flex; align-items: center; gap: 5px; } .reader-search-icon { font-size: 14px; color: var(--subtext); } .reader-search-inp { width: 160px; padding: 4px 10px; border: 1px solid var(--border); border-radius: 20px; @@ -5187,6 +5663,21 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami } .reader-search-inp:focus { border-color: var(--accent); width: 220px; } .reader-search-status { font-size: 11px; color: var(--subtext); white-space: nowrap; min-width: 32px; } +.reader-search-btn { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--subtext); + cursor: pointer; + padding: 0; +} +.reader-search-btn:hover { background: var(--panel); color: var(--accent); } +.reader-search-btn .mdi { font-size: 17px; } .reader-legend { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; font-size: 11px; color: var(--subtext); } .reader-leg { display: inline-flex; align-items: center; gap: 5px; } .reader-leg::before { content: ''; width: 11px; height: 11px; border-radius: 3px; } @@ -5206,6 +5697,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .reader-page { position: relative; flex: 0 0 auto; margin: 0 auto; box-shadow: 0 2px 10px rgba(0,0,0,.12); background: #fff; + contain: layout paint style; + content-visibility: auto; + contain-intrinsic-size: auto 1100px; } .reader-canvas { display: block; } .reader-overlay { position: absolute; inset: 0; pointer-events: none; } @@ -5213,9 +5707,30 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami position: absolute; border-radius: 3px; background: rgba(37,99,235,.30); z-index: 3; box-shadow: 0 0 0 1.5px rgba(37,99,235,.75); transition: left .12s, top .12s, width .12s, height .12s; } -/* Per-sentence synthesis-state overlay (PDF) */ +.reader-search-hit { + position: absolute; + z-index: 2; + border-radius: 2px; + background: rgba(250, 204, 21, .35); + box-shadow: 0 0 0 1px rgba(202, 138, 4, .25); + pointer-events: none; +} +.reader-search-hit.search-current { + z-index: 4; + background: rgba(250, 204, 21, .72); + box-shadow: 0 0 0 2px rgba(202, 138, 4, .85); +} +.reader-word.search-hit { background: rgba(250, 204, 21, .35); border-radius: 3px; } +.reader-word.search-current { + background: rgba(250, 204, 21, .75); + box-shadow: 0 0 0 2px rgba(202, 138, 4, .8); +} +/* Per-sentence synthesis-state overlay (PDF) — the "not synthesised" tint stays + invisible until synthesis actually starts (button, play, or a restored doc + with saved audio); otherwise a freshly-imported book reads as wall-to-wall red. */ .reader-stat { position: absolute; border-radius: 3px; z-index: 1; transition: background .2s; } -.reader-stat-pending { background: rgba(239,68,68,.12); } +.reader-stat-pending { background: transparent; } +.reader-doc.reader-synth-started .reader-stat-pending { background: rgba(239,68,68,.12); } .reader-stat-synth { background: rgba(234,179,8,.30); } .reader-stat-ready { background: rgba(34,197,94,.18); } .reader-stat-reading { background: rgba(37,99,235,.20); z-index: 2; } @@ -5228,7 +5743,8 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami .reader-text-para { margin: 0 0 1em; } .reader-word { border-radius: 3px; padding: 0 1px; cursor: pointer; transition: background .12s; } /* Per-sentence synthesis-state tint (text) */ -.reader-word.stat-pending { background: rgba(239,68,68,.09); } +.reader-word.stat-pending { background: transparent; } +.reader-doc.reader-synth-started .reader-word.stat-pending { background: rgba(239,68,68,.09); } .reader-word.stat-synth { background: rgba(234,179,8,.22); } .reader-word.stat-ready { background: rgba(34,197,94,.14); } .reader-word.stat-reading { background: rgba(37,99,235,.15); }