Merge Reader/Casting/Rehearser pipeline into one guided workflow (v1.12.84)
- Add a 6-stage pipeline stepper (Source -> Cast Audiobook -> Cast Characters -> Script Rehearser -> Generate MP3s -> Audiobook) with direct, non-destructive jumps between stages and a prominent guided-tour look - Split PDF import into an explicit "load" then "Extract Text" step, with in-browser OCR (Tesseract.js, vendored) to recover chapter headlines baked into a PDF as images instead of real text - Fix casting feed silently merging pages after leaving/returning: segments now carry their own page number instead of re-guessing it from text - Fix excessive "Unknown" speaker attribution: restore the attribution LLM's output token budget, which had been cut roughly in half and was truncating dialogue-dense passages - Fix Theater Play library cards failing to open (dead pre-migration IndexedDB API calls, missing section navigation) - Fix bulk "Set tag" wiping a voice's existing tags instead of adding to them - Start merging Casting's feed with Script Rehearser's Stage UI: collapsible character sidebar, shared "paper" page styling, inline text editing - Fix a performance regression from that merge (per-row listeners on every redraw) by moving to event delegation - Various layout/clutter fixes: hide reader chrome until a document is loaded, collapse secondary settings by default, fix overlapping toolbar icons, fix duplicate "opening" notifications Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
34e4bb99e1
commit
9d80ec27e7
149
CHANGELOG.md
149
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
|
||||
|
||||
12
Dockerfile
12
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 && \
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||
segments = []
|
||||
|
||||
@ -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
|
||||
# <reader_library>/<doc_id>/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}
|
||||
|
||||
111
routes/tts.py
111
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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.12.58">
|
||||
<meta name="app-version" content="1.12.84">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.58">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.84">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -316,7 +316,10 @@
|
||||
</div><!-- /app-shell -->
|
||||
|
||||
<div id="toast" aria-live="polite" aria-atomic="true"></div>
|
||||
<div id="status-bar" role="status" aria-live="polite" aria-atomic="true">Loading…</div>
|
||||
<div id="status-bar" role="status" aria-live="polite" aria-atomic="false">
|
||||
<span id="status-message">Loading…</span>
|
||||
<span id="status-engines" aria-label="Active engine status"></span>
|
||||
</div>
|
||||
|
||||
<!-- Global Directory Browser Modal -->
|
||||
<div class="reh-modal-overlay" id="global-dir-browser-modal" hidden style="z-index:9999;">
|
||||
@ -362,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.12.58"></script>
|
||||
<script src="/static/loader.js?v=1.12.84"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -138,6 +138,7 @@
|
||||
}
|
||||
await refreshTtsBackendAvailability();
|
||||
await refreshSttBackends();
|
||||
if (typeof window.refreshStatusBarEngines === 'function') window.refreshStatusBarEngines({ force: true });
|
||||
} catch (e) { toast('Apply failed: ' + e.message, 'error'); }
|
||||
}
|
||||
window.applyAndSaveSettings = applyAndSaveSettings;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 = `
|
||||
<div class="cs-progress-summary">${escHtml(charLabel || '')}</div>
|
||||
${list.length ? `<div class="cs-progress-list">
|
||||
${list.map(c => `
|
||||
<div class="cs-progress-chip ${c.status ? 'is-' + escHtml(c.status) : ''}">
|
||||
<span class="cs-progress-dot">${escHtml((c.name || '?')[0].toUpperCase())}</span>
|
||||
<span class="cs-progress-meta">
|
||||
<span class="cs-progress-name">${escHtml(c.name || 'Unknown')}</span>
|
||||
${c.alias ? `<span class="cs-progress-alias">${escHtml(c.alias)}</span>` : ''}
|
||||
</span>
|
||||
<span class="cs-progress-fillmeta" title="${escHtml(String(c.filled || 0))} profile fields filled">
|
||||
<span style="width:${Math.min(100, Math.round(((c.filled || 0) / CS_DETAIL_FIELDS.length) * 100))}%"></span>
|
||||
</span>
|
||||
${c.status ? `<span class="cs-progress-badge">${escHtml(c.status)}</span>` : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>` : ''}
|
||||
`;
|
||||
}
|
||||
},
|
||||
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 `<button class="cs-source-mark" type="button" data-page="${escHtml(String(src.page || ''))}" title="${escHtml(src.quote || 'Source')}">${n}</button>`;
|
||||
}
|
||||
|
||||
function csField(label, value, sheet, field) {
|
||||
if (!value) return '';
|
||||
return `<div class="cs-row"><dt>${label}</dt><dd>${escHtml(String(value))}</dd></div>`;
|
||||
return `<div class="cs-row"><dt>${label}${sheet && field ? csSourceBadgeHtml(sheet, field) : ''}</dt><dd>${escHtml(String(value))}</dd></div>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<div class="cs-row"><dt>Signature Items</dt><dd><ul>${inv.map(i => `<li>${escHtml(i)}</li>`).join('')}</ul></dd></div>`
|
||||
: '';
|
||||
@ -335,13 +521,13 @@ function csCardHtml(s) {
|
||||
: '';
|
||||
const sources = (s.sources || []).filter(x => x && (x.quote || x.page != null));
|
||||
const srcHtml = sources.length
|
||||
? `<div class="cs-sources"><span class="mdi mdi-book-open-page-variant-outline"></span> ${
|
||||
sources.map(x =>
|
||||
`${x.line_hint ? `<span class="cs-src-hint">[${escHtml(x.line_hint)}]</span> ` : ''}` +
|
||||
`${x.page != null ? `<b>p.${escHtml(String(x.page))}</b> ` : ''}` +
|
||||
`${x.quote ? `"${escHtml(x.quote)}"` : ''}`
|
||||
).join(' · ')
|
||||
}</div>`
|
||||
? `<div class="cs-sources"><div class="cs-sources-title"><span class="mdi mdi-book-open-page-variant-outline"></span> Quellen / Evidence</div><div class="cs-source-list">${
|
||||
sources.map((x, i) =>
|
||||
`<button class="cs-source-link" type="button" data-page="${escHtml(String(x.page || ''))}" title="${escHtml(x.quote || '')}">
|
||||
<b>${i + 1}</b>${x.line_hint ? `<span>${escHtml(x.line_hint)}</span>` : ''}${x.page != null ? `<em>p.${escHtml(String(x.page))}</em>` : ''}
|
||||
</button>`
|
||||
).join('')
|
||||
}</div></div>`
|
||||
: '';
|
||||
|
||||
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
|
||||
@ -363,22 +549,25 @@ function csCardHtml(s) {
|
||||
</div>
|
||||
</div>
|
||||
<dl class="cs-fields">
|
||||
${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 ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice & Speech</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
|
||||
${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 ? `<div class="cs-row cs-row-voice"><dt><span class="mdi mdi-account-voice"></span> Voice & Speech${csSourceBadgeHtml(s, 'voice_pattern')}</dt><dd>${escHtml(String(s.voice_pattern))}</dd></div>` : ''}
|
||||
${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')}
|
||||
</dl>
|
||||
${srcHtml}
|
||||
</div>`;
|
||||
@ -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; }
|
||||
|
||||
@ -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) {
|
||||
<div class="cl-edit-body">
|
||||
<label class="cl-edit-row"><span>Tags / productions (comma-separated — reuse across books & scripts)</span>
|
||||
<input type="text" data-rec-field="tags" value="${escHtml(String(rec.tags || rec.book || ''))}" placeholder="e.g. Das Jahr des Greifen, Star-Trek"></label>
|
||||
<label class="cl-edit-row cl-edit-color"><span>Character color</span>
|
||||
<input type="color" data-rec-field="color" value="${escHtml(clNormalizeColor(rec.color || s.color, rec.name))}"></label>
|
||||
<label class="cl-edit-row"><span>Moral alignment (0 evil – 100 good)</span>
|
||||
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
|
||||
<label class="cl-edit-row"><span>Arc direction</span>
|
||||
@ -333,8 +413,11 @@ function clEdit(id) {
|
||||
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas };
|
||||
const tagsEl = modal.querySelector('[data-rec-field="tags"]');
|
||||
const newTags = tagsEl ? clMergeTags(tagsEl.value, rec.book) : (rec.tags || rec.book);
|
||||
const colorEl = modal.querySelector('[data-rec-field="color"]');
|
||||
const color = clNormalizeColor(colorEl?.value || rec.color || s.color, newName);
|
||||
const newId = clKey(rec.book, newName);
|
||||
rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.updated = new Date();
|
||||
newSheet.color = color;
|
||||
rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.color = color; rec.updated = new Date();
|
||||
try {
|
||||
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
|
||||
await clPut(rec);
|
||||
@ -366,6 +449,33 @@ document.addEventListener('click', e => {
|
||||
const rec = _clRecords.find(r => r.id === deep.closest('.cl-card-wrap')?.dataset.id);
|
||||
if (rec && typeof csDeepAnalysis === 'function') csDeepAnalysis(rec.sheet, '');
|
||||
}
|
||||
const img = e.target.closest?.('.cl-card-wrap .cs-img-btn');
|
||||
if (img) {
|
||||
e.stopPropagation();
|
||||
const rec = _clRecords.find(r => r.id === img.closest('.cl-card-wrap')?.dataset.id);
|
||||
if (rec && typeof csBuildImagePrompt === 'function') {
|
||||
navigator.clipboard?.writeText(csBuildImagePrompt(rec.sheet))
|
||||
.then(() => toast('Image prompt copied', 'success'), () => toast('Copy failed', 'error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const voice = e.target.closest?.('.cl-card-wrap .cs-voice-btn');
|
||||
if (voice) {
|
||||
e.stopPropagation();
|
||||
const rec = _clRecords.find(r => r.id === voice.closest('.cl-card-wrap')?.dataset.id);
|
||||
if (rec && typeof csBuildVoicePrompt === 'function') {
|
||||
navigator.clipboard?.writeText(csBuildVoicePrompt(rec.sheet))
|
||||
.then(() => toast('Voice prompt copied', 'success'), () => toast('Copy failed', 'error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const src = e.target.closest?.('.cl-card-wrap .cs-source-link, .cl-card-wrap .cs-source-mark');
|
||||
if (src) {
|
||||
e.stopPropagation();
|
||||
const pg = parseInt(src.dataset.page, 10);
|
||||
if (pg && typeof window.readerJumpToPage === 'function') window.readerJumpToPage(pg);
|
||||
else if (pg) toast(`Source: page ${pg}`, 'info');
|
||||
}
|
||||
});
|
||||
|
||||
window.clRender = clRender;
|
||||
|
||||
@ -231,6 +231,8 @@ function _charCardHtml(rec, allChars) {
|
||||
+ '</div>'
|
||||
+ '<div class="lib-char-body">'
|
||||
+ '<div class="lib-char-name">' + tierBadge + escHtml(rec.name) + '<span class="mdi ' + genderIcon + '" style="font-size:11px;opacity:.5"></span></div>'
|
||||
+ (_libStr(sh.title) ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.title)) + '</div>' : '')
|
||||
+ (_libStr(sh.aliases) ? '<div class="lib-char-archetype">aka ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
|
||||
+ (sh.archetype ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
|
||||
+ (snippet ? '<div class="lib-char-snippet">' + escHtml(snippet) + '</div>' : '')
|
||||
+ _charAlignHtml(sh)
|
||||
@ -429,6 +431,8 @@ async function _charDetailPage(rec, allChars) {
|
||||
+ avatarHtml
|
||||
+ '<div class="lcd-header-body">'
|
||||
+ '<div class="lcd-name" contenteditable="true" data-rec-key="name" spellcheck="false">' + escHtml(rec.name) + '</div>'
|
||||
+ (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="full_name" spellcheck="false">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
|
||||
+ (_libStr(sh.title) ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="title" spellcheck="false">' + escHtml(_libStr(sh.title)) + '</div>' : '')
|
||||
+ '<div class="lcd-aliases" contenteditable="true" data-sheet-key="aliases" spellcheck="false">' + escHtml(_libStr(sh.aliases)) + '</div>'
|
||||
+ '<div class="lcd-archetype" contenteditable="true" data-sheet-key="archetype" spellcheck="false">' + escHtml(_libStr(sh.archetype)) + '</div>'
|
||||
+ '<div class="lcd-tier-gender">'
|
||||
@ -448,6 +452,13 @@ async function _charDetailPage(rec, allChars) {
|
||||
+ '</div>'
|
||||
+ alignHtml
|
||||
+ '<div class="lcd-sections">'
|
||||
+ _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
|
||||
+ '<div class="lcd-header-body">'
|
||||
+ '<div class="lcd-name">' + escHtml(rec.name) + '</div>'
|
||||
+ (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
|
||||
+ (_libStr(sh.title) ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.title)) + '</div>' : '')
|
||||
+ (_libStr(sh.aliases) ? '<div class="lcd-aliases">auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
|
||||
+ (_libStr(sh.archetype) ? '<div class="lcd-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
|
||||
+ '<div class="lcd-tier-gender">'
|
||||
|
||||
@ -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'); }
|
||||
}
|
||||
|
||||
|
||||
@ -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 = '<option value="">— select a voice —</option>';
|
||||
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 = '<option value="">— select a voice —</option>';
|
||||
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 = '<span class="mdi mdi-loading mdi-spin"></span> 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 = '<option value="">— fetch voices —</option>';
|
||||
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 = '<div style="padding:60px 20px; text-align:center; color:var(--subtext);"><span class="mdi mdi-drama-masks" style="font-size:48px; opacity:0.5; margin-bottom:16px; display:block;"></span><h3 style="margin:0 0 8px 0; color:var(--text);">No active casting session</h3><p style="margin:0;">Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.</p><br><button class="btn-primary" onclick="navReaderView(\'main\')">Go to Reader</button></div>';
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
<button class="btn-secondary sfr-retry-btn"><span class="mdi mdi-refresh"></span> Retry</button>`;
|
||||
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 = `<span class="sfr-seed">Seed ${seed}</span>
|
||||
<span class="sfr-dur">${durStr}${data.cached ? ' · saved' : ''}</span>
|
||||
<span class="sfr-dur">${escHtml(durStr)}</span>
|
||||
<button class="btn-secondary sfr-play-btn"><span class="mdi mdi-play"></span> Play</button>
|
||||
<button class="btn-primary sfr-use-btn">★ Use seed ${seed}</button>
|
||||
<span class="sfr-saved"></span>`;
|
||||
@ -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) + '%';
|
||||
|
||||
@ -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 || {};
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
|
||||
|
||||
BIN
static/js/tesseract/lang/deu.traineddata.gz
Normal file
BIN
static/js/tesseract/lang/deu.traineddata.gz
Normal file
Binary file not shown.
BIN
static/js/tesseract/lang/eng.traineddata.gz
Normal file
BIN
static/js/tesseract/lang/eng.traineddata.gz
Normal file
Binary file not shown.
BIN
static/js/tesseract/tesseract-core-simd-lstm.wasm
Normal file
BIN
static/js/tesseract/tesseract-core-simd-lstm.wasm
Normal file
Binary file not shown.
282
static/js/tesseract/tesseract-core-simd-lstm.wasm.js
Normal file
282
static/js/tesseract/tesseract-core-simd-lstm.wasm.js
Normal file
File diff suppressed because one or more lines are too long
3
static/js/tesseract/tesseract.min.js
vendored
Normal file
3
static/js/tesseract/tesseract.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
static/js/tesseract/worker.min.js
vendored
Normal file
3
static/js/tesseract/worker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -22,7 +22,152 @@ function toast(msg, type = '', ms = 6500) {
|
||||
clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(() => el.className = '', ms);
|
||||
}
|
||||
function status(msg) { $('status-bar').textContent = msg; }
|
||||
function status(msg) {
|
||||
ensureStatusBar();
|
||||
const el = $('status-message') || $('status-bar');
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
function ensureStatusBar() {
|
||||
const bar = $('status-bar');
|
||||
if (!bar) return null;
|
||||
if (!$('status-message')) {
|
||||
const previous = bar.textContent || 'Ready';
|
||||
bar.textContent = '';
|
||||
const msg = document.createElement('span');
|
||||
msg.id = 'status-message';
|
||||
msg.textContent = previous;
|
||||
bar.appendChild(msg);
|
||||
}
|
||||
if (!$('status-engines')) {
|
||||
const engines = document.createElement('span');
|
||||
engines.id = 'status-engines';
|
||||
engines.setAttribute('aria-label', 'Active engine status');
|
||||
bar.appendChild(engines);
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
|
||||
const STATUS_LLM_CACHE = { url: '', model: '', ok: false, checked: 0, pending: false };
|
||||
|
||||
function statusCleanUrl(url) {
|
||||
return String(url || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function statusShortUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.port ? `${u.hostname}:${u.port}` : u.hostname;
|
||||
} catch (_) {
|
||||
return statusCleanUrl(url) || 'not configured';
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabelList(items, fallback) {
|
||||
const clean = items.map(x => String(x || '').replace(/^\d+\s+/, '').trim()).filter(Boolean);
|
||||
if (!clean.length) return fallback;
|
||||
const shown = clean.slice(0, 4).join(', ');
|
||||
return clean.length > 4 ? shown + ` +${clean.length - 4}` : shown;
|
||||
}
|
||||
|
||||
function statusActiveTts() {
|
||||
let all = [];
|
||||
try {
|
||||
all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
|
||||
} catch (_) {}
|
||||
const labels = all.map(b => b.label || b.id);
|
||||
return {
|
||||
kind: 'tts',
|
||||
label: 'TTS',
|
||||
ok: all.length > 0,
|
||||
value: statusLabelList(labels, 'No TTS backend'),
|
||||
title: all.length ? `${all.length} reachable TTS backend${all.length === 1 ? '' : 's'}` : 'No reachable TTS backend',
|
||||
};
|
||||
}
|
||||
|
||||
function statusActiveStt() {
|
||||
let all = [];
|
||||
try {
|
||||
all = Array.isArray(_sttBackends) ? _sttBackends : [];
|
||||
} catch (_) {}
|
||||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
|
||||
const available = all.filter(b => b.available);
|
||||
const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null;
|
||||
return {
|
||||
kind: 'stt',
|
||||
label: 'STT',
|
||||
ok: !!(chosen && chosen.available),
|
||||
value: chosen ? (chosen.label || chosen.model || chosen.id) : 'No STT backend',
|
||||
title: chosen ? `${chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
|
||||
};
|
||||
}
|
||||
|
||||
function statusActiveLlm() {
|
||||
let settings = {};
|
||||
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
|
||||
const url = statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || '');
|
||||
const model = settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '';
|
||||
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
|
||||
const ok = !!url && same && STATUS_LLM_CACHE.ok;
|
||||
return {
|
||||
kind: 'llm',
|
||||
label: 'LLM',
|
||||
ok,
|
||||
value: model || statusShortUrl(url) || 'No LLM configured',
|
||||
title: url ? `${model || 'default model'} · ${url}` : 'No active LLM endpoint configured',
|
||||
};
|
||||
}
|
||||
|
||||
function statusEngineChip(info) {
|
||||
const cls = info.ok ? 'ok' : 'bad';
|
||||
return `<span class="status-engine ${cls}" title="${escHtml(info.title || '')}">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
<span class="status-engine-label">${escHtml(info.label)}</span>
|
||||
<span class="status-engine-value">${escHtml(info.value)}</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function updateStatusBar() {
|
||||
ensureStatusBar();
|
||||
const engines = $('status-engines');
|
||||
if (!engines) return;
|
||||
const items = [statusActiveLlm(), statusActiveStt(), statusActiveTts()];
|
||||
engines.innerHTML = items.map(statusEngineChip).join('');
|
||||
}
|
||||
|
||||
async function refreshStatusBarEngines({ force = false } = {}) {
|
||||
updateStatusBar();
|
||||
let settings = {};
|
||||
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
|
||||
const url = statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || '');
|
||||
const model = settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '';
|
||||
if (!url || STATUS_LLM_CACHE.pending) return;
|
||||
const fresh = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model && (Date.now() - STATUS_LLM_CACHE.checked) < 30000;
|
||||
if (fresh && !force) return;
|
||||
STATUS_LLM_CACHE.pending = true;
|
||||
try {
|
||||
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
||||
const key = settings.llm_api_key || '';
|
||||
if (key) fetchUrl += '&api_key=' + encodeURIComponent(key);
|
||||
const data = await fetch(fetchUrl).then(r => r.json());
|
||||
const models = Array.isArray(data.models) ? data.models : [];
|
||||
STATUS_LLM_CACHE.url = url;
|
||||
STATUS_LLM_CACHE.model = model;
|
||||
STATUS_LLM_CACHE.ok = !data.error && (!!models.length || !model) && (!model || !models.length || models.includes(model));
|
||||
STATUS_LLM_CACHE.checked = Date.now();
|
||||
} catch (_) {
|
||||
STATUS_LLM_CACHE.url = url;
|
||||
STATUS_LLM_CACHE.model = model;
|
||||
STATUS_LLM_CACHE.ok = false;
|
||||
STATUS_LLM_CACHE.checked = Date.now();
|
||||
} finally {
|
||||
STATUS_LLM_CACHE.pending = false;
|
||||
updateStatusBar();
|
||||
}
|
||||
}
|
||||
|
||||
window.updateStatusBar = updateStatusBar;
|
||||
window.refreshStatusBarEngines = refreshStatusBarEngines;
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
@ -155,7 +300,7 @@ function enhanceAccessibility(root = document) {
|
||||
if (statusBar) {
|
||||
statusBar.setAttribute('role', 'status');
|
||||
statusBar.setAttribute('aria-live', 'polite');
|
||||
statusBar.setAttribute('aria-atomic', 'true');
|
||||
statusBar.setAttribute('aria-atomic', 'false');
|
||||
}
|
||||
const toastEl = $('toast');
|
||||
if (toastEl) {
|
||||
@ -533,3 +678,91 @@ document.addEventListener('click', e => {
|
||||
sel.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cross-workflow pipeline stepper ─────────────────────────────────────────
|
||||
// PDF → Text → Cast Audiobook → Cast Characters (optional) → Script Rehearser
|
||||
// → Generate MP3s → Audiobook. A persistent strip (rendered into any element
|
||||
// with class .wf-stepper — currently one in s-reader.html, one in
|
||||
// s-rehearser.html) that lets you jump directly to any reachable stage
|
||||
// without losing state: each stage's data lives in its own owner
|
||||
// (readerState / _audiobook / rehState) regardless of which stage is on
|
||||
// screen, so this only ever toggles visibility/phase — it never rebuilds
|
||||
// anything from scratch.
|
||||
let _wfActive = 'source';
|
||||
|
||||
// PDF import and text extraction used to be two stepper stops, but they both
|
||||
// just land you on the same Reader screen — merged into one "Source" stop
|
||||
// (matches the "Source" label already used on the Reader's import card).
|
||||
const WF_STEPS = [
|
||||
{ key: 'source', label: 'Source', enabled: () => true },
|
||||
{ key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length },
|
||||
{ key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true },
|
||||
{ key: 'rehearser', label: 'Script Rehearser',enabled: () => !!window.rehState?.lines?.length },
|
||||
{ key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length },
|
||||
{ key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length },
|
||||
];
|
||||
|
||||
function workflowCrumbGo(key) {
|
||||
if (key === 'source') {
|
||||
// navTo('s-reader') auto-restores an active casting panel a tick later
|
||||
// (nav.js's showSection) — without suppressing that, it silently undoes
|
||||
// the showReaderView('main') below and clicking "Source" looked like
|
||||
// it did nothing.
|
||||
window._readerSuppressCastRestore = true;
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
if (typeof showReaderView === 'function') showReaderView('main');
|
||||
} else if (key === 'cast') {
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
if (typeof audiobookOpenCastView === 'function') audiobookOpenCastView();
|
||||
} else if (key === 'chars') {
|
||||
if (typeof csForReader === 'function') csForReader();
|
||||
_wfActive = 'chars';
|
||||
refreshWorkflowCrumbs();
|
||||
} else if (key === 'rehearser') {
|
||||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||||
} else if (key === 'mp3') {
|
||||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||||
if (typeof showPhase === 'function' && window.rehState?.lines?.length) showPhase(3);
|
||||
} else if (key === 'audiobook') {
|
||||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||||
if (typeof showPhase === 'function' && window.rehState?.clips?.length) showPhase(4);
|
||||
}
|
||||
}
|
||||
window.workflowCrumbGo = workflowCrumbGo;
|
||||
|
||||
function refreshWorkflowCrumbs(active) {
|
||||
if (active) _wfActive = active;
|
||||
const title = window.readerState?.title || window.rehState?.title || '';
|
||||
const containers = document.querySelectorAll('.wf-stepper');
|
||||
if (!containers.length) return;
|
||||
const anyState = WF_STEPS.some(st => st.key !== 'pdf' && st.enabled());
|
||||
containers.forEach(el => {
|
||||
if (!anyState) { el.innerHTML = ''; el.hidden = true; return; }
|
||||
el.hidden = false;
|
||||
const parts = [];
|
||||
if (title) parts.push(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
|
||||
const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive);
|
||||
WF_STEPS.forEach((st, i) => {
|
||||
const enabled = st.enabled();
|
||||
const cur = _wfActive === st.key;
|
||||
const done = curIdx >= 0 && i < curIdx && enabled;
|
||||
if (i > 0) parts.push(`<span class="wf-step-arrow${done ? ' is-done' : ''}">›</span>`);
|
||||
parts.push(
|
||||
`<button type="button" class="wf-step${cur ? ' is-current' : ''}${done ? ' is-done' : ''}${st.optional ? ' is-optional' : ''}" ` +
|
||||
`data-wf="${st.key}"${(!enabled || cur) ? ' disabled' : ''} title="${st.optional ? 'Optional — ' : ''}${escHtml(st.label)}">` +
|
||||
`<span class="wf-step-num">${done ? '<span class="mdi mdi-check"></span>' : i + 1}</span><span>${escHtml(st.label)}${st.optional ? '<small>optional</small>' : ''}</span></button>`
|
||||
);
|
||||
});
|
||||
el.innerHTML = parts.join('');
|
||||
el.querySelectorAll('.wf-step[data-wf]').forEach(btn => {
|
||||
btn.addEventListener('click', () => workflowCrumbGo(btn.dataset.wf));
|
||||
});
|
||||
});
|
||||
}
|
||||
window.refreshWorkflowCrumbs = refreshWorkflowCrumbs;
|
||||
|
||||
// Rehearser's own phase tabs (Library/Cast/Stage/Summary) map onto the last
|
||||
// three stepper stops — keep the stepper's "current" highlight following them.
|
||||
window.onRehearserPhaseChange = function (n) {
|
||||
refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser');
|
||||
};
|
||||
|
||||
@ -3388,13 +3388,21 @@ $('vl-bulk-tag')?.addEventListener('click', () => {
|
||||
applyBtn.className = 'vl-bulk-btn';
|
||||
applyBtn.innerHTML = '<span class="mdi mdi-check"></span> Apply';
|
||||
applyBtn.addEventListener('click', async () => {
|
||||
const tagVal = inp.value.trim();
|
||||
// Add the typed tag(s) to whatever each voice already has instead of
|
||||
// replacing its tag list — a voice can carry several tags (e.g. "Privat,
|
||||
// Sillytavern"), and a bulk "Set tag" action is meant to add one more,
|
||||
// not wipe out the others.
|
||||
const newTags = inp.value.split(',').map(t => t.trim()).filter(Boolean);
|
||||
let done = 0;
|
||||
for (const id of ids) {
|
||||
await saveMeta(id, { tag: tagVal }).catch(() => {});
|
||||
const v = (window._voices || []).find(vv => vv.id === id);
|
||||
const existing = String(v?.tag || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||
const merged = existing.slice();
|
||||
for (const t of newTags) if (!merged.some(e => e.toLowerCase() === t.toLowerCase())) merged.push(t);
|
||||
await saveMeta(id, { tag: merged.join(', ') }).catch(() => {});
|
||||
done++;
|
||||
}
|
||||
toast(`Tag set on ${done} voice${done!==1?'s':''}`, 'success');
|
||||
toast(`Tag added on ${done} voice${done!==1?'s':''}`, 'success');
|
||||
inp.remove(); applyBtn.remove();
|
||||
_bulkSelected.clear();
|
||||
await loadVoiceLibrary();
|
||||
|
||||
@ -15,6 +15,32 @@
|
||||
return (window._voices || []).find(v => v.id === id) || null;
|
||||
}
|
||||
|
||||
function _voiceLang(v, id) {
|
||||
const raw = String(v?.lang || v?.language || '').trim();
|
||||
const fromMeta = raw && raw.length <= 5 ? raw : '';
|
||||
const fromId = String(id || '').split('_')[0] || '';
|
||||
return (fromMeta || fromId).toUpperCase();
|
||||
}
|
||||
|
||||
function _voiceGender(v, id) {
|
||||
const raw = String(v?.gender || v?.sex || '').trim();
|
||||
const first = raw ? raw.charAt(0).toUpperCase() : '';
|
||||
if (['F', 'M', 'N'].includes(first)) return first;
|
||||
const parts = String(id || '').split('_');
|
||||
const fromId = (parts[1] || '').charAt(0).toUpperCase();
|
||||
return ['F', 'M', 'N'].includes(fromId) ? fromId : '';
|
||||
}
|
||||
|
||||
function _voiceMetaLabel(id) {
|
||||
const v = _voiceData(id);
|
||||
return [_voiceGender(v, id), _voiceLang(v, id)].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function _voiceOptionLabel(id) {
|
||||
const meta = _voiceMetaLabel(id);
|
||||
return meta ? `${id} ${meta}` : id;
|
||||
}
|
||||
|
||||
// Shared gender/type avatar icons (male/female/neutral/robot/animal). Returns null
|
||||
// when the voice has no avatar key, so callers fall back to photo/flag/letter.
|
||||
const VOICE_AVATAR_ICONS = {
|
||||
@ -71,10 +97,12 @@
|
||||
|
||||
function _buildItem(id, label) {
|
||||
const v = _voiceData(id);
|
||||
const meta = _voiceMetaLabel(id);
|
||||
const name = id || label || '';
|
||||
return `<div class="vp-item" data-value="${_esc(id)}" tabindex="-1">
|
||||
${_avatarHtml(id, 26)}
|
||||
<span class="vp-item-name">${_esc(label || id)}</span>
|
||||
${_flagSpan(v)}
|
||||
<span class="vp-item-name">${_esc(name)}</span>
|
||||
${meta ? `<span class="vp-item-meta">${_esc(meta)}</span>` : _flagSpan(v)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@ -158,8 +186,9 @@
|
||||
if (!v) {
|
||||
content.innerHTML = '<span class="vp-trigger-placeholder">— select after fetch —</span>';
|
||||
} else {
|
||||
const label = Array.from(sel.options).find(o => o.value === v)?.textContent || v;
|
||||
content.innerHTML = `${_avatarHtml(v, 24)}<span class="vp-trigger-name">${_esc(label)}</span>`;
|
||||
const label = v;
|
||||
const meta = _voiceMetaLabel(v);
|
||||
content.innerHTML = `${_avatarHtml(v, 24)}<span class="vp-trigger-name">${_esc(label)}</span>${meta ? `<span class="vp-trigger-meta">${_esc(meta)}</span>` : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -217,7 +246,8 @@
|
||||
sel.innerHTML = '<option value="">— select after fetch —</option>';
|
||||
voices.forEach(id => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = opt.textContent = id;
|
||||
opt.value = id;
|
||||
opt.textContent = _voiceOptionLabel(id);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (prev && voices.includes(prev)) sel.value = prev;
|
||||
|
||||
@ -199,6 +199,12 @@
|
||||
return TAB_SECTION_MAP[k] === sectionId;
|
||||
});
|
||||
if (tabName) runSideEffects(tabName);
|
||||
// Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
|
||||
// plain sidebar navigation (not just the crumb's own buttons).
|
||||
if (typeof window.refreshWorkflowCrumbs === 'function') {
|
||||
if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
|
||||
else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source');
|
||||
}
|
||||
};
|
||||
|
||||
// Pulsing dot on the Read Aloud nav item while casting is in progress
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wf-stepper" id="wf-stepper-reader" hidden></div>
|
||||
|
||||
<div class="tab-content" id="tab-reader">
|
||||
|
||||
<div id="reader-main-view">
|
||||
@ -37,38 +39,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reader-controls" style="margin-top:12px; background:var(--bg); border:1px solid var(--border); padding:10px 12px; border-radius:8px">
|
||||
<div class="field reader-ctl">
|
||||
<label>Speed <span id="reader-speed-label" class="reader-speed-label">1.0×</span></label>
|
||||
<div class="reader-tuning-panel" aria-label="Read Aloud tuning">
|
||||
<div class="reader-tuning-row">
|
||||
<label for="reader-speed">Speed <span id="reader-speed-label" class="reader-speed-label">1.0×</span></label>
|
||||
<input type="range" id="reader-speed" min="0.5" max="2" step="0.05" value="1" aria-label="Reading speed">
|
||||
<span class="reader-tuning-help">Playback speed while reading.</span>
|
||||
</div>
|
||||
<div class="field reader-ctl reader-ctl-sm">
|
||||
<label>Seed <span class="note">(blank = auto)</span></label>
|
||||
<div class="reader-tuning-row">
|
||||
<label for="reader-seed">Seed</label>
|
||||
<input type="number" id="reader-seed" placeholder="auto" autocomplete="off">
|
||||
<span class="reader-tuning-help">Blank uses auto; a fixed value can make repeated synthesis more consistent.</span>
|
||||
</div>
|
||||
<div class="field reader-ctl reader-ctl-sm">
|
||||
<label>Temperature <span class="note">(0–1)</span></label>
|
||||
<div class="reader-tuning-row">
|
||||
<label for="reader-temp">Temperature</label>
|
||||
<input type="number" id="reader-temp" min="0" max="1.5" step="0.05" placeholder="default" autocomplete="off">
|
||||
<span class="reader-tuning-help">Lower values, for example 0.3, keep cloned voices steadier.</span>
|
||||
</div>
|
||||
<div class="field reader-ctl reader-ctl-sm">
|
||||
<label>Native Speed <span class="note">(0.5–2)</span></label>
|
||||
<div class="reader-tuning-row">
|
||||
<label for="reader-tts-speed">Native speed</label>
|
||||
<input type="number" id="reader-tts-speed" min="0.5" max="2" step="0.05" value="1" placeholder="1.0" autocomplete="off">
|
||||
<span class="reader-tuning-help">Backend voice speed before Read Aloud playback control.</span>
|
||||
</div>
|
||||
<label class="btn-primary reader-import-btn" title="Import a PDF or text file" style="margin-left:auto">
|
||||
<span class="mdi mdi-file-upload-outline"></span> Import
|
||||
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
|
||||
</label>
|
||||
<div class="reader-backend-hint" id="reader-backend-hint"></div>
|
||||
</div>
|
||||
<div class="reader-backend-hint" id="reader-backend-hint" style="padding-left:14px; margin-top:8px"></div>
|
||||
|
||||
<!-- ② Drag & drop / empty state (inside the collapsible box) ────── -->
|
||||
<div class="reader-dropzone" id="reader-dropzone" style="margin-top:12px">
|
||||
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon"></span>
|
||||
<strong>Drag & drop a document here</strong>
|
||||
<span class="note">.pdf · .txt · .md — or use the Import button above</span>
|
||||
<!-- ② Text paste + drag & drop / empty state ────────────────────── -->
|
||||
<div class="reader-source-panel" id="reader-import-area">
|
||||
<div class="reader-source-head">
|
||||
<div>
|
||||
<strong><span class="mdi mdi-file-document-plus-outline"></span> Source</strong>
|
||||
<span>Paste text, drop a document, or import a file.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reader-source-grid">
|
||||
<div class="reader-paste-panel">
|
||||
<div class="reader-source-method">
|
||||
<span class="mdi mdi-text-box-outline"></span>
|
||||
<div><strong>Paste text</strong><span>Plain text, Markdown, chapters, notes, or draft prose.</span></div>
|
||||
</div>
|
||||
<textarea id="reader-paste-text" class="reader-paste-text" rows="5" placeholder="Paste text here..." aria-label="Paste text to read aloud"></textarea>
|
||||
<div class="reader-paste-actions">
|
||||
<button class="btn-primary btn-sm" id="reader-paste-load" type="button"><span class="mdi mdi-text-box-check-outline"></span> Use pasted text</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reader-dropzone" id="reader-dropzone">
|
||||
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon"></span>
|
||||
<strong>Drop a document</strong>
|
||||
<span class="note">PDF, TXT, or Markdown</span>
|
||||
<label class="btn-secondary btn-sm reader-file-pick" title="Import a PDF or text file">
|
||||
<span class="mdi mdi-file-upload-outline"></span> Import file
|
||||
<input type="file" id="reader-file-input" accept=".pdf,.txt,.md" style="display:none">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ②b Extract Text — shown after a PDF's pages are loaded/visible but before
|
||||
getTextContent()+OCR has run; this is where extraction options belong ── -->
|
||||
<div class="reader-extract-banner" id="reader-extract-banner" hidden>
|
||||
<span class="mdi mdi-text-recognition reader-extract-icon"></span>
|
||||
<div class="reader-extract-info">
|
||||
<strong>PDF loaded · <span id="reader-extract-pagecount">0</span> pages</strong>
|
||||
<p>Extract the text to enable reading, search, and casting.</p>
|
||||
</div>
|
||||
<label class="reader-ctl" title="Recover chapter headings that are embedded as images in the PDF (rather than real text) using in-browser OCR. Slightly slows extraction." style="margin:0; font-size:11px">
|
||||
<input type="checkbox" id="reader-ocr-headings" checked> OCR headings
|
||||
</label>
|
||||
<button class="btn-primary btn-sm" id="reader-extract-btn" type="button"><span class="mdi mdi-text-recognition"></span> Extract Text</button>
|
||||
</div>
|
||||
|
||||
<!-- ③ Zoom toolbar + search + status legend (PDF) — above the document ───── -->
|
||||
<div class="reader-toolbar" id="reader-toolbar" hidden>
|
||||
<div class="reader-zoom" id="reader-zoom">
|
||||
@ -84,6 +124,9 @@
|
||||
<span class="mdi mdi-magnify reader-search-icon"></span>
|
||||
<input type="search" id="reader-search" class="reader-search-inp" placeholder="Search…" autocomplete="off" aria-label="Search document text">
|
||||
<span class="reader-search-status" id="reader-search-status"></span>
|
||||
<button class="reader-search-btn" id="reader-search-prev" type="button" title="Previous match" aria-label="Previous search match"><span class="mdi mdi-chevron-up"></span></button>
|
||||
<button class="reader-search-btn" id="reader-search-next" type="button" title="Next match" aria-label="Next search match"><span class="mdi mdi-chevron-down"></span></button>
|
||||
<button class="reader-search-btn" id="reader-search-clear" type="button" title="Clear search" aria-label="Clear search"><span class="mdi mdi-close"></span></button>
|
||||
</div>
|
||||
<div class="reader-legend">
|
||||
<span class="reader-leg reader-leg-pending">Not synthesised</span>
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wf-stepper" id="wf-stepper-rehearser" hidden></div>
|
||||
|
||||
<!-- Phase sub-tabs -->
|
||||
<div class="reh-subtabs" id="reh-subtabs">
|
||||
<button class="reh-subtab active" data-phase="1"><span class="mdi mdi-bookshelf"></span> <span>Library</span></button>
|
||||
|
||||
@ -92,7 +92,7 @@
|
||||
<button class="vl-bulk-btn" id="vl-bulk-select-all" title="Select all visible"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> All</button>
|
||||
<button class="vl-bulk-btn" id="vl-bulk-deselect" title="Clear selection"><span class="mdi mdi-checkbox-multiple-blank-outline"></span> None</button>
|
||||
<div class="vl-bulk-sep"></div>
|
||||
<button class="vl-bulk-btn" id="vl-bulk-tag" title="Add / replace tag on selected voices"><span class="mdi mdi-tag-outline"></span> Set tag</button>
|
||||
<button class="vl-bulk-btn" id="vl-bulk-tag" title="Add a tag to the selected voices, keeping their existing tags"><span class="mdi mdi-tag-outline"></span> Set tag</button>
|
||||
<button class="vl-bulk-btn" id="vl-bulk-source" title="Set source / origin on selected voices"><span class="mdi mdi-database-outline"></span> Set source</button>
|
||||
<button class="vl-bulk-btn" id="vl-bulk-hide" title="Disable (hide) selected voices"><span class="mdi mdi-eye-off-outline"></span> Hide</button>
|
||||
<button class="vl-bulk-btn" id="vl-bulk-unhide" title="Enable (unhide) selected voices"><span class="mdi mdi-eye-outline"></span> Unhide</button>
|
||||
|
||||
610
static/style.css
610
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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user