Release v1.22.2
This commit is contained in:
parent
e2f7b90ad9
commit
fe807d1682
61
.github/workflows/sonarqube.yml
vendored
Normal file
61
.github/workflows/sonarqube.yml
vendored
Normal file
@ -0,0 +1,61 @@
|
||||
name: Test and SonarQube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history so SonarQube can attribute issues to commits (blame).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci || npm install
|
||||
|
||||
# Checks that run without a live server. The Playwright suite in tests/
|
||||
# drives the running application (Docker, port 7890) and cannot execute
|
||||
# on a bare CI runner, so it is deliberately NOT wired in here — a step
|
||||
# that always fails would make the Quality Gate meaningless. Wire it up
|
||||
# once the app can be started in CI.
|
||||
- name: Verify Python sources compile
|
||||
run: python -m compileall -q core routes scripts server.py ttsvc_cli.py
|
||||
|
||||
- name: Verify JavaScript parses
|
||||
run: |
|
||||
for f in $(git ls-files 'static/js/*.js' 'scripts/*.mjs'); do
|
||||
node --check "$f" || exit 1
|
||||
done
|
||||
|
||||
- name: Build the frontend bundle
|
||||
run: node scripts/minify.mjs
|
||||
|
||||
- name: SonarQube scan
|
||||
uses: SonarSource/sonarqube-scan-action@v8.1.0
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||
with:
|
||||
# Project key, sources and exclusions live in sonar-project.properties
|
||||
# so they stay identical for local and CI scans.
|
||||
args: >-
|
||||
-Dsonar.qualitygate.wait=true
|
||||
-Dsonar.qualitygate.timeout=300
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -51,3 +51,6 @@ static/dist/
|
||||
# Local caches / scratch / recovery artifacts
|
||||
claude studio/
|
||||
*.recovered-*.yml
|
||||
|
||||
# Local SonarQube working guide (not part of the project)
|
||||
SONARQUBE-LLM-GUIDE.md
|
||||
|
||||
111
CHANGELOG.md
111
CHANGELOG.md
@ -5,6 +5,114 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.22.2] — 2026-09-03
|
||||
|
||||
### Added
|
||||
- **SonarQube Continuous Analysis.** Added `.github/workflows/sonarqube.yml` and `sonar-project.properties` so pushes to
|
||||
`main` are scanned by the SonarQube Community Build instance, with the Quality Gate blocking the job on failure. The
|
||||
scan covers hand-written source only — the generated `static/dist/` bundle, vendored code, ComfyUI workflow graphs and
|
||||
runtime `config/` state are excluded, since scanning them buries real findings in noise. CI verifies that Python
|
||||
compiles, every JavaScript file parses and the bundle builds; the Playwright suite is deliberately not wired in yet
|
||||
because it drives a running application on port 7890 and cannot execute on a bare runner, and a step that always fails
|
||||
would make the Quality Gate meaningless. Credentials are referenced only as `secrets.SONAR_TOKEN` / `vars.SONAR_HOST_URL`
|
||||
and appear in no committed file.
|
||||
|
||||
### Changed
|
||||
- **Deployment Now Uses a Published Image.** The container image was only ever built locally and never pushed anywhere, so Portainer's "Pull and redeploy" always failed with `pull access denied … repository does not exist` — and the failed deploy removed the running container, taking the app offline. The image is now published to GitHub Container Registry as `ghcr.io/martin-b78/tts-voice-creator-clone-and-design-2`, tagged both `:latest` and the current version, and `docker-compose.yml` / `portainer-stack.yml` reference that name, so re-pull works normally. `docker-compose.yml` keeps its `build:` section, so `docker compose build` still produces the image locally under the registry name. Python and static files remain bind-mounted: code changes still need only a container recreate, and a rebuild plus push is required only when `Dockerfile` or `requirements.txt` change.
|
||||
|
||||
### Fixed
|
||||
- **Book Period Was Never Applied to Character Images.** `csBuildImagePrompt` returned the LLM-written `image_prompt`
|
||||
from its first line, before the block that appends the book's genre, setting and era — so whenever an LLM prompt
|
||||
existed (effectively always) the world grounding was dead code. That block even contained an explicit rule that a rank
|
||||
title "should NOT be drawn in a contemporary military uniform", which therefore never ran: "Oberst Alrik" was rendered
|
||||
in a 19th-century dress uniform with epaulettes and gold braid in a book explicitly dated 1420-1500. The grounding is
|
||||
now appended to LLM-written prompts as well, and `/api/character-generate-image` accepts a `book_context` field so
|
||||
direct API callers get the same period lock. Verified by regenerating: epaulettes and the Napoleonic tunic are gone.
|
||||
|
||||
- **Portraits Were Full-Body Scenes, Not Casting Portraits.** Generated `image_prompt` values narrated the scene the
|
||||
character appeared in — "standing in a grassy landscape at dusk", "he is riding a horse", "the background shows a
|
||||
campfire" — so every portrait came out as a wide environmental shot, and one rendered a medieval character in a room
|
||||
with a radiator and a modern window. Three changes: the sheet guidance now forbids setting, location, weather,
|
||||
background and actions in `image_prompt` (not just art style); the portrait path strips whole scene sentences from the
|
||||
~190 prompts already saved; and the appended rendering directive now states framing explicitly and overrides the
|
||||
prompt — one person, tight head-and-shoulders, plain neutral backdrop, no anachronistic objects. Verified by
|
||||
regenerating: framing is tight and the invented clutter is gone.
|
||||
- **Injury and Transformation Guidance Strengthened Again.** The first fix (baseline appearance, wounds to `notes`) was
|
||||
not enough: a character regenerated *after* it still produced "naked, almost flayed, raw flesh dripping blood" as their
|
||||
permanent description, and rendered as a nude gore figure. The field now also names gore, nudity, flaying and
|
||||
transformation, and instructs that when the book only ever describes someone vividly during such a scene, their
|
||||
ordinary clothed appearance should be inferred from role, rank and origin and marked as inferred.
|
||||
|
||||
- **Portrait Generation Produced Pure Black Images.** The bundled portrait workflow ran Flux-dev with `cfg: 3.5`. Flux-dev
|
||||
is a distilled model and requires `cfg: 1.0` (its guidance belongs in a `FluxGuidance` node), so the sampler produced a
|
||||
black frame — which ComfyUI reported as a *successful* job, and the app saved as a 19 KB "portrait". Every other bundled
|
||||
workflow (concept sheet, identity, consistent-images) already used `cfg: 1.0`; the portrait graph was the lone outlier.
|
||||
Corrected to 1.0, after which the same prompt renders a real 1.28 MB image.
|
||||
- **Bundled Workflows Are Baked Into the Image, Not Mounted.** `/app/workflows` ships inside the container, so editing
|
||||
`workflows/*.json` in the repo has no effect at runtime until the image is rebuilt — the container kept reporting the old
|
||||
`cfg: 3.5` after the file on disk read `1.0`. The corrected graph is therefore also saved into Settings
|
||||
(`comfyui_workflow`), which takes precedence over the bundled copy and persists across restarts.
|
||||
|
||||
- **Character Portraits Rendered People's Injuries as Their Faces.** The `physical` field captured whatever the book
|
||||
described at a character's worst moment — Bartka's read "Schädel ist eine blutverkrustete Masse, skalpiert", Amber's
|
||||
"trägt eine klaffende Wunde an der Brust" — and that text is used verbatim to generate the portrait, so Bartka rendered
|
||||
as a half-skull corpse and Amber with a bleeding chest. The field now explicitly asks for the character's baseline
|
||||
appearance through most of the book, with wounds, blood, dying and unconscious states directed to `notes`/`arc_note`
|
||||
instead, and states why: a transient injury written there becomes the character's permanent face.
|
||||
- **Portrait Prompts Fought Their Own Rendering Directive.** Saved `image_prompt` values embedded their own style, medium,
|
||||
framing and background ("painterly game concept art", "plain neutral parchment background"), while the renderer appends
|
||||
a directive demanding a photorealistic live-action portrait that is explicitly "not an illustration, painting". Every
|
||||
portrait was generated from two contradictory briefs at once. The field guidance now forbids style/medium/framing/
|
||||
lighting in `image_prompt`, and the portrait path strips such clauses from the ~190 prompts already saved, keeping only
|
||||
the text describing who the character is. Concept-art generation is untouched, since it has its own illustrative
|
||||
directive.
|
||||
|
||||
- **Buttons Silently Did Nothing While a Pass Was Running.** Casting, Recast, Verify and Export all opened with an
|
||||
`if (_audiobook.running) return;` guard that returned in complete silence, so clicking one while another pass was in
|
||||
flight looked identical to a broken button. This window opens on its own: casting auto-starts the auto-repair sequence
|
||||
800 ms after finishing, with no visible indication. Confirmed live — an automated pipeline recorded Recast and Verify as
|
||||
"done" in under a second each because both returned instantly this way, which would have reported a cast-only result as
|
||||
a full pipeline. The guards now explain the refusal instead of swallowing the click.
|
||||
|
||||
- **ElevenLabs Imports Always Tagged EN.** `sourceLanguageCode()` required an uppercase 2-letter code to recognize an already-valid language code, but ElevenLabs' API returns it lowercase ("fr", "de") — the check silently failed and fell back to a name-based lookup table that only has full language names as keys, not ISO codes, defaulting every non-English ElevenLabs import to EN regardless of the voice's actual (correctly displayed) language tag. Now matches the code case-insensitively.
|
||||
- **Voice-Clone Reference Transcript Never Reached the TTS Engine.** Saving a reference transcript from the Voice Library only wrote this app's own sidecar file — the actual Qwen voice-clone engine reads `ref_text` from its own separate `voices.json`, which nothing updated outside one unrelated batch-import code path. No amount of restarting TTS containers fixed a design-origin voice's playback (`ref_text is required` / connection reset) because the stale `voices.json` entry itself never changed. Saving a transcript now also publishes it to the engine and clears the stale cached embedding.
|
||||
- **Rename Filename Silently Did Nothing.** Same root cause as an earlier `.vr-bench` bug: selecting a voice moves its list row into the single-voice detail view, so by the time "rename filename" was clicked there, the code's `wrap.querySelector('.vr-name-input')` was searching an already-emptied container and found nothing — no error, no rename. Added the same `#voices-inspector` fallback already used for the benchmark cell, plus a visible error if the control still can't be found.
|
||||
- **Design-Origin Voice Playback Permanently Misrouted.** A voice's Play button decided voice_clone vs Voice Design backend by checking `origin !== 'designed'` — so a voice originally made via Voice Design that had since had a real reference WAV + transcript attached (fully clone-capable) still had every playback routed to the Voice Design service forever, 404ing when that service wasn't reachable at its configured port. Now decided purely by whether the voice currently has a reference clip.
|
||||
- **STT Engine Picker Had No Effect.** Picking a different engine in the footer's STT flyup only pushed the value into a few unrelated `<select>` elements on other pages — it was never persisted, and `/api/transcribe` (used by "Re-recognise text") always fell back to the legacy, frequently-dead "Configured Whisper/STT" URL regardless of what was selected. The flyup now persists the pick to the (previously unused) `stt_preferred_backend` setting, and `/api/transcribe` honors it when no explicit backend is requested.
|
||||
- **Design-Origin Voices Permanently Misrouted for Playback.** A voice's Play button decided whether to use the voice_clone or Voice Design backend by checking `origin !== 'designed'` — so a voice originally made via Voice Design that had since had a real reference WAV + transcript attached (making it fully clone-capable) still had every playback routed to the Voice Design service forever, producing a 404 when that service wasn't reachable at its configured port. Now decided purely by whether the voice currently has a reference clip.
|
||||
### Added
|
||||
- **Attribution Confidence + Review Navigation.** Every cast line now carries a colour-coded, calibrated confidence badge — `Confident 96%`, `Likely 79%`, `Likely 75%`, `Probably 69%`, `Doubtful 47%`, `Guess 16%` — with a tooltip giving the reasoning ("no nearby speech tag", "speaker is a generic role label", "fused: several turns in one segment"). Scored from local evidence only, no extra LLM calls.
|
||||
|
||||
The percentages are **calibrated against the hand-corrected reference rather than raw heuristic points**: each band displays the correctness actually observed for lines scoring in it, so "Doubtful 47%" means such lines really were right 47% of the time. Every band verified within 0.8 points. Nothing is labelled "certain" — even the best band is wrong about 1 line in 25, and a label the reviewer learns to distrust is worse than no label.
|
||||
|
||||
Review controls sit in the feed toolbar: prev/next buttons that jump only between lines needing attention, a live count, a threshold selector ("Not Confident" or "Doubtful + Guess"), and F2 / Shift+F2. **On the measured book this flags 1405 of 2273 lines (62%) while catching ~90% of all real attribution errors** — a substantial saving over reading everything, though less dramatic than the 45% suggested by the text-matched subset alone.
|
||||
|
||||
Also adds "2.6 Score attribution confidence" under Identify Characters, which scores an existing cast in place (instant, no LLM calls) so books cast before this feature don't need re-casting to get colours.
|
||||
- **Generic Role Labels No Longer Get Character Profiles.** Bit parts labelled only by role ("Wache", "Mann", "Menge") that speak a line or two cannot support a profile — there is nothing in the book to fill one from. Measured: 85% of generic-role-label records came back completely empty (24 of 28) versus 13% for records naming a real person. Role labels with fewer than 4 lines are now skipped for profile generation, saving an LLM call each and keeping empty shells out of the character library. They remain in the cast and still get voices; role labels with real presence (4+ lines) are still profiled.
|
||||
- **"Also Known As" Role-Label Resolution.** The caster routinely credits a line to what a character *is* ("Schmied", "Henker", "Druide") rather than who they *are*, and each such label became its own cast entry with its own voice. Speaker labels are now resolved to proper names by reading German appositive constructions out of the book's own text — "Darrag der Schmied" / "der Schmied Darrag" / "Zerwas den Henker" — where the role is always the noun immediately after the article. Purely grammatical, so it carries to any book with no per-title configuration. Requires the pairing to recur at least twice and win outright, since one stray capitalised-noun pair is otherwise enough to bind a role to the wrong character. Measured against the hand-corrected reference: `Schmied→Darrag`, `Druide→Gamba`, `Amazone→Lysandra`, `Henker→Zerwas`, +35 correct attributions.
|
||||
|
||||
### Fixed
|
||||
- **Regional Accents Use BCP 47, Eliminating Code Collisions.** English voices can be named for their accent — `EN-GB`, `EN-US`, `EN-AU`, `EN-CA`, `EN-IE`, `EN-SCT`, `EN-NZ`, `EN-ZA`, `EN-IN` — with the same pattern for `DE-AT`, `DE-CH`, `PT-BR`, `ES-MX`, `ES-AR` and `FR-CA`. All collapse to their base language for routing and detection, since text alone cannot distinguish Australian from Irish English. Expressing the region as a BCP 47 subtag rather than a bare country code removes the namespace clash outright: every bare code keeps its ISO 639-1 meaning, so `CA` is Catalan, `AR` Arabic, `BR` Breton, `UK` Ukrainian, `IE` Interlingue, `CH` Chamorro, `ZA` Zhuang, `SG` Sango and `CO` Corsican. An interim country-code scheme had mapped nine of those onto the wrong language — `AR`→Spanish would have silently routed every Arabic request to a Spanish voice. Only country codes that are *not* ISO 639-1 languages (`GB`, `US`, `AU`, `NZ`, `PH`, `AT`, `MX`, `QC`…) are still accepted, purely so existing routes and the library's `PH_*` voice keep working.
|
||||
- **One Language Picker Everywhere.** The searchable flag dropdown is now used by every language selector — Clone a Voice, Design a Voice, the voice library's Add dialog, the Rehearser's design language and App Routing — instead of each having its own hardcoded and differently-sized list. Pickers that feed the TTS backend a language *name* ("German", not "DE") are enhanced without changing their values, so that contract is preserved.
|
||||
- **Non-ASCII Names Were Being Destroyed.** Route app/voice/sound values were sanitised against an `A-Za-z0-9` whitelist, so every non-ASCII letter became an underscore: an app named "Hörbuch" was stored as "H_rbuch", and Cyrillic, Greek, Japanese or Filipino names were reduced to underscores, after which the route could never match the name the user had typed. Sanitising now uses Unicode word characters, and the routes file is read and written as explicit UTF-8 (stored unescaped, so it stays human-readable). Punctuation and control characters are still stripped. Verified by round-trip through the API and disk.
|
||||
- **Language Detection Widened From 8 To 28 Languages.** Routing's text detector recognised only EN/DE/FR/ES/IT/PT/NL/PL, so a rule for any other language could never match on detected text — including the Filipino voice in the library. Non-Latin scripts are now identified by writing system (Cyrillic, Han, Kana, Hangul, Arabic, Hebrew, Greek, Devanagari, Thai and more), which needs no vocabulary and covers those languages permanently; Latin-script coverage gained Filipino, Indonesian, Turkish, Swedish, Danish, Norwegian, Finnish, Romanian, Czech, Hungarian, Vietnamese and Catalan. `PH`/`FIL` now alias to `TL`, and `GB`/`US` to `EN`, so a route labelled with a country code still matches. Fixed three defects found while testing: ä/ö/ü scored +3 for German although Turkish, Swedish and Finnish use them too ("bugün nasılsın" and "tack så mycket för hjälpen" both came back as German — now +1, with genuinely unique marks like ß, ñ, å keeping the strong score); Dutch, Polish and Portuguese sat in the score table with no word list at all and could never win on vocabulary; and a matched word scored the same as a shared diacritic, so one real hit only tied and lost on dict ordering. Verified 28/28 on a language sample.
|
||||
- **Routing Silently Reverted Unsupported Languages.** `core/routing.py` validated a route's language against its own eight-value whitelist and reset anything else to "Any" on save — so choosing Filipino, picking Save, and watching the row snap back to Any looked like a UI bug but was the server discarding the value, with no error. Any ISO-style 2-3 letter code is now accepted; genuinely malformed values still fall back to "Any". Verified by round-trip: TL persists, garbage does not.
|
||||
- **App Routing Covers Every Language.** The routing rules' Language dropdown offered only eight languages plus Any/Auto, so a route could not be written for anything else — a Filipino route had to be left on "Auto". It now draws from the same shared 79-entry language list, with Any/Auto still first. Deliberately kept as a `<select>` rather than the free-text combobox used for voice naming: a route's language is matched exactly against the detected code, so a typo there would not look broken — the rule would simply never match and traffic would fall through to a different voice. Falls back to the original eight if the shared list is unavailable.
|
||||
- **Any Language Code Can Be Entered.** The Language pickers in Clone a Voice, Design a Voice and the voice library's Add dialog were `<select>`s with ~17 hardcoded codes, so a language outside that list simply could not be chosen — reported live for Filipino, which had no entry. They are now editable comboboxes backed by a shared searchable datalist of 77 languages (code plus name, so typing "Tagalog" finds `TL`), and any code not on the list can still be typed directly. Input is constrained to letters, upper-cased and capped at three characters, since the code becomes part of the voice's filename ID. The generated ID now also updates while typing rather than only on blur.
|
||||
- **Arrow-Key Review Workflow.** Reviewing 1400 flagged lines by mouse was impractical, so the review queue is now driven from the keyboard: **←** previous, **→** next, **↑** mark correct, **↓** mark wrong, **1–9** assign the Nth most-spoken character, **n**/**0** assign Narrator, **u** undo. Marking or assigning settles the line and advances automatically, so a triage pass never re-serves the same line. Correct and wrong are separate verdicts: flagged-wrong lines drop into their own "Marked wrong" filter for a second, fixing-only pass, which is far quicker than deciding and repairing line by line. The counter shows both (`847 to check · 412✓ 144✗`), verdicts persist with the cast draft, and plain arrows are only captured once a review is under way so ordinary scrolling still works. Also fixes the F2 shortcut, which was bound to a `<div>` and therefore never fired unless that element happened to hold focus.
|
||||
- **Same-Speaker Dialogue Never Re-Merged After Recast.** The merge pass runs once at the end of casting, but recast and verify then reassign speakers and create fresh runs of consecutive lines by one character that nothing ever merged — 213 of them in the measured book. A dialogue-only merge now runs after those passes. Deliberately one-sided: measured against the human reference the caster's narration is already too coarse (median 152 chars vs 123) while its dialogue is slightly too fine (62 vs 65), and merging narration as well pushed text mismatches *up* from 727 to 746. Dialogue-only brings them down to 588 and moves the segment count from 2273 to 2059 against the human's 2110. Emotion tags are deliberately not required to match — requiring them blocked all but 42 of the 214 merges and left segmentation at 2230 segments / 685 mismatches. The cost is one emotion tag per character block rather than per chunk; the hand-corrected reference merges these blocks too, so a human reading the scene judged one emotion sufficient.
|
||||
- **Speaker Attribution Biased Toward the Protagonist.** The casting prompt instructed the model to "prefer the most likely named character over 'Unknown'", which produced 268 wrong names against only 4 Unknowns — lines credited to whoever was most prominent in the scene, above all the protagonist, who absorbed lines from the very characters addressing him (125 lines sat in bidirectional swap pairs, with the protagonist in 9 of the 18). The prompt now states explicitly that the nearest named character is often the addressee rather than the speaker, and directs the model to output 'Unknown' when the only evidence is proximity or importance. With uncertain lines now flagged and jumpable, an honest 'Unknown' is cheap to fix while a confident wrong name goes unnoticed.
|
||||
- **Auto-Repair's Final Sweep Never Finished Either.** Auto-repair runs unattended after every cast, and its last step used the same brute-force `includeNarrator` sweep — so the fix to the manual "Verify all characters" button did not cover it. Confirmed live: it sat for roughly 4 hours making almost no LLM calls, changed not a single segment, and ended only when the host process hit its 6-hour cap. It now uses the bounded suspect-scored pass like the rounds before it; no code path auto-triggers the unbounded sweep any more.
|
||||
- **"Verify All Characters" Never Finished.** The pass queued *every* narration segment — ~1000 lines on a real novel, about 190 sequential LLM calls — and in practice never completed, sitting at 0/1141 for hours before being abandoned, so it repaired nothing at all. It now uses the existing suspect-narration scoring to target ~265 plausible lines (~65 calls), which does complete. The scorer was also improved on two generic German signals: sustained first/second-person pronouns (a long speech that lost its quote marks is not third-person narration) and imperatives preceded by a discourse particle ("Nun schafft …"). Together this raises its catch rate on real "dialogue swallowed into narration" errors from 27/51 to 37/51.
|
||||
- **Duplicated Quotes and Speech Tags in Casting Output.** The LLM commonly returns a quote as its own segment *and* the full original sentence (quote + speech tag) as the next one. The cleanup only dropped an overlap when the narration **ended with** the dialogue text, never when it **started with** it — so the same words were spoken twice and the leftover got re-split downstream into a third stray fragment (confirmed live: `»Wir werden sehen«` appearing as two dialogue lines plus a bare `, murmelte der Ork.`; narration paragraphs restated verbatim before continuing). Duplicated prefixes are now stripped, comparing only significant characters so differing quote marks/whitespace don't hide the repeat, and the remaining speech tag is correctly typed as Narrator rather than left on the character who just spoke.
|
||||
- **Re-split Pass Bypassed the Fragment Check.** The narration re-split that runs after per-segment cleanup creates brand-new dialogue segments which never went through the one-word-fragment plausibility check — which is why a `»quoted title«` still survived as dialogue despite the previous fix. The check now also runs on that pass's output.
|
||||
- **Base Casting Also Over-Fragmented Guillemet Titles.** The 1.22.2 fix for this only covered the verification pass (`audiobookRecastUnknown`) — the initial "Cast as Audiobook" pass has its own separate per-chunk cleanup logic with the same gap, plus an existing quote-hallucination check that only looks at text over 10 characters, so a short »quoted title/name« (which does have real quote marks around it, just not as speech) sailed straight through. Confirmed live: a fresh cast run produced 5296 segments (vs. ~2100-2300 expected) with the same "»Lilo« and »Zerwas«" byline split into three one-word segments. The shared plausibility check now runs in both the initial cast and the verification pass.
|
||||
- **"Verify All Characters" Over-Fragmentation.** The 2nd Quality Run / "Verify all characters" pass accepted any multi-segment split the LLM returned for an existing line as long as nothing was left "Unknown" — with no check that the split was actually plausible. German uses guillemets (»«) for quoted titles/names as well as speech, so a stylistic line like a dedication byline ("»Lilo« and »Zerwas«") could get cut into three pieces with the two bare one-word "dialogue" fragments assigned guessed speakers, ballooning the cast (confirmed live: 2471 → 4728 segments, +91%, in a single verification pass). A dialogue split-piece that's a single word with no sentence-ending punctuation — never how a real spoken line reads — is now rejected before it's accepted, falling back to leaving the original line as-is.
|
||||
- **Character Profile Cross-Contamination.** The character-sheet extraction prompt asks for one JSON sheet per character mentioned in a passage, but never told the LLM to keep facts scoped to the specific character they describe — a scene where one character was called "leader of the Orks" could leak that title/role onto other characters just mentioned nearby, and since the (template-based) portrait prompt is built from those same sheet fields, contaminated sheets produced similar-looking character images too. Added an explicit strict-attribution rule to the prompt: only the character a fact specifically and explicitly describes gets it, never a co-mentioned character in the same scene/group — ambiguous ownership is left blank rather than guessed.
|
||||
- **German Chapter Headings Mispronounced.** "1. Kapitel" was being sent to TTS verbatim, and the bare digit-plus-period reads as a list marker to the TTS frontend's normalizer rather than a number — it went silent, leaving only "Kapitel" spoken after an odd pause. German chapter/part/book headings are now spelled out as spoken ordinals ("Erstes Kapitel", "Dritter Teil", "Zweites Buch") before synthesis, with correct grammatical gender per noun. Only the audio-bound copy of the text changes — casting, display, and export filenames are untouched.
|
||||
- **Dropped Text During Casting.** The LLM occasionally omitted a sentence or whole paragraph from its dialogue-attribution response for a passage — not mis-attributed, just never mentioned — and nothing re-checked the source text against what actually made it into the cast, so the audiobook would silently skip straight to the next paragraph. Each passage's source text is now checked sentence-by-sentence against the returned segments; any gap is recovered as a Narrator segment spliced back in next to its nearest surviving neighbour, and the cast summary now reports how many gaps were recovered.
|
||||
- **PDF Import Friction.** Uploading a PDF now extracts its text automatically instead of requiring a separate "Extract Text" click — the step was never optional, so the manual gate just added friction. The "OCR headings" toggle moved out of the removed prompt banner to sit next to the file-drop zone instead.
|
||||
|
||||
## [1.22.1] — 2026-08-23
|
||||
|
||||
### Fixed
|
||||
@ -3127,7 +3235,8 @@ Initial feature-complete release.
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.24...HEAD
|
||||
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.22.2...HEAD
|
||||
[1.22.2]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.22.1...v1.22.2
|
||||
[1.14.24]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.23...v1.14.24
|
||||
[1.14.23]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.22...v1.14.23
|
||||
[1.14.22]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.14.21...v1.14.22
|
||||
|
||||
110
TODO.md
110
TODO.md
@ -106,3 +106,113 @@ Priority levels:
|
||||
- [ ] Test in a VM or container limited to 4 GB RAM.
|
||||
- [ ] Monitor browser-tab memory, JavaScript heap, DOM-node count, active audio blobs, Docker memory, and temporary disk usage.
|
||||
- [ ] Add a memory diagnostics page showing browser heap where supported, rendered rows, cache count/size, active jobs, server/container memory, and largest recent operations.
|
||||
|
||||
## P1 — Speaker attribution: measured state and next steps (paused 2026-09-02)
|
||||
|
||||
Three full regression runs of *Der Sturm* against the hand-corrected reference.
|
||||
Everything below is measured, not estimated. Artefacts live in
|
||||
`config/regression_snapshots/der_sturm/` (`*_manual.json` are the human
|
||||
reference — never overwrite them).
|
||||
|
||||
| Run | Segments | Headline agreement | **Fully correct** | Wrong speaker | Unmatched |
|
||||
|-----|---------:|-------------------:|------------------:|--------------:|----------:|
|
||||
| 1 | 2143 | 72.6% | **54.2%** | 438 | 544 |
|
||||
| 2 | 2273 | 77.7% | **52.8%** | 345 | 727 |
|
||||
| 3 | 2227 | 78.7% | **52.2%** | 314 | 750 |
|
||||
| human reference | 2110 | — | — | 0 | — |
|
||||
|
||||
**Read the headline with care.** Agreement is computed only over rows whose text
|
||||
matches the human's, and that pool keeps shrinking (1598 → 1545 → 1475). Absolute
|
||||
correct segments went 1160 → 1200 → 1161. The rising headline is largely a
|
||||
shrinking denominator; true end-to-end accuracy has drifted slightly *down*.
|
||||
|
||||
### What genuinely improved
|
||||
- Unknown speakers 121 → 5 → 8; the addressee/Unknown prompt rewrite did **not**
|
||||
overcorrect (369 after cast collapsed to 19 after one recast pass).
|
||||
- Wrong speaker names 438 → 345 → 314 — consistent, real.
|
||||
- Verify pass now completes (~65 LLM calls) instead of never finishing.
|
||||
|
||||
### The blocker: segmentation, not attribution
|
||||
750 of ~1060 problem segments are text that doesn't match the human's at all:
|
||||
roughly 400 split finer than the human, ~210 merged where the human split, ~70
|
||||
OCR/wording differences. Attribution work cannot fix these.
|
||||
|
||||
- `fused` dialogue segments hit **212** (human: 6, run 2: 59). These are
|
||||
`_audiobookMergeAdjacentDialogue` joins using `\n\n`; 137 of them match no
|
||||
human segment. Splitting them back was tested and is **worse** (52.2% → 43.4%),
|
||||
so the merge is not the primary cause — but merging blocks the human kept
|
||||
separate is contributing.
|
||||
- The merge was predicted (on run 2 data) to cut unmatched 727 → 588. On run 3 it
|
||||
went to 750. **That prediction did not hold** and needs re-deriving on run 3.
|
||||
- Known unfixed: the Schwester Larissa epigraph still splits mid-sentence
|
||||
(unclosed-guillemet handling).
|
||||
|
||||
### Next steps, highest value first
|
||||
1. Attack segmentation directly — it is now the dominant error class. Start by
|
||||
re-measuring the dialogue-merge rule against `cast_run3.json` rather than
|
||||
run 2, since its original justification no longer holds.
|
||||
2. Decide the merge's fate on that evidence: keep, tune the join, or revert
|
||||
(`_audiobookMergeAdjacentDialogue`, audiobook.js). Note it deliberately
|
||||
ignores emotion tags, so a character block carries one emotion — reverting
|
||||
restores per-chunk emotion at the cost of segmentation.
|
||||
3. Only then re-run book 1. Re-running before segmentation moves will re-measure
|
||||
rules already known not to shift the number that matters.
|
||||
4. **Book 2 generalization run never started.** Reference is backed up at
|
||||
`config/regression_snapshots/book2/`. Its point is to test whether these rules
|
||||
generalise or were overfit to book 1 — still unanswered, and the confidence
|
||||
scorer in particular was tuned solely on book 1.
|
||||
|
||||
### Character profiles
|
||||
48 human vs 64 auto (run 3), 35 matched, 13 missed. Auto over-generates and got
|
||||
worse than run 2's 51. Generic role labels (`Fremder` 19 lines, `Vampir` 19,
|
||||
`Inquisitor` 15, `Ork` 13, and the adverb `Unsicher`) still become cast members
|
||||
with their own voices; the appositive resolver only fires where the book supplies
|
||||
grammatical evidence (`Darrag der Schmied`), and pre-reveal aliases like
|
||||
"der Fremde" deliberately withhold it.
|
||||
|
||||
### Harness notes (cost real time; do not relearn)
|
||||
- Casting auto-starts `audiobookAutoRepairCast` 800 ms after finishing, which
|
||||
re-sets `_audiobook.running`. Guards now explain the refusal instead of
|
||||
returning silently, but an automated driver must still wait for `running` to
|
||||
clear and treat a sub-60s phase as a bailout, not a success.
|
||||
- Stall detection must key on the passage counter (`#ab-cv-count`). `segments`,
|
||||
`done` and `unknown` do not move during a verify pass — watching them killed a
|
||||
healthy run twice.
|
||||
- Character Sheets only saves after its passage loop ends; poll `/api/characters`
|
||||
until records appear before closing the browser, or a 4.4 h run is discarded.
|
||||
|
||||
## P1 — Character forms (decided 2026-09-02, not yet built)
|
||||
|
||||
Shape-shifting characters (Zerwas: human / vampire / wolf-demon, aliases "Fürst der
|
||||
Nacht", "der Wolfsmensch") need more than one appearance per character.
|
||||
|
||||
**Decided:** forms live INSIDE one character record, not as separate records — one
|
||||
voice, one attribution target, so cast lines saying "Zerwas" always resolve. The
|
||||
sheet gains a form switcher; each form carries its own `physical`, `image_prompt`
|
||||
and portrait. **Detection is automatic**: the sheet prompt asks the LLM to identify
|
||||
whether a character has distinct forms and describe each, so it generalises to any
|
||||
book (werewolves, disguises, transformations) without per-book rules.
|
||||
|
||||
Not started. Data model, sheet UI and prompt changes all still to do.
|
||||
|
||||
## P1 — Portrait / concept-sheet identity mismatch
|
||||
|
||||
The avatar and the concept sheet can show two different people (Lancorian: dark-haired
|
||||
man vs white-haired mage). Cause is understood: `image_prompt` has two sources — the
|
||||
LLM writes it as one of the 53 character-sheet fields, while
|
||||
`_build_character_portrait_prompt` builds it deterministically from the same structured
|
||||
facts the concept sheet uses. The deterministic one keeps both images consistent; the
|
||||
LLM-written one omits identity anchors (age, hair, build) so the model invents them.
|
||||
|
||||
The app's own flow already calls `/api/character-generate-prompts` after extraction, so
|
||||
**this is mainly a process trap**: any script that regenerates sheets must call that
|
||||
endpoint too, or it leaves an LLM-authored prompt in place. Consider removing
|
||||
`image_prompt` from the sheet-extraction field list entirely so there is only one source.
|
||||
|
||||
### Known regression from the 2026-09-02 batch
|
||||
Regenerating the top-10 profiles from evidence windows (passages around each character's
|
||||
own dialogue) produced weaker `physical` text than the app's full-book pass — Lancorian's
|
||||
became behaviour ("peers through small windows, moves nimbly on spiral staircases")
|
||||
rather than appearance, where the older sheet had "shoulder-length white hair, faded scar
|
||||
across forehead". Prefer the app's own Character Sheets flow over evidence-window
|
||||
shortcuts when appearance quality matters.
|
||||
|
||||
@ -154,14 +154,27 @@ def char_get(char_id: str) -> dict | None:
|
||||
|
||||
def char_put(rec: dict) -> dict:
|
||||
image = rec.get("image")
|
||||
if isinstance(image, str) and image.startswith("/api/characters/"):
|
||||
# /api/characters (the bulk list) hands out a lightweight image URL
|
||||
# instead of the real base64 blob (see routes/characters.py). A record
|
||||
# round-tripped from that list and saved back here would otherwise
|
||||
# silently overwrite the real stored portrait with this placeholder
|
||||
# string — keep whatever is already on the row instead.
|
||||
sheet = rec.get("sheet") or {}
|
||||
concept_art_image = sheet.get("concept_art_image")
|
||||
needs_existing = (
|
||||
(isinstance(image, str) and image.startswith("/api/characters/"))
|
||||
or (isinstance(concept_art_image, str) and concept_art_image.startswith("/api/characters/"))
|
||||
)
|
||||
if needs_existing:
|
||||
# /api/characters (the bulk list) hands out lightweight URLs instead
|
||||
# of the real base64 blobs for BOTH the portrait and the reference
|
||||
# sheet image (see routes/characters.py) — the reference sheet was
|
||||
# added later and, until this guard, had no equivalent protection: a
|
||||
# record round-tripped from that list and saved back here would
|
||||
# silently overwrite the real stored reference image with this
|
||||
# placeholder URL string, permanently losing it. Keep whatever is
|
||||
# already on the row for either field instead.
|
||||
existing = char_get(rec.get("id", ""))
|
||||
rec = {**rec, "image": existing.get("image") if existing else None}
|
||||
if isinstance(image, str) and image.startswith("/api/characters/"):
|
||||
image = existing.get("image") if existing else None
|
||||
if isinstance(concept_art_image, str) and concept_art_image.startswith("/api/characters/"):
|
||||
sheet = {**sheet, "concept_art_image": (existing.get("sheet") or {}).get("concept_art_image") if existing else None}
|
||||
rec = {**rec, "image": image, "sheet": sheet}
|
||||
with _open() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO characters (id, book, name, tags, voice, image, sheet, analysis, created, updated)
|
||||
|
||||
181
core/routing.py
181
core/routing.py
@ -19,7 +19,12 @@ def _clean_route_token(value: str, default: str = "*") -> str:
|
||||
value = str(value or "").strip()
|
||||
if not value:
|
||||
return default
|
||||
value = re.sub(r"[^A-Za-z0-9_\-\.\* ]+", "_", value)
|
||||
# \w (Unicode) rather than an A-Za-z whitelist: the old pattern replaced
|
||||
# every non-ASCII letter with "_", so an app called "Hörbuch" was stored as
|
||||
# "H_rbuch" and a Cyrillic, Greek or Filipino voice name was reduced to
|
||||
# underscores — the route then never matched the name the user had typed.
|
||||
# Punctuation and control characters are still stripped.
|
||||
value = re.sub(r"[^\w\-\.\* ]+", "_", value, flags=re.UNICODE)
|
||||
return value[:80] or default
|
||||
|
||||
|
||||
@ -27,7 +32,9 @@ def _clean_route_sound(value: str) -> str:
|
||||
value = str(value or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
value = re.sub(r"[^A-Za-z0-9_\-\.\* /]+", "_", value)
|
||||
# Same Unicode fix as _clean_route_token; "/" stays allowed because sound
|
||||
# values are paths under the voices folder.
|
||||
value = re.sub(r"[^\w\-\.\* /]+", "_", value, flags=re.UNICODE)
|
||||
return value[:240]
|
||||
|
||||
|
||||
@ -63,8 +70,14 @@ def _clean_route_backend(value: str) -> str:
|
||||
|
||||
|
||||
def _normalize_route(rule: dict, idx: int = 0) -> dict:
|
||||
# Any ISO-style language code is accepted, not just the eight the built-in
|
||||
# text detector can itself produce. The old fixed whitelist silently reset
|
||||
# anything else to "*" on save — confirmed live: choosing Filipino (TL) and
|
||||
# pressing Save snapped the row back to "Any", with no error to explain it.
|
||||
# The value is only ever compared for equality against a detected code or
|
||||
# bypassed entirely for voice-specific rules, so a wider set is harmless.
|
||||
lang = str(rule.get("language", "*") or "*").strip().upper()
|
||||
if lang not in _ROUTE_LANGS:
|
||||
if lang not in _ROUTE_LANGS and not re.fullmatch(r"[A-Z]{2,3}(?:-[A-Z0-9]{2,4})?", lang):
|
||||
lang = "*"
|
||||
output_voice = _clean_route_token(rule.get("output_voice", ""), "")
|
||||
try:
|
||||
@ -106,7 +119,7 @@ def _load_tts_routes() -> list[dict]:
|
||||
result: list[dict] = []
|
||||
try:
|
||||
if TTS_ROUTES_FILE.exists():
|
||||
raw = json.loads(TTS_ROUTES_FILE.read_text())
|
||||
raw = json.loads(TTS_ROUTES_FILE.read_text(encoding="utf-8"))
|
||||
routes = raw.get("routes", raw) if isinstance(raw, dict) else raw
|
||||
if isinstance(routes, list):
|
||||
result = [_normalize_route(r, i) for i, r in enumerate(routes) if isinstance(r, dict)]
|
||||
@ -138,7 +151,7 @@ def _save_tts_routes(routes: list[dict]) -> None:
|
||||
TTS_ROUTES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
clean = [_normalize_route(r, i) for i, r in enumerate(routes)]
|
||||
state_put("tts_routes", {"routes": clean})
|
||||
TTS_ROUTES_FILE.write_text(json.dumps({"routes": clean}, indent=2))
|
||||
TTS_ROUTES_FILE.write_text(json.dumps({"routes": clean}, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
_routes_cache = clean
|
||||
_routes_cache_mtime = TTS_ROUTES_FILE.stat().st_mtime
|
||||
_routes_cache_db_updated = state_updated("tts_routes")
|
||||
@ -183,23 +196,116 @@ def _request_app_name(request: Request) -> str:
|
||||
return "Open WebUI"
|
||||
|
||||
|
||||
# Regional variants share a language. Voices are NAMED with the country code so
|
||||
# an accent is visible at a glance (AU_F_..., US_M_..., GB_N_...), but routing
|
||||
# and detection must treat them all as the one language — text alone cannot tell
|
||||
# Australian from Irish English, so a route labelled AU has to match detected EN.
|
||||
#
|
||||
# Note the namespace clash this creates: these are COUNTRY codes, while the
|
||||
# language list uses LANGUAGE codes, and "CA" means Canada here but Catalan in
|
||||
# ISO 639-1. Canadian English is the realistic case for this library, so CA maps
|
||||
# to EN and Catalan is listed as CAT in the pickers.
|
||||
_LANG_VARIANTS = {
|
||||
# ONLY bare codes that are not themselves ISO 639-1 languages. Regional
|
||||
# variants are BCP 47 subtags now (EN-GB, PT-BR), so this table exists purely
|
||||
# for backward compatibility with codes already saved in routes and baked
|
||||
# into voice filenames.
|
||||
#
|
||||
# Deliberately ABSENT because each collides with a real ISO 639-1 language,
|
||||
# which is exactly the namespace clash this rework removes:
|
||||
# CA=Catalan AR=Arabic IE=Interlingue BR=Breton CH=Chamorro
|
||||
# ZA=Zhuang UK=Ukrainian SG=Sango CO=Corsican
|
||||
# Brazilian Portuguese is PT-BR, Canadian English EN-CA, Swiss German DE-CH.
|
||||
"GB": "EN", "US": "EN", "AU": "EN", "NZ": "EN", "JM": "EN", "SCO": "EN", "WLS": "EN", "IR": "EN",
|
||||
"AT": "DE", "MX": "ES", "QC": "FR",
|
||||
"PH": "TL", "FIL": "TL",
|
||||
}
|
||||
|
||||
|
||||
def _canonical_lang(value: str) -> str:
|
||||
"""Collapse a regional variant onto its base language code.
|
||||
|
||||
"EN-GB" -> "EN", "PT-BR" -> "PT". Bare legacy country codes still resolve
|
||||
via _LANG_VARIANTS so previously saved routes and PH_* voices keep matching.
|
||||
"""
|
||||
v = str(value or "").strip().upper()
|
||||
if "-" in v:
|
||||
return v.split("-", 1)[0]
|
||||
return _LANG_VARIANTS.get(v, v)
|
||||
|
||||
|
||||
# ── Language detection ────────────────────────────────────────────────────────
|
||||
|
||||
# Languages whose WRITING SYSTEM identifies them outright. Checked before any
|
||||
# word statistics, because a script match is far stronger evidence than
|
||||
# stopword frequency and needs no per-language vocabulary. This is also what
|
||||
# makes the detector extensible for free: adding a voice in Russian, Japanese
|
||||
# or Arabic needs no new word lists.
|
||||
_SCRIPT_LANGS = [
|
||||
("JA", r"[\u3040-\u309f\u30a0-\u30ff]"), # kana — check before Han, Japanese also uses Han
|
||||
("KO", r"[\uac00-\ud7af\u1100-\u11ff]"), # hangul
|
||||
("ZH", r"[\u4e00-\u9fff]"), # han
|
||||
("RU", r"[\u0400-\u04ff]"), # cyrillic (RU is the common case; UK/BG share it)
|
||||
("EL", r"[\u0370-\u03ff]"),
|
||||
("HE", r"[\u0590-\u05ff]"),
|
||||
("AR", r"[\u0600-\u06ff]"),
|
||||
("HI", r"[\u0900-\u097f]"), # devanagari
|
||||
("BN", r"[\u0980-\u09ff]"),
|
||||
("TA", r"[\u0b80-\u0bff]"),
|
||||
("TE", r"[\u0c00-\u0c7f]"),
|
||||
("TH", r"[\u0e00-\u0e7f]"),
|
||||
("KA", r"[\u10a0-\u10ff]"),
|
||||
("HY", r"[\u0530-\u058f]"),
|
||||
("AM", r"[\u1200-\u137f]"),
|
||||
]
|
||||
|
||||
|
||||
def _detect_text_language(text: str) -> str:
|
||||
words = re.findall(r"\b[a-zäöüßéèêàçùœáíóúñąćęłńóśźż]+\b", text.lower())
|
||||
raw = str(text or "")
|
||||
# A non-Latin script settles the question on its own.
|
||||
for code, pattern in _SCRIPT_LANGS:
|
||||
if re.search(pattern, raw):
|
||||
return code
|
||||
|
||||
words = re.findall(r"[^\W\d_]+", raw.lower(), re.UNICODE)
|
||||
if not words:
|
||||
return "EN"
|
||||
|
||||
scores = {"EN": 0, "DE": 0, "FR": 0, "ES": 0, "IT": 0, "PT": 0, "NL": 0, "PL": 0}
|
||||
|
||||
scores = {
|
||||
"EN": 0, "DE": 0, "FR": 0, "ES": 0, "IT": 0, "PT": 0, "NL": 0, "PL": 0,
|
||||
"TL": 0, "ID": 0, "TR": 0, "SV": 0, "DA": 0, "NO": 0, "FI": 0,
|
||||
"RO": 0, "CS": 0, "HU": 0, "VI": 0, "CA": 0,
|
||||
}
|
||||
|
||||
if re.search(r"[äöüß]", text.lower()):
|
||||
scores["DE"] += 3
|
||||
if re.search(r"[ąćęłńóśźż]", text.lower()):
|
||||
low = raw.lower()
|
||||
# Diacritics are only strong evidence when they are (near) unique to one
|
||||
# language. ä/ö/ü are NOT: Turkish, Swedish, Finnish, Estonian and Hungarian
|
||||
# all use them, and scoring them +3 for German made "bugün nasılsın" and
|
||||
# "tack så mycket för hjälpen" both come back as German. They now give a
|
||||
# weak nudge, while genuinely distinctive marks keep the strong score.
|
||||
if "ß" in low:
|
||||
scores["DE"] += 3 # unique to German
|
||||
if re.search(r"[äöü]", low):
|
||||
scores["DE"] += 1 # shared with several languages
|
||||
if re.search(r"[ąćęłńśźż]", low):
|
||||
scores["PL"] += 3
|
||||
if re.search(r"[ãõ]", text.lower()):
|
||||
if re.search(r"[ãõ]", low):
|
||||
scores["PT"] += 3
|
||||
if re.search(r"[ñ¿¡]", text.lower()):
|
||||
if re.search(r"[ñ¿¡]", low):
|
||||
scores["ES"] += 3
|
||||
if "å" in low:
|
||||
for c in ("SV", "DA", "NO"):
|
||||
scores[c] += 3
|
||||
if re.search(r"[ışğ]", low) or "ı" in low:
|
||||
scores["TR"] += 3
|
||||
if re.search(r"[őű]", low):
|
||||
scores["HU"] += 3
|
||||
if re.search(r"[řěůč]", low):
|
||||
scores["CS"] += 3
|
||||
if re.search(r"[ăşţșț]", low):
|
||||
scores["RO"] += 3
|
||||
if re.search(r"[ĉĝĥ]", low):
|
||||
pass
|
||||
|
||||
stopwords = {
|
||||
"EN": {"the", "be", "to", "of", "and", "a", "in", "that", "have", "i", "it", "for", "not", "on", "with", "he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say", "her", "she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", "up", "out", "if", "about", "who", "get", "which", "go", "me", "when", "make", "can", "like", "time", "no", "just", "him", "know", "take", "people", "into", "year", "your", "good", "some", "could", "them", "see", "other", "than", "then", "now", "look", "only", "come", "its", "over", "think", "also", "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", "even", "new", "want", "because", "any", "these", "give", "day", "most", "us", "hello", "hi", "yes", "thanks", "please"},
|
||||
@ -207,12 +313,39 @@ def _detect_text_language(text: str) -> str:
|
||||
"FR": {"et", "le", "la", "les", "des", "une", "avec", "pour", "est", "pas", "que", "je", "tu", "il", "elle", "nous", "vous", "ils", "elles", "qui", "quoi", "quand", "où", "pourquoi", "comment", "oui", "non", "merci", "bonjour", "bien", "très", "tout", "plus", "moins", "dans", "sur", "sous", "devant", "derrière", "avant", "après", "ici", "là", "aujourd'hui", "maintenant", "faire", "dire", "aller", "venir", "voir", "savoir", "un", "en", "au", "aux", "ce", "ces", "se", "sa", "son", "ses"},
|
||||
"ES": {"el", "la", "los", "las", "una", "con", "para", "que", "pero", "está", "hola", "y", "o", "no", "sí", "gracias", "por", "favor", "bien", "mal", "muy", "mucho", "poco", "más", "menos", "todo", "nada", "algo", "aquí", "allí", "ahora", "hoy", "mañana", "ayer", "siempre", "nunca", "hacer", "decir", "ir", "venir", "ver", "saber", "poder", "querer", "tener", "ser", "estar", "un", "en", "su", "sus", "te", "me", "se", "nos"},
|
||||
"IT": {"il", "lo", "gli", "una", "con", "per", "che", "ciao", "grazie", "sono", "della", "e", "o", "non", "sì", "prego", "bene", "male", "molto", "poco", "più", "meno", "tutto", "niente", "qualcosa", "qui", "lì", "ora", "oggi", "domani", "ieri", "sempre", "mai", "fare", "dire", "andare", "venire", "vedere", "sapere", "potere", "volere", "avere", "essere", "un", "in", "su", "di", "da", "al", "ai", "mi", "ti", "si", "ci", "vi"},
|
||||
# PT had the same gap as NL/PL — present in the score table, no word
|
||||
# list, so it could only ever win on the ã/õ diacritic.
|
||||
"PT": {"o", "a", "os", "as", "de", "do", "da", "em", "para", "com", "que", "não", "sim", "é", "são", "um", "uma", "mas", "ou", "porque", "eu", "tu", "ele", "ela", "nós", "eles", "obrigado", "obrigada", "olá", "bom", "dia", "boa", "noite", "agora", "hoje", "amanhã", "ontem", "muito", "pouco", "tudo", "nada", "aqui", "ali", "bem", "mal", "pela", "pelo", "ajuda", "fazer", "dizer", "ver", "saber", "poder", "ter", "estar"},
|
||||
# NL and PL were in the score table from the start but never had word
|
||||
# lists — Dutch could only ever win by accident, and "Hallo, hoe gaat
|
||||
# het vandaag met je?" was scored as German off the shared "hallo".
|
||||
"NL": {"de", "het", "een", "en", "van", "is", "op", "dat", "die", "niet", "je", "ik", "jij", "hij", "zij", "wij", "met", "voor", "aan", "zijn", "hebben", "maar", "of", "omdat", "hallo", "dank", "bedankt", "goedemorgen", "ja", "nee", "hoe", "wat", "waar", "wanneer", "waarom", "nu", "vandaag", "morgen", "gisteren", "gaat", "heel", "veel", "graag"},
|
||||
"PL": {"i", "w", "na", "z", "do", "nie", "tak", "jest", "to", "że", "się", "ale", "lub", "bo", "ja", "ty", "on", "ona", "my", "wy", "oni", "dziękuję", "dzień", "dobry", "cześć", "proszę", "teraz", "dzisiaj", "jutro", "wczoraj", "bardzo", "mam", "jak", "co", "gdzie", "kiedy", "dlaczego"},
|
||||
# Filipino/Tagalog — the language of the library's PH voice, and the
|
||||
# reason this list needed widening at all.
|
||||
"TL": {"ang", "ng", "sa", "mga", "ay", "na", "at", "ko", "mo", "niya", "namin", "natin", "nila", "ako", "ikaw", "siya", "kami", "tayo", "kayo", "sila", "ito", "iyan", "iyon", "dito", "diyan", "doon", "hindi", "oo", "salamat", "kumusta", "magandang", "umaga", "gabi", "po", "ba", "kung", "para", "dahil", "pero", "kasi", "lang", "din", "rin", "may", "meron", "wala", "gusto", "ayaw", "pwede", "kailangan", "ngayon", "bukas", "kahapon", "maraming"},
|
||||
"ID": {"yang", "dan", "di", "ke", "dari", "untuk", "dengan", "ini", "itu", "saya", "aku", "kamu", "dia", "kami", "kita", "mereka", "tidak", "ya", "adalah", "ada", "sudah", "belum", "akan", "bisa", "harus", "juga", "atau", "tetapi", "karena", "kalau", "terima", "kasih", "selamat", "pagi", "malam", "sekarang", "besok", "kemarin"},
|
||||
"TR": {"bir", "ve", "bu", "için", "ile", "olarak", "çok", "daha", "var", "yok", "evet", "hayır", "teşekkür", "merhaba", "günaydın", "ben", "sen", "biz", "siz", "onlar", "ama", "veya", "çünkü", "şimdi", "yarın", "dün", "değil", "gibi", "kadar", "sonra", "önce"},
|
||||
"SV": {"och", "att", "det", "som", "en", "på", "är", "av", "för", "med", "till", "den", "har", "inte", "om", "jag", "du", "vi", "ni", "de", "hej", "tack", "ja", "nej", "men", "eller", "här", "där", "nu", "idag", "imorgon"},
|
||||
"DA": {"og", "at", "det", "en", "til", "er", "som", "på", "de", "med", "han", "af", "for", "ikke", "der", "jeg", "du", "vi", "hej", "tak", "ja", "nej", "men", "eller", "her", "nu", "idag", "meget"},
|
||||
"NO": {"og", "i", "jeg", "det", "at", "en", "et", "den", "til", "er", "som", "på", "de", "med", "han", "av", "ikke", "der", "du", "vi", "hei", "takk", "ja", "nei", "men", "eller", "her", "nå"},
|
||||
"FI": {"ja", "on", "ei", "se", "että", "en", "ovat", "kun", "niin", "mutta", "minä", "sinä", "hän", "me", "te", "he", "kiitos", "hei", "kyllä", "tämä", "tuo", "nyt", "huomenna", "eilen", "myös", "vain", "koska"},
|
||||
"RO": {"și", "de", "la", "în", "cu", "pe", "un", "o", "este", "sunt", "nu", "da", "pentru", "care", "ce", "eu", "tu", "el", "ea", "noi", "voi", "ei", "mulțumesc", "bună", "dar", "sau", "acum", "mâine", "ieri"},
|
||||
"CS": {"a", "je", "se", "na", "že", "v", "to", "s", "z", "do", "ne", "ano", "ale", "nebo", "protože", "já", "ty", "on", "ona", "my", "vy", "oni", "děkuji", "dobrý", "den", "teď", "zítra", "včera", "být", "mít"},
|
||||
"HU": {"és", "az", "egy", "hogy", "nem", "igen", "de", "vagy", "mert", "én", "te", "ő", "mi", "ti", "ők", "köszönöm", "szia", "jó", "napot", "most", "holnap", "tegnap", "van", "nincs", "ez", "az", "itt", "ott"},
|
||||
"VI": {"và", "của", "là", "có", "không", "được", "trong", "cho", "với", "này", "đó", "tôi", "bạn", "chúng", "họ", "cảm", "ơn", "chào", "vâng", "nhưng", "hoặc", "vì", "bây", "giờ", "hôm", "nay", "mai"},
|
||||
"CA": {"i", "el", "la", "els", "les", "de", "que", "no", "sí", "amb", "per", "una", "un", "és", "són", "però", "o", "perquè", "jo", "tu", "ell", "ella", "nosaltres", "vosaltres", "ells", "gràcies", "hola", "ara", "demà", "ahir"},
|
||||
}
|
||||
|
||||
# Weight 2 per matched word so a single real vocabulary hit outranks the
|
||||
# weak shared-diacritic nudge above (worth 1). Otherwise "Kiitos paljon
|
||||
# avusta tänään" tied Finnish against German on the ä alone and lost the
|
||||
# tie on dict ordering. Genuinely distinctive diacritics still score 3 and
|
||||
# so still beat one lone word.
|
||||
for word in words:
|
||||
for lang, words_set in stopwords.items():
|
||||
if word in words_set:
|
||||
scores[lang] += 1
|
||||
scores[lang] += 2
|
||||
|
||||
best_lang = max(scores, key=scores.get)
|
||||
if scores[best_lang] == 0:
|
||||
@ -228,9 +361,27 @@ def _route_specificity(rule: dict, app: str, voice: str, lang: str) -> tuple[int
|
||||
r_app = str(rule.get("app", "*"))
|
||||
r_voice = str(rule.get("input_voice", "*"))
|
||||
r_lang = str(rule.get("language", "*")).upper()
|
||||
# PH (the country code used by the library's Filipino voice ids) and TL
|
||||
# (the ISO language code the detector returns) mean the same language;
|
||||
# without this a PH-labelled route could never match detected TL text.
|
||||
def _lang_key(v: str) -> str:
|
||||
return _canonical_lang(v)
|
||||
|
||||
app_ok = r_app == "*" or _app_key(r_app) == _app_key(app)
|
||||
voice_ok = r_voice == "*" or r_voice.lower() == voice.lower()
|
||||
lang_ok = r_lang in {"*", "AUTO"} or r_lang == lang
|
||||
# When the caller's requested voice is already a SPECIFIC value (not the
|
||||
# generic "*"/"default" some apps always send regardless of language),
|
||||
# it already unambiguously identifies which rule applies — cross-checking
|
||||
# it against text-based language auto-detection is redundant, and on
|
||||
# short text (a flashcard app's single vocabulary words routinely score
|
||||
# zero on every language's stopword list) that detector silently
|
||||
# defaults to EN, wrongly failing the match and falling through to a
|
||||
# different rule mid-session even though the caller always explicitly
|
||||
# requested this exact voice (confirmed live). Only rules keyed on a
|
||||
# generic input_voice still need the detected language to disambiguate
|
||||
# which specific voice to route a "default" request to.
|
||||
voice_is_specific = r_voice not in {"*", "default"}
|
||||
lang_ok = voice_is_specific or r_lang in {"*", "AUTO"} or _lang_key(r_lang) == _lang_key(lang)
|
||||
if not (app_ok and voice_ok and lang_ok):
|
||||
return None
|
||||
return (
|
||||
|
||||
@ -3,7 +3,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: tts-voice-creator-clone-and-design-2:latest
|
||||
image: ghcr.io/martin-b78/tts-voice-creator-clone-and-design-2:${APP_TAG:-latest}
|
||||
container_name: tts-voice-creator-clone-and-design-2
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# TTS Voice Creator - Clone and Design 2 — Portainer Stack
|
||||
#
|
||||
# IMPORTANT: build the image on the host before deploying / updating this stack:
|
||||
# The image is published to GitHub Container Registry, so Portainer's
|
||||
# "Pull and redeploy" / "Re-pull image" works normally.
|
||||
#
|
||||
# To publish a new build from the host:
|
||||
#
|
||||
# cd /home/sparky/Docker/tts-voice-creator-clone-and-design-2
|
||||
# docker build -t tts-voice-creator-clone-and-design-2:latest .
|
||||
# docker compose build
|
||||
# docker push ghcr.io/martin-b78/tts-voice-creator-clone-and-design-2:latest
|
||||
#
|
||||
# Then paste this file into Portainer → Stacks → Add stack → Web editor
|
||||
# (or update the existing stack).
|
||||
# Then in Portainer: Stacks -> this stack -> Update the stack -> Re-pull image.
|
||||
#
|
||||
# This reconstructs the current stack from the running container plus the
|
||||
# current app code. The older Portainer cache used 7890:7860, but this version
|
||||
# listens on container port 7890.
|
||||
# Pin a specific release by replacing ":latest" below with a version tag
|
||||
# (e.g. ":1.22.2"); every build is pushed under both tags.
|
||||
#
|
||||
# After any Python code change (core/ or routes/) you must recreate/redeploy
|
||||
# this stack or rebuild the image. Static files are bind-mounted.
|
||||
# Python code (server.py, core/, routes/) and static files are bind-mounted,
|
||||
# so code changes need only a container recreate -- not a rebuild+push.
|
||||
# Rebuild and push only when Dockerfile or requirements.txt change.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
tts-voice-creator-clone-and-design-2:
|
||||
image: tts-voice-creator-clone-and-design-2:latest
|
||||
image: ghcr.io/martin-b78/tts-voice-creator-clone-and-design-2:latest
|
||||
container_name: tts-voice-creator-clone-and-design-2
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
125
routes/admin.py
125
routes/admin.py
@ -1,19 +1,82 @@
|
||||
"""Admin routes: index, favicon, browse-dirs, robots."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from fastapi.responses import FileResponse, PlainTextResponse, Response
|
||||
|
||||
from core.constants import STATIC_DIR, __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_BROWSE_BLOCKED: frozenset[str] = frozenset({
|
||||
"/proc", "/sys", "/dev", "/run", "/boot",
|
||||
})
|
||||
|
||||
# Any folder a Browse dialog leads a user to pick only really "exists" for
|
||||
# them if it survives the container being recreated and, ideally, is visible
|
||||
# from their host machine too — i.e. it's on a bind/volume mount, not the
|
||||
# container's own throwaway overlay filesystem. Read once from /proc/mounts
|
||||
# (containers don't remount at runtime) rather than per-request: confirmed
|
||||
# live as a real gap — a user picked a folder from the picker for the
|
||||
# audiobook-export "Save Location" field with no way to tell it was actually
|
||||
# just container-internal storage that would vanish and never show up on
|
||||
# their host.
|
||||
_PERSISTED_MOUNTS: frozenset[str] | None = None
|
||||
_MOUNT_IGNORE_FSTYPES = frozenset({
|
||||
"overlay", "proc", "sysfs", "cgroup", "cgroup2", "devpts", "mqueue",
|
||||
"tmpfs", "devtmpfs", "securityfs", "debugfs", "tracefs", "pstore",
|
||||
"bpf", "hugetlbfs", "autofs", "nsfs",
|
||||
})
|
||||
|
||||
|
||||
def _persisted_mounts() -> frozenset[str]:
|
||||
global _PERSISTED_MOUNTS
|
||||
if _PERSISTED_MOUNTS is not None:
|
||||
return _PERSISTED_MOUNTS
|
||||
mounts: set[str] = set()
|
||||
try:
|
||||
with open("/proc/mounts", "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
mnt, fstype = parts[1], parts[2]
|
||||
if fstype in _MOUNT_IGNORE_FSTYPES or mnt == "/":
|
||||
continue
|
||||
if any(mnt == b or mnt.startswith(b + "/") for b in _BROWSE_BLOCKED):
|
||||
continue
|
||||
# Individual files are sometimes bind-mounted too (this app's
|
||||
# own dev setup does this for /etc/resolv.conf etc.) — those
|
||||
# can never be "picked" as a folder and would otherwise mark
|
||||
# their parent directory as a reachable waypoint that then
|
||||
# dead-ends (confirmed live: /etc showed up pinned for this
|
||||
# reason alone, with nothing browsable underneath it).
|
||||
if not os.path.isdir(mnt):
|
||||
continue
|
||||
mounts.add(mnt)
|
||||
except OSError as exc:
|
||||
logger.warning("Could not read /proc/mounts for the folder browser: %s", exc)
|
||||
_PERSISTED_MOUNTS = frozenset(mounts)
|
||||
return _PERSISTED_MOUNTS
|
||||
|
||||
|
||||
def _mount_relation(path_str: str, mounts: frozenset[str]) -> tuple[bool, bool]:
|
||||
"""Returns (is_persisted, leads_to_persisted) for `path_str`.
|
||||
is_persisted: the path itself is on (at or below) a real mount — safe to
|
||||
actually save into. leads_to_persisted: an ancestor of some mount — not
|
||||
itself persisted, but worth navigating into (e.g. /home on the way to
|
||||
/home/app/.config/tts-voice-creator, which sits inside otherwise-ordinary
|
||||
overlay directories)."""
|
||||
is_persisted = any(path_str == m or path_str.startswith(m + "/") for m in mounts)
|
||||
leads_to = is_persisted or any(m == path_str or m.startswith(path_str + "/") for m in mounts)
|
||||
return is_persisted, leads_to
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def index():
|
||||
@ -52,14 +115,64 @@ async def browse_dirs(path: str = "/"):
|
||||
if not p.is_dir():
|
||||
raise HTTPException(404, "Not a directory")
|
||||
try:
|
||||
entries = sorted(
|
||||
[d.name for d in p.iterdir() if d.is_dir() and not d.name.startswith(".")],
|
||||
key=str.lower,
|
||||
)
|
||||
all_dirs = sorted([d.name for d in p.iterdir() if d.is_dir()], key=str.lower)
|
||||
entries = [n for n in all_dirs if not n.startswith(".")]
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Permission denied")
|
||||
parent = str(p.parent) if p.parent != p else None
|
||||
return {"path": str(p), "parent": parent, "dirs": entries}
|
||||
mounts = _persisted_mounts()
|
||||
is_persisted, _ = _mount_relation(p_str, mounts)
|
||||
# Additive fields only — existing callers (e.g. voice-library.js's own
|
||||
# folder picker) read `dirs` as plain strings and are unaffected.
|
||||
# Computed over ALL subdirectories, dotdirs included: the app's own
|
||||
# default export location (~/.config/tts-voice-creator) sits behind a
|
||||
# dotdir, so excluding those here would make it unreachable through
|
||||
# mounted-only navigation even though it's the single most useful target
|
||||
# (confirmed live) — `dirs` above still hides dotfiles for every other
|
||||
# (unrestricted) use of this endpoint, unaffected.
|
||||
reachable_dirs = []
|
||||
persisted_dirs = []
|
||||
for name in all_dirs:
|
||||
child = p_str.rstrip("/") + "/" + name if p_str != "/" else "/" + name
|
||||
child_persisted, child_leads_to = _mount_relation(child, mounts)
|
||||
if child_leads_to:
|
||||
reachable_dirs.append(name)
|
||||
if child_persisted:
|
||||
persisted_dirs.append(name)
|
||||
return {
|
||||
"path": p_str, "parent": parent, "dirs": entries,
|
||||
"is_persisted": is_persisted,
|
||||
"reachable_dirs": reachable_dirs,
|
||||
"persisted_dirs": persisted_dirs,
|
||||
}
|
||||
|
||||
|
||||
_MKDIR_NAME_RE = re.compile(r"^[^/\\\x00]{1,100}$")
|
||||
|
||||
|
||||
@router.post("/api/browse-dirs/mkdir")
|
||||
async def browse_dirs_mkdir(payload: dict = Body(...)):
|
||||
"""Create a subfolder from inside the folder browser modal — without
|
||||
this, picking a destination that doesn't exist yet (e.g. a new export
|
||||
folder) meant leaving the app to create it first (confirmed live)."""
|
||||
path = str(payload.get("path") or "")
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name or name in (".", "..") or not _MKDIR_NAME_RE.match(name):
|
||||
raise HTTPException(400, "Invalid folder name")
|
||||
p = Path(path).resolve()
|
||||
p_str = str(p)
|
||||
if any(p_str == b or p_str.startswith(b + "/") for b in _BROWSE_BLOCKED):
|
||||
raise HTTPException(403, "Access to this path is not permitted")
|
||||
if not p.is_dir():
|
||||
raise HTTPException(404, "Not a directory")
|
||||
new_dir = p / name
|
||||
if new_dir.exists() and not new_dir.is_dir():
|
||||
raise HTTPException(409, "A file with that name already exists here")
|
||||
try:
|
||||
new_dir.mkdir(exist_ok=True)
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Permission denied")
|
||||
return {"ok": True, "path": str(new_dir)}
|
||||
|
||||
|
||||
@router.get("/api/version")
|
||||
|
||||
@ -30,10 +30,21 @@ async def characters_list():
|
||||
# payload/DOM balloon to tens of megabytes for a book with dozens of
|
||||
# portraits, blocking rendering with no visual feedback. Single-record
|
||||
# fetches (characters_get below) still return the real base64.
|
||||
#
|
||||
# sheet.concept_art_image (the reference-sheet image) needed the exact
|
||||
# same treatment and never got it: after the "Consistent profile +
|
||||
# reference" feature started populating it for a whole cast in one run,
|
||||
# this endpoint's payload measured 190MB across the library (mostly this
|
||||
# one field) — confirmed live as the cause of every character-list
|
||||
# action (including just clicking a table column to sort) appearing to
|
||||
# hang for minutes while the browser fetched and parsed it.
|
||||
chars = char_get_all()
|
||||
for c in chars:
|
||||
if c.get("image"):
|
||||
c["image"] = f"/api/characters/{c['id']}/image"
|
||||
sheet = c.get("sheet")
|
||||
if isinstance(sheet, dict) and sheet.get("concept_art_image"):
|
||||
c["sheet"] = {**sheet, "concept_art_image": f"/api/characters/{c['id']}/concept-art-image"}
|
||||
return {"characters": chars}
|
||||
|
||||
|
||||
@ -64,6 +75,28 @@ async def characters_image(char_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/characters/{char_id:path}/concept-art-image")
|
||||
async def characters_concept_art_image(char_id: str):
|
||||
"""Same lightweight-URL treatment as /image, for the reference sheet."""
|
||||
rec = char_get(char_id)
|
||||
sheet = (rec or {}).get("sheet") or {}
|
||||
concept_art_image = sheet.get("concept_art_image")
|
||||
if rec is None or not concept_art_image:
|
||||
raise HTTPException(404, "No concept art image")
|
||||
m = _DATA_URL_RE.match(concept_art_image)
|
||||
if not m:
|
||||
raise HTTPException(404, "Invalid image data")
|
||||
try:
|
||||
raw = base64.b64decode(m.group(2))
|
||||
except Exception:
|
||||
raise HTTPException(404, "Invalid image data")
|
||||
return Response(
|
||||
content=raw,
|
||||
media_type=m.group(1),
|
||||
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/characters/{char_id:path}")
|
||||
async def characters_get(char_id: str):
|
||||
rec = char_get(char_id)
|
||||
|
||||
@ -53,6 +53,35 @@ router = APIRouter()
|
||||
# scenario this lock exists to prevent.
|
||||
_attribution_llm_lock = threading.Lock()
|
||||
|
||||
# Tracks in-progress "consistent profile + reference" image generations so the
|
||||
# UI can poll for live status instead of staring at a blocking fetch for up to
|
||||
# ~20 minutes with zero feedback — that silence is what made a legitimately
|
||||
# slow-but-working generation look identical to a hung one. Keyed by job id;
|
||||
# entries are pruned an hour after completion so this can't grow unbounded.
|
||||
_consistent_image_jobs: dict[str, dict] = {}
|
||||
_consistent_image_jobs_lock = threading.Lock()
|
||||
|
||||
|
||||
def _cij_new(job_id: str, name: str) -> None:
|
||||
with _consistent_image_jobs_lock:
|
||||
now = time.time()
|
||||
_consistent_image_jobs[job_id] = {
|
||||
"name": name, "stage": "queued", "detail": "Queued…",
|
||||
"started": now, "updated": now, "done": False, "error": None, "result": None,
|
||||
}
|
||||
cutoff = now - 3600
|
||||
for stale_id in [j for j, v in _consistent_image_jobs.items() if v.get("done") and v.get("updated", 0) < cutoff]:
|
||||
_consistent_image_jobs.pop(stale_id, None)
|
||||
|
||||
|
||||
def _cij_set(job_id: str, **fields) -> None:
|
||||
with _consistent_image_jobs_lock:
|
||||
job = _consistent_image_jobs.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.update(fields)
|
||||
job["updated"] = time.time()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _watchdog_close(resp, timeout_seconds):
|
||||
@ -1282,8 +1311,17 @@ def _primary_visual_role(sheet: dict) -> str:
|
||||
return min(supported, key=len) if supported else roles[0]
|
||||
|
||||
|
||||
_TRANSIENT_INJURY_RE = re.compile(
|
||||
r"\b(blut\w*|wunden?|verletz\w*|geschwollen|entstellt|blaue\s+flecken|pr[eä]llung\w*|"
|
||||
r"schnittwunde\w*|messerschnitt\w*|riss(?:e|en)?|durchbohrt\w*|zerschunden|erschöpfung|"
|
||||
r"blood\w*|wound\w*|bruis\w*|injur\w*|bleeding|swollen|disfigured|gash\w*|laceration\w*|"
|
||||
r"exhaust(?:ed|ion))\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _established_visual_value(sheet: dict, override_key: str, source_key: str,
|
||||
limit: int = 320) -> str:
|
||||
limit: int = 320, strip_injury: bool = False) -> str:
|
||||
"""Return visual facts that are established rather than speculative prose.
|
||||
|
||||
Character extraction is intentionally generous and sometimes records its
|
||||
@ -1291,6 +1329,17 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str,
|
||||
Those guesses are useful in the profile, but they must not become costume
|
||||
facts. A user-supplied visual override is authoritative; otherwise only
|
||||
non-speculative sentences survive.
|
||||
|
||||
strip_injury additionally drops sentences describing an acute wound,
|
||||
bleeding, bruising, or exhaustion — extraction often pulls a character's
|
||||
"physical" description from whatever passage happened to describe them
|
||||
most vividly, frequently a violent scene late in the book, baking a
|
||||
one-time injury into what should be an evergreen baseline appearance.
|
||||
Confirmed live: a profile portrait meant to be a neutral productio
|
||||
reference instead showed a bloody facial wound and a chest blood stain,
|
||||
spoiling a plot beat that hadn't happened yet when read at that point in
|
||||
the book. A permanent mark (a scar, a missing finger) uses different
|
||||
words ("Narbe"/scar implies healed) and is unaffected by this filter.
|
||||
"""
|
||||
override = _visual_prompt_value(sheet.get(override_key)).strip()
|
||||
if override:
|
||||
@ -1307,7 +1356,11 @@ def _established_visual_value(sheet: dict, override_key: str, source_key: str,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
sentences = re.split(r"(?<=[.!?])\s+", raw)
|
||||
grounded = [sentence.strip() for sentence in sentences if sentence.strip() and not uncertain.search(sentence)]
|
||||
grounded = [
|
||||
sentence.strip() for sentence in sentences
|
||||
if sentence.strip() and not uncertain.search(sentence)
|
||||
and not (strip_injury and _TRANSIENT_INJURY_RE.search(sentence))
|
||||
]
|
||||
value = " ".join(grounded).strip()
|
||||
if len(value) > limit:
|
||||
value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
|
||||
@ -1323,7 +1376,7 @@ def _character_visual_facts(name: str, sheet: dict, include_items: bool = True)
|
||||
return value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
|
||||
|
||||
species = _established_visual_value(sheet, "visual_species", "race_species", 100)
|
||||
appearance = _established_visual_value(sheet, "visual_appearance", "physical", 320)
|
||||
appearance = _established_visual_value(sheet, "visual_appearance", "physical", 320, strip_injury=True)
|
||||
costume = _established_visual_value(sheet, "visual_costume", "clothing", 320)
|
||||
rows = [
|
||||
("name", name),
|
||||
@ -1357,6 +1410,197 @@ def _book_visual_context(book: str, book_profile: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
_ELF_SPECIES_RE = re.compile(r"\b(elf|elfe|elben|elbisch|half-elf|halbelfe)\b", re.IGNORECASE)
|
||||
_ORC_SPECIES_RE = re.compile(r"\b(ork|orc|orken|orkisch)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _character_ear_shape(sheet: dict) -> str:
|
||||
"""Positive-only ear-shape phrase, gated on the character's own species.
|
||||
|
||||
Nothing previously told the model an ear's SHAPE — every bareheaded
|
||||
render just said to keep "ears" visible, with no species qualifier, and
|
||||
the model's fantasy-genre default is pointed elf ears regardless of
|
||||
species. Confirmed live: characters explicitly marked "Mensch" (human)
|
||||
in their own sheet still rendered with pointed ears. Elf/half-elf and
|
||||
orc characters keep the pointed phrasing; everyone else — including
|
||||
dwarves, vampires-in-human-form, and anyone with no species set at all —
|
||||
gets ordinary rounded human ears, since a missing/uncertain species is
|
||||
far more likely human than elf in this cast.
|
||||
"""
|
||||
species_text = _visual_prompt_value(sheet.get("visual_species") or sheet.get("race_species"))
|
||||
if _ELF_SPECIES_RE.search(species_text) or _ORC_SPECIES_RE.search(species_text):
|
||||
return "pointed elf-shaped ears"
|
||||
return "ordinary rounded human ears"
|
||||
|
||||
|
||||
_LEATHER_ONLY_ARMOR_RE = re.compile(r"\b(leder(?:r[üu]stung|panzer)?|leather)\b", re.IGNORECASE)
|
||||
# Broad on purpose: this only has to rule OUT the leather-only claim, so a
|
||||
# false positive here (calling something "metal" that technically isn't)
|
||||
# just falls back to the old, already-shipped blanket wording — safe. A
|
||||
# false NEGATIVE (missing a real metal-armor mention) is what actually
|
||||
# breaks a character, as it did for Lysandra: her established armor is a
|
||||
# bronze muscle cuirass with polished bronze greaves, but the original list
|
||||
# only covered mail/plate/steel by name, and a stray "she used to wear plain
|
||||
# leather clothing" backstory clause made her match leather-only instead —
|
||||
# confirmed live while picking validation characters, before it ever reached
|
||||
# a real image generation.
|
||||
_METAL_ARMOR_RE = re.compile(
|
||||
r"\b(kettenhemd|kettenr[üu]stung|plattenr[üu]stung|schuppenpanzer|harnisch|"
|
||||
r"stahlr[üu]stung|eisenr[üu]stung|bronzer[üu]stung|kupferr[üu]stung|"
|
||||
r"metallr[üu]stung|erzr[üu]stung|"
|
||||
r"(?:stahl|eisen|bronze|kupfer|erz|silber|gold|metall)[- ]?panzer|"
|
||||
r"muskelpanzer|brustpanzer|beinschiene|armschiene|schienenpanzer|"
|
||||
r"chain\s*mail|chainmail|plate\s*armor|plate-armou?r|steel\s*armor|steel-armou?r|"
|
||||
r"scale\s*mail|bronze\s*(?:armor|cuirass|breastplate|greaves)|"
|
||||
r"metal\s*(?:armor|armour|cuirass|breastplate|greaves|plate)|greaves|vambraces|pauldrons?|cuirass)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Backstory ("she used to wear leather before joining the guard") is not the
|
||||
# CURRENT established costume — strip clauses introduced by a past-reference
|
||||
# word before checking, the same way headwear detection already treats
|
||||
# inventory as non-authoritative for what's actually worn now.
|
||||
_PAST_REFERENCE_CLAUSE_RE = re.compile(
|
||||
r"\b(?:zuvor|früher|einst|vorher|damals|ehemals|formerly|previously|used to wear)\b[^.;]*[.;]?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _character_is_leather_only_armor(sheet: dict) -> bool:
|
||||
"""True when the character's OWN established costume names leather armor
|
||||
and never mentions any metal-armor term.
|
||||
|
||||
The blanket book-era material sentence ("wool, linen, leather, forged
|
||||
metal, period armor") used to apply identically to every character
|
||||
regardless of what they actually wear, so a leather-armor character still
|
||||
rendered in metal plate/chainmail — the blanket wording mentioned "forged
|
||||
metal"/"period armor" more prominently than one German "Lederrüstung"
|
||||
mention — confirmed live on Karyla. Same authority rule as headwear: the
|
||||
established appearance/costume text wins over generic period vocabulary.
|
||||
|
||||
Callers must phrase what they do with this as a POSITIVE statement, not a
|
||||
"do not add X" sentence — Qwen's positive text encoder still visualizes
|
||||
a noun mentioned after a negator (see _positive_conditioning_text), so a
|
||||
negative instruction here would just add another mention of metal armor.
|
||||
"""
|
||||
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
|
||||
established = " ".join(filter(None, (
|
||||
_visual_prompt_value(sheet.get("visual_appearance") or sheet.get("physical")),
|
||||
costume or _visual_prompt_value(sheet.get("clothing")),
|
||||
)))
|
||||
established = _PAST_REFERENCE_CLAUSE_RE.sub("", established)
|
||||
return bool(_LEATHER_ONLY_ARMOR_RE.search(established) and not _METAL_ARMOR_RE.search(established))
|
||||
|
||||
|
||||
_COMBAT_GEAR_RE = re.compile(
|
||||
r"\b(r[üu]stung|panzer|harnisch|kettenhemd|schild|schwert|dolch|messer|speer|lanze|"
|
||||
r"armbrust|bogen|axt|streitaxt|keule|hellebarde|"
|
||||
r"armor|armour|shield|sword|dagger|blade|spear|lance|crossbow|bow|axe|mace|halberd)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _character_has_established_combat_gear(sheet: dict) -> bool:
|
||||
"""True only when the character's OWN costume/inventory actually names
|
||||
armor or a weapon — a job title alone ("Unterführerin", "General",
|
||||
"Kommandant") does not.
|
||||
|
||||
authority_lock (below) used to apply to any character whose title/role
|
||||
just READ as military rank, which meant a civilian militia deputy whose
|
||||
established costume is explicitly "practical clothes for housework and
|
||||
smithing" — no armor at all — still rendered in full plate with a spear,
|
||||
because the rank word alone triggered the lock. Confirmed live on Misira.
|
||||
"""
|
||||
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
|
||||
established = " ".join(filter(None, (
|
||||
costume or _visual_prompt_value(sheet.get("clothing")),
|
||||
", ".join(_character_prop_item_list(sheet)),
|
||||
)))
|
||||
return bool(_COMBAT_GEAR_RE.search(established))
|
||||
|
||||
|
||||
_EXPLICIT_AGE_RE = re.compile(
|
||||
r"\b(\d{1,2})[\s-]*(?:jahre|jährig|j[äa]hrige[rns]?|years?[\s-]*old|y\.?o\.?)\b", re.IGNORECASE,
|
||||
)
|
||||
_CHILD_KEYWORD_RE = re.compile(
|
||||
r"\b(kind|kleinkind|kindlich|s[äa]ugling|kleinkindalter|"
|
||||
r"child|infant|toddler|little\s+(?:boy|girl|kid))\b", re.IGNORECASE,
|
||||
)
|
||||
_TEEN_KEYWORD_RE = re.compile(
|
||||
r"\b(jugendlich(?:e|er|es)?|teenager|halbw[üu]chsig(?:e|er|es)?|heranwachsend(?:e|er|es)?|"
|
||||
r"teen(?:ager)?|adolescent)\b", re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _character_age_category(sheet: dict) -> str:
|
||||
"""Return 'child', 'teen', or 'adult' (the default) from whatever age
|
||||
signal the sheet actually has.
|
||||
|
||||
Every render template previously hard-coded "EXACTLY ONE ADULT PERSON"
|
||||
for every character with zero exceptions, so a genuinely child or
|
||||
teenage cast member was always rendered as a full adult — confirmed as a
|
||||
real gap, not just an edge case, once asked about directly. An explicit
|
||||
numeric age wins when present; otherwise this falls back to age-bearing
|
||||
words anywhere in the established profile text.
|
||||
"""
|
||||
text = " ".join(filter(None, (
|
||||
_visual_prompt_value(sheet.get("age")),
|
||||
_visual_prompt_value(sheet.get("physical")),
|
||||
_visual_prompt_value(sheet.get("archetype")),
|
||||
_visual_prompt_value(sheet.get("profession")),
|
||||
_visual_prompt_value(sheet.get("title")),
|
||||
_visual_prompt_value(sheet.get("aliases")),
|
||||
)))
|
||||
age_match = _EXPLICIT_AGE_RE.search(text)
|
||||
if age_match:
|
||||
age = int(age_match.group(1))
|
||||
if age < 13:
|
||||
return "child"
|
||||
if age < 18:
|
||||
return "teen"
|
||||
return "adult"
|
||||
if _CHILD_KEYWORD_RE.search(text):
|
||||
return "child"
|
||||
if _TEEN_KEYWORD_RE.search(text):
|
||||
return "teen"
|
||||
return "adult"
|
||||
|
||||
|
||||
# (subject clause, "identical <noun>" noun, proportion clause) per age
|
||||
# category — all phrased positively (no "not an adult"/"no adult" wording),
|
||||
# since this encoder still visualizes a noun mentioned after a negator (see
|
||||
# _character_is_leather_only_armor's docstring for the same reasoning).
|
||||
_AGE_BODY_LOCK = {
|
||||
"child": (
|
||||
"EXACTLY ONE CHILD.",
|
||||
"child",
|
||||
"Natural child body proportions: roughly five head-heights tall, a proportionally larger head, "
|
||||
"shorter limbs, and a soft, undeveloped child physique. ",
|
||||
),
|
||||
"teen": (
|
||||
"EXACTLY ONE TEENAGE PERSON.",
|
||||
"teenage person",
|
||||
"Natural teenage body proportions: roughly six to six and a half head-heights tall, and a slighter, "
|
||||
"still-developing build. ",
|
||||
),
|
||||
"adult": (
|
||||
"EXACTLY ONE ADULT PERSON.",
|
||||
"adult person",
|
||||
# "seven and a half head-heights tall" alone still rendered stocky,
|
||||
# dwarf-like proportions in practice — confirmed live. Figure-drawing
|
||||
# convention gets a tall, slender adult look by also specifying leg
|
||||
# length as a fraction of total height and a slim waist, not just an
|
||||
# overall height ratio, so this states that explicitly.
|
||||
"Natural adult human body proportions: an average adult stands about seven and a half head-heights "
|
||||
"tall, with long legs making up slightly more than half of total height, a slender waist, and a "
|
||||
"correctly proportioned torso and arms. ",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _age_body_lock(sheet: dict) -> tuple[str, str, str]:
|
||||
return _AGE_BODY_LOCK[_character_age_category(sheet)]
|
||||
|
||||
|
||||
def _character_has_established_headwear(sheet: dict) -> bool:
|
||||
# Inventory is deliberately excluded. A helmet/hood listed as a prop or
|
||||
# recovered object does not mean the character wears it. Treat only the
|
||||
@ -1385,11 +1629,22 @@ def _period_lock(book: str, book_profile: dict, sheet: dict) -> str:
|
||||
if _character_has_established_headwear(sheet) else
|
||||
"The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed."
|
||||
)
|
||||
leather_only = _character_is_leather_only_armor(sheet)
|
||||
materials = (
|
||||
"wool, linen, leather, hand stitching, tools, weapons, fastenings, and footwear"
|
||||
if leather_only else
|
||||
"wool, linen, leather, forged metal, hand stitching, period armor, tools, weapons, fastenings, and footwear"
|
||||
)
|
||||
armor_note = (
|
||||
"This character's armor and everyday wear are leather and cloth, with small forged-metal buckles and fastenings only. "
|
||||
if leather_only else ""
|
||||
)
|
||||
return (
|
||||
f"Book lock: {_book_visual_context(book, book_profile)}. "
|
||||
"Use museum-authentic Aventurian early-Renaissance c.1420-1500 wool, linen, leather, forged metal, hand stitching, period armor, tools, weapons, fastenings, and footwear. "
|
||||
f"Use museum-authentic Aventurian early-Renaissance c.1420-1500 {materials}. "
|
||||
"Garments use ties, lacing, toggles, hand-sewn buttons, hooks, or simple forged buckles; footwear uses stitched leather soles and period construction. "
|
||||
"Every visible design element is pre-industrial and period-authentic, with natural materials, hand-worked construction, flat stitched-leather soles, and age-appropriate wear. "
|
||||
f"{armor_note}"
|
||||
f"{headwear}"
|
||||
)
|
||||
|
||||
@ -1411,7 +1666,20 @@ def _character_prop_item_list(sheet: dict) -> list[str]:
|
||||
r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|"
|
||||
r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|"
|
||||
r"hat|headwear|hood|cap|helmet|veil|cloak|coat|shirt|robe|armor set|armour set|"
|
||||
r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|kleidung|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer)\b",
|
||||
r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer|"
|
||||
# Vague plural/collective nouns describe a CATEGORY, not one
|
||||
# renderable object ("household items", "belongings", "gear") — the
|
||||
# existing philosophy here is that an empty slot beats a wrong
|
||||
# render, and these have no single concrete shape to lock onto.
|
||||
r"items?|belongings|gear|equipment|supplies|sachen|habseligkeiten|utensilien)\b|"
|
||||
# These trail onto a compound prefix with no word boundary in
|
||||
# between ("Kinderkleidung", "Haushaltsgegenstände") — a leading \b
|
||||
# silently let those slip past this exclusion entirely, which is
|
||||
# exactly why "children's clothing" and "household items" both
|
||||
# rendered as a small person instead of being dropped as a
|
||||
# non-object category. Suffix-only match (no leading \b) catches
|
||||
# the compound form too.
|
||||
r"kleidung\b|gegenst[äa]nde\b|ausr[üu]stung\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
uncertain = re.compile(r"\b(implied|inferred|possible|impliziert|vermutlich|wahrscheinlich)\b", re.IGNORECASE)
|
||||
@ -1473,11 +1741,26 @@ def _prop_render_description(item: str) -> str:
|
||||
(r"\b(rolle|schriftrolle|scroll|document|map|karte)\b", "one complete rolled parchment document tied with a plain cord, its surface blank and unlettered"),
|
||||
(r"\b(phiole|vial|fläschchen|bottle)\b", "one small complete period glass vial with a plain fitted stopper"),
|
||||
(r"\b(laterne|lantern|lamp)\b", "one complete early-Renaissance hand lantern of forged iron and translucent horn panels"),
|
||||
# Worn/held items are the ones most likely to pull a whole person into
|
||||
# frame — an amulet or crown alone is a weak object cue, but strongly
|
||||
# associated with someone wearing it. Naming the object as a
|
||||
# tabletop still-life object, resting on a surface rather than worn,
|
||||
# is a positive framing (never "no person"/"no body") that keeps the
|
||||
# same reasoning as the rest of this file's locks.
|
||||
(r"\b(amulett|amulet|anh[äa]nger|kette|halskette|pendant|necklace)\b", "one small pendant on its own plain cord or chain, coiled flat on the sweep as a tabletop still-life object"),
|
||||
(r"\b(feder|federn|quill|feather)\b", "one single bird feather lying flat on the sweep as a tabletop still-life object"),
|
||||
(r"\b(buch|tome|book)\b", "one closed leather-bound book resting flat and closed on the sweep as a tabletop still-life object"),
|
||||
(r"\b(decke|blanket|cloak|umhang|mantel)\b", "one folded woven blanket or cloak, laid flat and folded on the sweep as a tabletop still-life object"),
|
||||
(r"\b(krone|circlet|diadem|crown)\b", "one crown or circlet alone, resting on a plain stand or cushion as a tabletop still-life object"),
|
||||
(r"\bring\b", "one single metal ring resting on the sweep as a tabletop still-life object"),
|
||||
(r"\b(weinbecher|becher|goblet|chalice|cup)\b", "one single empty metal or wooden drinking goblet standing upright on the sweep as a tabletop still-life object"),
|
||||
(r"\b(kr[äa]uter|herb|herbs)\b", "one small bundle of dried herbs tied with cord, resting on the sweep as a tabletop still-life object"),
|
||||
(r"\b(gew[äa]nder|robe|robes|garment|garments|kleidung|clothing|gown)\b", "one folded garment, laid flat and folded on the sweep as an empty textile still-life object"),
|
||||
)
|
||||
for pattern, description in rules:
|
||||
if re.search(pattern, lowered, re.IGNORECASE):
|
||||
return f"{value} — {description}"
|
||||
return f"{value} — one complete portable object matching this exact noun, silhouette, and function"
|
||||
return f"{value} — one complete portable object matching this exact noun, silhouette, and function, resting on the sweep as a tabletop still-life object"
|
||||
|
||||
|
||||
def _character_scene_evidence(sheet: dict) -> str:
|
||||
@ -1655,20 +1938,109 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
has_headwear = _character_has_established_headwear(sheet)
|
||||
portrait_subject = _positive_conditioning_text(portrait_subject)
|
||||
sheet_subject = _positive_conditioning_text(sheet_subject)
|
||||
ear_shape = _character_ear_shape(sheet)
|
||||
no_headwear = (
|
||||
"Use only the explicitly established period headwear."
|
||||
if has_headwear else
|
||||
"The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed."
|
||||
f"The scalp, crown, hairline, {ear_shape}, and complete natural hairstyle are visibly exposed and unobstructed."
|
||||
)
|
||||
bareheaded = not has_headwear
|
||||
head_lock = (
|
||||
"HEAD CONTINUITY: In all three figures, clearly show the exposed scalp, crown, hairline, ears, and complete natural hairstyle in front, profile, and rear views. "
|
||||
f"HEAD CONTINUITY: In all three figures, clearly show the exposed scalp, crown, hairline, {ear_shape}, and complete natural hairstyle in front, profile, and rear views. "
|
||||
if bareheaded else
|
||||
"HEADWEAR LOCK: reproduce only the explicitly established period headwear, identically in all three views. "
|
||||
)
|
||||
leather_only = _character_is_leather_only_armor(sheet)
|
||||
period_materials = (
|
||||
"wool, linen, leather, hand-worked fastenings, and flat stitched-leather footwear"
|
||||
if leather_only else
|
||||
"wool, linen, leather, hand-worked fastenings, restrained forged-metal details, and flat stitched-leather footwear"
|
||||
)
|
||||
period_armor_note = (
|
||||
"This character's armor and clothing are leather and cloth, with small forged-metal buckles and fastenings only. "
|
||||
if leather_only else ""
|
||||
)
|
||||
# _positive_conditioning_text strips the book era's "no modern or
|
||||
# 20th-century clothing" clause outright (it starts with "no", and
|
||||
# negated nouns still get visualized by this encoder — see that
|
||||
# function's docstring), so that ban never actually reached the model.
|
||||
# Confirmed live: any character whose title/role reads as military rank
|
||||
# ("General", "Kommandant", "Offizier") rendered in modern uniforms, and
|
||||
# some carried a gun/rifle instead of a period weapon. A negative
|
||||
# replacement ("no rifle", "not a uniform") would be actively
|
||||
# counterproductive for the same reason the ban itself was silently
|
||||
# dropped — it would just add the first mention of "rifle" to the
|
||||
# prompt. This states the period equivalents positively instead, never
|
||||
# naming a modern item at all.
|
||||
# Gated: this used to fire for ANY military-sounding title/role text,
|
||||
# which meant a civilian militia deputy whose own established costume is
|
||||
# "practical clothes for housework and smithing" — no armor at all —
|
||||
# still rendered in full plate with a spear, because a rank WORD alone
|
||||
# triggered it regardless of what the character is actually established
|
||||
# to wear. Confirmed live on Misira. Only fires when armor or a weapon is
|
||||
# actually established.
|
||||
authority_lock = (
|
||||
(
|
||||
"Any military rank, command, or authority in this world is shown only through hand-forged period arms and "
|
||||
"dress: quilted wool gambesons, riveted leather brigandine, forged plate or mail, sword belts, spears, "
|
||||
"poleaxes, and crossbows, tailored by a period armorer or tailor. "
|
||||
)
|
||||
if _character_has_established_combat_gear(sheet) else ""
|
||||
)
|
||||
# A single short "established worn costume" clause (many characters have
|
||||
# only a few established words, e.g. just "sleepwear") competes with a
|
||||
# strong base-model bias toward generic fantasy-adventurer armor and a
|
||||
# sword — confirmed live: a character established in nothing but plain
|
||||
# sleepwear and a blanket still rendered in full leather armor with
|
||||
# pauldrons and a sword. Restating the costume as the COMPLETE outfit
|
||||
# (not "no other clothing" — same reasoning as authority_lock above)
|
||||
# gives that one clause more weight without naming anything to avoid.
|
||||
# Referring back to "the costume above" abstractly wasn't concrete
|
||||
# enough competition for the model's own armor-heavy fantasy bias — for
|
||||
# a civilian with no armor established, repeating the actual clothing
|
||||
# words here (not just pointing at them) gives that description a
|
||||
# second, closer mention instead of relying on the Subject clause alone.
|
||||
established_costume = _established_visual_value(sheet, "visual_costume", "clothing", 320)
|
||||
clothing_lock = (
|
||||
f"CLOTHING LOCK: this character's complete outfit in every view is exactly {established_costume} — "
|
||||
"its own established materials, and nothing more elaborate. "
|
||||
if established_costume else
|
||||
"CLOTHING LOCK: the established worn costume above is this character's complete outfit in every view — "
|
||||
"exactly that clothing, its own established materials, and nothing more elaborate. "
|
||||
)
|
||||
# A character's "physical" field is often written from ONE specific scene
|
||||
# (frequently a late, dramatic one) rather than a neutral baseline — a
|
||||
# profile portrait meant as a evergreen production reference then shows
|
||||
# injuries, blood, or a distressed expression from a moment that hasn't
|
||||
# even happened yet in the book, spoiling it before the reader gets
|
||||
# there. Naming what to avoid ("no blood", "no wounds") would just
|
||||
# visualize those words per the same encoder behavior documented above,
|
||||
# so this states only the desired neutral baseline positively; any
|
||||
# permanently established mark (an old scar, tattoo, missing finger) is
|
||||
# still covered by "appearance" above and isn't contradicted by this.
|
||||
baseline_lock = (
|
||||
"PORTRAIT BASELINE: a calm, composed, healthy resting expression and unmarked skin, showing the "
|
||||
"character's everyday default appearance — not any single dramatic moment from the story. "
|
||||
)
|
||||
# baseline_lock covers the character's own skin/expression; the model's
|
||||
# "gritty realistic fantasy" style bias separately adds blood staining to
|
||||
# ARMOR on its own, even once the injury text feeding it is gone —
|
||||
# confirmed live: Baronin Ira's stored appearance text no longer mentions
|
||||
# any wound (the strip_injury filter above removed it), yet the render
|
||||
# still showed a bloodstain across her chest armor. Same positive-only
|
||||
# approach, aimed at the armor's surface condition instead of the skin.
|
||||
gear_condition_lock = (
|
||||
"Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from "
|
||||
"daily use. "
|
||||
)
|
||||
period = (
|
||||
f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. Aventurian early Renaissance c.1420-1500 only: "
|
||||
"wool, linen, leather, hand-worked fastenings, restrained forged-metal details, and flat stitched-leather footwear. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. "
|
||||
f"{period_materials}. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. "
|
||||
f"{period_armor_note}"
|
||||
f"{authority_lock}"
|
||||
f"{baseline_lock}"
|
||||
f"{gear_condition_lock}"
|
||||
f"{clothing_lock}"
|
||||
f"{no_headwear}"
|
||||
)
|
||||
if visual_style == "concept":
|
||||
@ -1677,11 +2049,23 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
else:
|
||||
portrait_style = "Strict live-action photorealistic historical-fantasy production photography; real actor, honest skin, natural hair, restrained warm-neutral film color."
|
||||
sheet_style = "Strict live-action photorealistic costume-continuity studio photography by that same production photographer, with a real actor and physically believable period materials."
|
||||
# Nothing previously told the model what a correctly-proportioned body
|
||||
# looks like, and the turnaround triptych in particular squeezes each
|
||||
# full-body figure into a narrow 512-wide third of the frame — confirmed
|
||||
# live as a recurring "short/stubby" look across most characters. Every
|
||||
# template also used to hard-code "EXACTLY ONE ADULT PERSON" with zero
|
||||
# exceptions, so a genuinely child or teenage cast member was always
|
||||
# rendered as a full adult; _age_body_lock picks age-appropriate wording
|
||||
# instead. Everything here is phrased positively (no negator) per the
|
||||
# same reasoning as the armor lock above: this encoder still visualizes a
|
||||
# noun mentioned after "no"/"not", so "adult proportions, not dwarfed"
|
||||
# would still cue dwarfism rather than prevent it.
|
||||
subject_clause, identical_noun, proportion_lock = _age_body_lock(sheet)
|
||||
portrait = (
|
||||
("HEAD CONTINUITY: Clearly show the exposed scalp, crown, hairline, ears, and complete natural hairstyle. " if bareheaded else "")
|
||||
(f"HEAD CONTINUITY: Clearly show the exposed scalp, crown, hairline, {ear_shape}, and complete natural hairstyle. " if bareheaded else "")
|
||||
+ "CREATE ONE VERTICAL FULL-BODY CHARACTER IDENTITY. "
|
||||
f"CHARACTER: {portrait_subject}. {period} {portrait_style} "
|
||||
"EXACTLY ONE ADULT PERSON. Interpret all metaphors, memories, relationships, titles, and story actions only as character direction. The background is completely empty. "
|
||||
f"{subject_clause} {proportion_lock}Interpret all metaphors, memories, relationships, titles, and story actions only as character direction. The background is completely empty. "
|
||||
"Complete head-to-boots silhouette, relaxed front three-quarter stance, eye level, 50mm, plain uniform warm-white seamless studio background, fixed soft north-window key camera-left. "
|
||||
"Occupation is immediately obvious from the costume and material wear alone. Show occupational work clothing only. Both hands are empty; the belt, back, and clothing are clear of carried equipment. Face unobstructed. "
|
||||
"Leave generous empty space above the hair and around the complete body. Keep the complete head, hair, hands, and feet fully inside frame. Output 1024x1536."
|
||||
@ -1698,7 +2082,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
"LEFT THIRD: one complete full-body strict front view, shoulders hips knees and feet square to camera. "
|
||||
"CENTER THIRD: one complete full-body strict side view with the entire body rotated exactly 90 degrees toward image-left. "
|
||||
"RIGHT THIRD: one complete full-body strict rear view looking directly away, showing the back of head, back garment seams, backs of legs and heels. "
|
||||
"All three figures are the identical adult person with identical face, species, age, body proportions, hair, facial hair, costume construction, seams, palette, material wear, and footwear. Keep the exact anatomically established facial structure and species traits from Subject in every view. Use the same full-length 50mm camera distance in every panel. Align all three crowns and all three soles horizontally; each complete head-to-boots silhouette occupies the same height and remains centered in its equal-width third. Use a neutral stance, relaxed arms, visibly empty hands, unobstructed belts, and unobstructed backs. "
|
||||
f"All three figures are the identical {identical_noun} with identical face, species, age, body proportions, hair, facial hair, costume construction, seams, palette, material wear, and footwear. {proportion_lock}Keep the exact anatomically established facial structure and species traits from Subject in every view. Use the same full-length 50mm camera distance in every panel. Align all three crowns and all three soles horizontally; each complete head-to-boots silhouette occupies the same height and remains centered in its equal-width third. Use a neutral stance, relaxed arms, visibly empty hands, unobstructed belts, and unobstructed backs. "
|
||||
"Plain empty warm-white seamless studio background, neutral museum-soft light, soft contact shadows. Keep generous margin above every head and below every foot. The frame contains exactly the three required figures and the plain seamless background. Keep every surface unlettered and free of graphic overlays. Output 1536x1024."
|
||||
)
|
||||
side_view = (
|
||||
@ -2071,7 +2455,86 @@ def _bundled_comfyui_workflow(image_kind: str) -> tuple[str, str, str, str]:
|
||||
raise HTTPException(500, f"Bundled ComfyUI workflow is unavailable: {exc}")
|
||||
|
||||
|
||||
def _apply_character_image_rendering_style(prompt: str, image_kind: str) -> str:
|
||||
# Prompts saved before the field guidance was tightened embed their own style,
|
||||
# medium, framing and background ("painterly game concept art", "plain neutral
|
||||
# parchment background", "high quality painterly fantasy illustration"). The
|
||||
# rendering directive appended below then demands the opposite — a photorealistic
|
||||
# live-action portrait that is explicitly "not an illustration, painting…" —
|
||||
# so the model receives two contradictory briefs in one prompt and splits the
|
||||
# difference. Confirmed live on stored prompts. Strip the style clauses out so
|
||||
# the directive is the single source of truth for how the image should look;
|
||||
# everything describing WHO the character is survives untouched.
|
||||
# Clause labels and style words that describe HOW to render rather than WHO the
|
||||
# character is. Matching whole sentences (rather than nibbling substrings) keeps
|
||||
# the result readable — an earlier substring version left orphans like
|
||||
# "Style: Composition:" behind.
|
||||
_IMG_DIRECTIVE_LABEL = re.compile(
|
||||
r"^\s*(?:style|composition|lighting|context|task|background|framing|camera|render(?:ing)?|"
|
||||
r"mood|genre|setting|scene|atmosphere|environment)\s*:",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Whole sentences that describe the surroundings rather than the person. The
|
||||
# LLM narrates the scene it read in the book ("standing in a grassy landscape
|
||||
# at dusk", "the background shows a campfire"), which turns a casting portrait
|
||||
# into a full-body environmental shot — confirmed on every generated portrait.
|
||||
_IMG_SCENE_SENTENCE = re.compile(
|
||||
r"^\s*(?:the\s+)?(?:background|setting|scene|environment|atmosphere|surroundings)\b"
|
||||
r"|\b(?:in the distance|dark,?\s*stormy atmosphere)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_IMG_STYLE_WORDS = re.compile(
|
||||
r"\b(?:painterly|illustration|illustrated|concept\s+art|game\s+npc|stylized|cartoon|anime|"
|
||||
r"digital\s+painting|watercolou?r|headshot|bust|plain\s+neutral|parchment\s+background|"
|
||||
r"neutral\s+background|square\s+portrait)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _strip_conflicting_image_style(prompt: str) -> str:
|
||||
"""Drop clauses that dictate style/framing, keep those describing the character.
|
||||
|
||||
Prompts saved before the field guidance was tightened embed their own style,
|
||||
medium, framing and background. The rendering directive appended afterwards
|
||||
demands the opposite, so the model gets two contradictory briefs at once and
|
||||
splits the difference. Stripping them makes the directive authoritative.
|
||||
"""
|
||||
text = str(prompt or "")
|
||||
parts = re.split(r"(?<=[.!?])\s+|\n+", text)
|
||||
kept = [p for p in parts
|
||||
if p.strip()
|
||||
and not _IMG_DIRECTIVE_LABEL.search(p)
|
||||
and not _IMG_SCENE_SENTENCE.search(p)
|
||||
and not _IMG_STYLE_WORDS.search(p)]
|
||||
cleaned = " ".join(x.strip() for x in kept).strip()
|
||||
cleaned = re.sub(r"\s{2,}", " ", cleaned)
|
||||
# Never return an empty prompt: if a prompt was nothing but style clauses,
|
||||
# the original is still better than no description at all.
|
||||
return cleaned or text.strip()
|
||||
|
||||
|
||||
def _world_clause(book_context: str) -> str:
|
||||
"""Period/world grounding for image prompts.
|
||||
|
||||
The client normally appends this, but direct API callers (scripts, batch
|
||||
regeneration) bypass the client entirely — which is how a colonel in a
|
||||
1420-1500 setting was rendered in a 19th-century dress uniform.
|
||||
"""
|
||||
ctx = str(book_context or "").strip()
|
||||
if not ctx:
|
||||
return ""
|
||||
return (
|
||||
f"WORLD AND PERIOD (authoritative, overrides any era implied above): {ctx}. "
|
||||
"Clothing, materials, tailoring, armour, weapons and insignia must fit that period. "
|
||||
"A rank title must NOT become a modern or 19th-century military uniform — no epaulettes, "
|
||||
"braided frogging, peaked caps or industrial-era tailoring. "
|
||||
)
|
||||
|
||||
|
||||
def _apply_character_image_rendering_style(prompt: str, image_kind: str, book_context: str = "") -> str:
|
||||
# Concept sheets have their own illustrative directive, so only the
|
||||
# photoreal portrait path needs the conflicting style words removed.
|
||||
if image_kind != "concept_art":
|
||||
prompt = _strip_conflicting_image_style(prompt)
|
||||
"""Keep asset style deterministic, including for prompts saved before v1.20.36.
|
||||
|
||||
A profile and a concept sheet serve different purposes. The LLM provides
|
||||
@ -2085,20 +2548,30 @@ def _apply_character_image_rendering_style(prompt: str, image_kind: str) -> str:
|
||||
"FINAL RENDERING DIRECTIVE: make this a refined hand-drawn production design sheet: "
|
||||
"pencil and ink linework, restrained watercolor washes, readable costume construction and props, "
|
||||
"subtle paper texture. It must be illustrative but not cartoonish; do not render it as a photograph, "
|
||||
"3D game art, anime, glossy digital painting, or a collage."
|
||||
"3D game art, anime, glossy digital painting, or a collage. "
|
||||
f"{_world_clause(book_context)}"
|
||||
)
|
||||
# The directive is appended last, where diffusion models weight it most, and
|
||||
# states framing explicitly. Stripping scene text from the prompt is not
|
||||
# enough on its own: a residual clause like "riding a horse" still pulled the
|
||||
# camera back to a full-body environmental shot instead of a casting portrait.
|
||||
return (
|
||||
f"{prompt}\n\n"
|
||||
"FINAL RENDERING DIRECTIVE: render this as a photorealistic live-action cinematic casting portrait, "
|
||||
"with natural human skin texture and pores, realistic hair, physically plausible anatomy, authentic "
|
||||
"fabric/leather/metal, and photographic lens lighting. Do not make an illustration, painting, anime, "
|
||||
"cartoon, game render, CGI character, or plastic-skinned doll."
|
||||
"fabric/leather/metal, and photographic lens lighting. "
|
||||
"FRAMING (overrides anything above): a tight head-and-shoulders portrait of ONE person, face clearly "
|
||||
"visible and facing the camera, against a plain uncluttered neutral studio backdrop. Ignore any "
|
||||
"landscape, room, background, weather, action or scene mentioned above — show only the person. "
|
||||
f"{_world_clause(book_context)}"
|
||||
"No modern or anachronistic objects. "
|
||||
"Do not make an illustration, painting, anime, cartoon, game render, CGI character, or plastic-skinned doll."
|
||||
)
|
||||
|
||||
|
||||
def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
|
||||
prompt_field: str, output_node_id: str, prompt: str,
|
||||
seed_key: str = "") -> str:
|
||||
seed_key: str = "", progress=None) -> str:
|
||||
"""Submits a pre-exported ComfyUI API-format workflow with the given
|
||||
prompt text injected into one designated node/field, waits for it to
|
||||
finish, and returns the resulting image as a data: URI.
|
||||
@ -2160,9 +2633,12 @@ def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
|
||||
|
||||
# Poll history — generation on a real workflow (multi-sampler, upscale,
|
||||
# etc.) can take minutes, so this waits longer than a typical API call.
|
||||
deadline = time.time() + 300
|
||||
start = time.time()
|
||||
deadline = start + 300
|
||||
history = None
|
||||
while time.time() < deadline:
|
||||
if progress:
|
||||
progress(time.time() - start)
|
||||
try:
|
||||
hr = requests.get(f"{base}/history/{prompt_id}", timeout=10)
|
||||
if hr.ok:
|
||||
@ -2328,7 +2804,8 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str,
|
||||
identity_image: str, profile_prompt: str,
|
||||
front_prompt: str, side_prompt: str,
|
||||
back_prompt: str, scene_prompt: str,
|
||||
prop_prompts: list[str], style_key: str = "") -> dict:
|
||||
prop_prompts: list[str], style_key: str = "",
|
||||
progress=None) -> dict:
|
||||
"""Generate independently controlled views, scene and unique prop tiles."""
|
||||
try:
|
||||
workflow = json.loads(workflow_json)
|
||||
@ -2389,9 +2866,12 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str,
|
||||
# this local GPU. At the current model/runtime speed a pair can exceed
|
||||
# ten minutes; keep the HTTP request alive so a successful Comfy job is
|
||||
# not reported as a misleading 502 just after it finishes.
|
||||
deadline = time.time() + 1800
|
||||
start = time.time()
|
||||
deadline = start + 1800
|
||||
history = None
|
||||
while time.time() < deadline:
|
||||
if progress:
|
||||
progress(time.time() - start)
|
||||
try:
|
||||
hr = requests.get(f"{base}/history/{prompt_id}", timeout=10)
|
||||
if hr.ok:
|
||||
@ -2519,7 +2999,8 @@ async def character_generate_image(request: Request):
|
||||
# images" run across a whole cast could be tens of minutes of site-wide
|
||||
# freeze with no error, just silence. Runs in a worker thread instead.
|
||||
image_kind = "concept_art" if str(data.get("image_kind") or "").strip().lower() in {"concept", "concept_art", "sheet", "character_sheet"} else "portrait"
|
||||
prompt = _apply_character_image_rendering_style(prompt, image_kind)
|
||||
book_context: str = (data.get("book_context") or "").strip()
|
||||
prompt = _apply_character_image_rendering_style(prompt, image_kind, book_context)
|
||||
return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind)
|
||||
|
||||
|
||||
@ -2573,35 +3054,79 @@ async def character_generate_consistent_images(request: Request):
|
||||
)
|
||||
# Do not perpetuate the modern hats, clothing, drinks, or scenery present
|
||||
# in a bad legacy portrait. Reuse is opt-in only after visual review.
|
||||
identity_image = str(data.get("identity_image") or "").strip() if data.get("reuse_identity") is True else ""
|
||||
if identity_image.startswith("/"):
|
||||
# Library records store same-origin image URLs rather than embedding
|
||||
# large data URIs. Resolve those URLs server-side before uploading the
|
||||
# image to ComfyUI.
|
||||
try:
|
||||
local_image = requests.get(f"http://127.0.0.1:7890{identity_image}", timeout=30)
|
||||
local_image.raise_for_status()
|
||||
identity_image = "data:" + (local_image.headers.get("Content-Type") or "image/png") + ";base64," + base64.b64encode(local_image.content).decode("ascii")
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Could not load the existing identity image: {exc}")
|
||||
identity_image_input = str(data.get("identity_image") or "").strip() if data.get("reuse_identity") is True else ""
|
||||
# Each click gets a fresh latent family so a rejected composition is not
|
||||
# reproduced forever. The same family key is shared by the identity,
|
||||
# turnaround, scene and prop branches to preserve within-run cohesion.
|
||||
generation_key = f"voice-creator-cast-v6|{book}|{visual_style}|{name}|{uuid.uuid4().hex}"
|
||||
if not identity_image:
|
||||
anchor = await asyncio.to_thread(
|
||||
_comfyui_run,
|
||||
comfy_url, _bundled_character_identity_workflow(),
|
||||
"5", "text", "identity_save", profile_prompt, generation_key,
|
||||
)
|
||||
identity_image = anchor
|
||||
result = await asyncio.to_thread(
|
||||
_comfyui_run_consistent_images,
|
||||
comfy_url, _bundled_consistent_comfyui_workflow(), identity_image,
|
||||
profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts,
|
||||
generation_key,
|
||||
)
|
||||
return result
|
||||
|
||||
# The whole render can legitimately take 10-20+ minutes on a local GPU
|
||||
# (identity, then front/side/back/scene/props run serially). Returning a
|
||||
# job id immediately and doing the actual work in a background thread —
|
||||
# instead of blocking this request for the full duration — lets the UI
|
||||
# poll /status and show live progress rather than a silent hung-looking
|
||||
# fetch that a page reload would then throw away mid-render.
|
||||
job_id = uuid.uuid4().hex
|
||||
_cij_new(job_id, name or "Character")
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
identity_image = identity_image_input
|
||||
if identity_image.startswith("/"):
|
||||
# Library records store same-origin image URLs rather than
|
||||
# embedding large data URIs. Resolve those URLs server-side
|
||||
# before uploading the image to ComfyUI.
|
||||
_cij_set(job_id, stage="identity", detail="Loading existing identity image…")
|
||||
try:
|
||||
local_image = requests.get(f"http://127.0.0.1:7890{identity_image}", timeout=30)
|
||||
local_image.raise_for_status()
|
||||
identity_image = "data:" + (local_image.headers.get("Content-Type") or "image/png") + ";base64," + base64.b64encode(local_image.content).decode("ascii")
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Could not load the existing identity image: {exc}")
|
||||
if not identity_image:
|
||||
_cij_set(job_id, stage="identity", detail="Generating shared identity portrait…")
|
||||
identity_image = _comfyui_run(
|
||||
comfy_url, _bundled_character_identity_workflow(),
|
||||
"5", "text", "identity_save", profile_prompt, generation_key,
|
||||
# Just a heartbeat (refreshes "updated" so /status's
|
||||
# stale_seconds doesn't fire a false "may be stuck"
|
||||
# warning) — the elapsed counter itself is computed
|
||||
# client-side from "started", not baked into this text.
|
||||
progress=lambda elapsed: _cij_set(job_id),
|
||||
)
|
||||
_cij_set(job_id, stage="render", detail="Rendering profile, turnaround, scene & props…")
|
||||
result = _comfyui_run_consistent_images(
|
||||
comfy_url, _bundled_consistent_comfyui_workflow(), identity_image,
|
||||
profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts,
|
||||
generation_key,
|
||||
progress=lambda elapsed: _cij_set(job_id),
|
||||
)
|
||||
_cij_set(job_id, stage="done", detail="Done", done=True, result=result)
|
||||
except HTTPException as exc:
|
||||
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc.detail))
|
||||
except Exception as exc:
|
||||
_cij_set(job_id, stage="error", detail="Failed", done=True, error=str(exc))
|
||||
|
||||
threading.Thread(target=_worker, daemon=True, name=f"consistent-images-{job_id}").start()
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/api/character-generate-consistent-images/status/{job_id}")
|
||||
async def character_generate_consistent_images_status(job_id: str):
|
||||
"""Poll target for the background job started above.
|
||||
|
||||
`result` stays null until `done` is true, so intermediate polls never ship
|
||||
the multi-megabyte base64 image payload — only the final one does.
|
||||
"""
|
||||
with _consistent_image_jobs_lock:
|
||||
job = _consistent_image_jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, "Unknown or expired job id")
|
||||
payload = dict(job)
|
||||
now = time.time()
|
||||
payload["elapsed"] = round(now - payload.pop("started"), 1)
|
||||
payload["stale_seconds"] = round(now - payload.pop("updated"), 1)
|
||||
return payload
|
||||
|
||||
|
||||
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict:
|
||||
@ -2861,6 +3386,39 @@ def _attribution_prepare(data: dict) -> dict:
|
||||
"(z.B. \"Man nennt mich Andra\", \"Ich bin X\", \"Mein Name ist X\", \"Ich heiße X\"), ist diese genannte "
|
||||
"Person die Sprecherin dieser Zeile — nicht 'Unknown', auch ohne separaten Sprecher-Tag."
|
||||
)
|
||||
# Appended to EVERY prompt, with its own anchor, and deliberately last so it
|
||||
# overrides anything above it.
|
||||
#
|
||||
# The block above is skipped whenever a stored prompt already contains
|
||||
# "Doppelpunkt-Regel", and the saved German prompt does — so neither it nor
|
||||
# the English fallback below actually reaches the model on a configured
|
||||
# instance. Both the stored prompt and the client default still carry
|
||||
# "Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung
|
||||
# gegenüber 'Unknown'", which measurably produced 268 wrong names against
|
||||
# only 4 Unknowns, and made role labels ("Der Fremde", "Schmied") into
|
||||
# first-class cast members with their own voices. Correcting it in either
|
||||
# of those places alone would leave every already-saved prompt biased, so
|
||||
# the correction rides along unconditionally instead.
|
||||
if "ADRESSATEN-VORRANG" not in base_prompt:
|
||||
base_prompt += (
|
||||
"\n\nVORRANG-REGELN (überschreiben alle vorherigen Regeln bei Widerspruch):\n"
|
||||
"A. ADRESSATEN-VORRANG: Die Person, die in der Nähe eines Zitats genannt wird, ist sehr oft die "
|
||||
"ANGESPROCHENE — nicht die sprechende. Prüfe ausdrücklich: zeigt der Text, dass diese Person SPRICHT "
|
||||
"(Sprechverb, oder eine Handlung von ihr, die das Zitat rahmt), oder wird sie nur angesprochen, "
|
||||
"angesehen, beantwortet oder beschrieben? Besonders die Hauptfigur zieht fälschlich Zeilen an sich, "
|
||||
"die den Personen gehören, die MIT ihr sprechen — in beide Richtungen, sodass in einem Gespräch die "
|
||||
"Zeilen zweier Figuren vertauscht werden. Halte den Sprecherwechsel über eine ganze Szene konsistent, "
|
||||
"statt Zeile für Zeile neu zu entscheiden.\n"
|
||||
"B. Ist der EINZIGE Anhaltspunkt für einen Namen, dass die Figur in der Nähe steht oder in der Szene "
|
||||
"wichtig ist, gib 'Unknown' aus. Das überschreibt jede frühere Regel, die einen genannten Charakter "
|
||||
"oder eine Rollenbezeichnung gegenüber 'Unknown' bevorzugt. Unsichere Zeilen werden markiert und sind "
|
||||
"billig zu korrigieren; ein selbstsicher falscher Name ist es nicht, denn er gibt einer Figur "
|
||||
"unbemerkt die Stimme einer anderen. Wende Sprecherwechsel und eindeutige Tags weiterhin zuerst an — "
|
||||
"'Unknown' ist die Antwort bei echter Mehrdeutigkeit, kein Ersatz für Nachdenken.\n"
|
||||
"C. Ist dieselbe Person im Text mit einem Eigennamen bekannt, benutze IMMER diesen Namen, nie eine "
|
||||
"Rollenbezeichnung ('der Schmied', 'der Fremde', 'der Vampir'). Eine Rollenbezeichnung ist nur dann "
|
||||
"ein gültiger Sprecher, wenn die Person im bisherigen Text überhaupt keinen Eigennamen hat."
|
||||
)
|
||||
if not base_prompt.strip():
|
||||
base_prompt = (
|
||||
"You attribute dialogue in prose fiction for a multi-voice audiobook. "
|
||||
@ -2890,7 +3448,7 @@ def _attribution_prepare(data: dict) -> dict:
|
||||
"'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the "
|
||||
"known-characters list. Reuse the EXACT known names.\n"
|
||||
"4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and "
|
||||
"context — this should be rare. Prefer the most likely named character over 'Unknown'.\n"
|
||||
"context. See the VORRANG-REGELN appended below, which take precedence.\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 "
|
||||
|
||||
@ -465,7 +465,25 @@ async def update_voice_meta(request: Request):
|
||||
meta["needs_tts_restart"] = True
|
||||
_save_meta(wav, meta)
|
||||
if "transcript" in data:
|
||||
wav.with_suffix(".reference.txt").write_text(data.get("transcript", "").strip(), encoding="utf-8")
|
||||
transcript = data.get("transcript", "").strip()
|
||||
wav.with_suffix(".reference.txt").write_text(transcript, encoding="utf-8")
|
||||
# Saving the transcript here only ever updated OUR OWN sidecar file —
|
||||
# the Qwen voice-clone engine has its own separate voices.json config
|
||||
# (see sync_voice_clone_reference) that it actually reads ref_text
|
||||
# from at generation time, and nothing was telling it this voice's
|
||||
# transcript had changed. A container restart looked like the fix
|
||||
# (and the "needs_tts_restart" flag above suggests exactly that) but
|
||||
# doesn't help: it just reloads the same stale voices.json, so
|
||||
# generation kept failing with "ref_text is required" even after
|
||||
# restarting every TTS container (confirmed live). The batch
|
||||
# "prepare missing references" flow (_prepare_voice_reference) always
|
||||
# did this sync step; the interactive Save-text path here never did.
|
||||
if transcript:
|
||||
try:
|
||||
from routes.tts import sync_voice_clone_reference
|
||||
await asyncio.to_thread(sync_voice_clone_reference, wav, transcript, str(meta.get("lang") or ""), settings)
|
||||
except Exception:
|
||||
pass # best-effort — the sidecar file (source of truth for this app) is already saved
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, wav)
|
||||
return {"ok": True, "path": str(wav), "enabled": meta.get("enabled", True),
|
||||
"transcript": wav.with_suffix(".reference.txt").read_text(encoding="utf-8").strip()
|
||||
|
||||
@ -470,7 +470,16 @@ async def transcribe(request: Request):
|
||||
if src is None or not src.exists():
|
||||
raise HTTPException(404, "Audio not found")
|
||||
settings = _load_settings()
|
||||
backend = _clean_stt_backend(str(data.get("backend") or data.get("stt_backend") or "configured"))
|
||||
# No explicit backend from the caller (the common case — most callers,
|
||||
# e.g. the Voice Library's "Re-recognise text", just send the audio id)
|
||||
# used to always fall through to the legacy single "configured" Whisper
|
||||
# URL, silently ignoring whatever backend the user picked as their
|
||||
# preferred STT engine in Settings/the status-bar flyup — confirmed
|
||||
# live: switching the active engine there had no effect on this
|
||||
# endpoint at all, since stt_preferred_backend was saved but never read
|
||||
# back anywhere. An explicit request still wins over the preference.
|
||||
raw_backend = data.get("backend") or data.get("stt_backend") or settings.get("stt_preferred_backend") or "configured"
|
||||
backend = _clean_stt_backend(str(raw_backend))
|
||||
try:
|
||||
text, used_backend = await asyncio.to_thread(_transcribe_audio, src, settings, backend)
|
||||
return {"text": text, "backend": used_backend}
|
||||
|
||||
@ -1371,15 +1371,43 @@ _AUDIOBOOK_EXPORT_DIR = CONFIG_DIR / "audiobook_exports"
|
||||
_EXPORT_FILENAME_RE = re.compile(r"^[^/\\]{1,200}$") # any single path segment, no traversal
|
||||
|
||||
|
||||
def _audiobook_export_book_dir(book: str) -> Path:
|
||||
def _audiobook_export_book_dir(book: str, folder: str | None = None) -> Path:
|
||||
"""Resolve where this book's exports live. `folder` — the "Save Location
|
||||
(Server)" field in the export dialog — was collected in the UI but never
|
||||
actually read here; every export silently landed in the fixed default dir
|
||||
no matter what the user typed (confirmed live). Honor it now: a relative
|
||||
value (e.g. the field's "exports/" placeholder) resolves under the fixed
|
||||
default dir for convenience, an absolute one (as the folder-browse picker
|
||||
always supplies) is used as-is, subject to the same blocklist the picker
|
||||
itself already enforces (routes/admin.py's browse-dirs endpoint) so this
|
||||
can't be pointed at /proc, /sys, etc.
|
||||
"""
|
||||
safe_book = re.sub(r"[^A-Za-z0-9_-]+", "_", book).strip("_")[:80] or "book"
|
||||
d = _AUDIOBOOK_EXPORT_DIR / safe_book
|
||||
# The dialog's field defaults to literally "exports/" — the JS caller
|
||||
# already normalizes that away, but treat it as "no override" here too
|
||||
# in case a stale cached bundle (or a direct API call) still sends it,
|
||||
# so the default export location can't silently gain an extra nested
|
||||
# "exports" folder.
|
||||
if folder and folder.strip().strip("/").lower() == "exports":
|
||||
folder = None
|
||||
if folder and folder.strip():
|
||||
from routes.admin import _BROWSE_BLOCKED
|
||||
base = Path(folder.strip()).expanduser()
|
||||
if not base.is_absolute():
|
||||
base = _AUDIOBOOK_EXPORT_DIR / base
|
||||
base = base.resolve()
|
||||
base_str = str(base)
|
||||
if any(base_str == b or base_str.startswith(b + "/") for b in _BROWSE_BLOCKED):
|
||||
raise HTTPException(403, "That folder is not permitted")
|
||||
d = base / safe_book
|
||||
else:
|
||||
d = _AUDIOBOOK_EXPORT_DIR / safe_book
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("/api/audiobook-export/{book}")
|
||||
async def list_audiobook_exports(book: str):
|
||||
async def list_audiobook_exports(book: str, folder: str | None = None):
|
||||
"""List previously-saved chapter exports for this book — lets the app
|
||||
show a "browse what's already been exported" view without re-running
|
||||
the export, and without any real filesystem access on the user's part.
|
||||
@ -1387,7 +1415,7 @@ async def list_audiobook_exports(book: str):
|
||||
same reason "zip" and "check"/"prune" are elsewhere in this file: a
|
||||
plain path segment matches ANY literal string at the routing level.
|
||||
"""
|
||||
book_dir = _audiobook_export_book_dir(book)
|
||||
book_dir = _audiobook_export_book_dir(book, folder)
|
||||
files = sorted(
|
||||
({"name": f.name, "size": f.stat().st_size} for f in book_dir.iterdir() if f.is_file()),
|
||||
key=lambda x: x["name"],
|
||||
@ -1396,11 +1424,11 @@ async def list_audiobook_exports(book: str):
|
||||
|
||||
|
||||
@router.get("/api/audiobook-export/{book}/zip")
|
||||
async def zip_audiobook_exports(book: str):
|
||||
async def zip_audiobook_exports(book: str, folder: str | None = None):
|
||||
"""Bundle every saved chapter for this book into one ZIP download —
|
||||
the "download everything at once" the per-file list doesn't offer."""
|
||||
import zipfile
|
||||
book_dir = _audiobook_export_book_dir(book)
|
||||
book_dir = _audiobook_export_book_dir(book, folder)
|
||||
files = [f for f in book_dir.iterdir() if f.is_file()]
|
||||
if not files:
|
||||
raise HTTPException(404, "No exported files for this book")
|
||||
@ -1417,10 +1445,10 @@ async def zip_audiobook_exports(book: str):
|
||||
|
||||
|
||||
@router.get("/api/audiobook-export/{book}/{filename}")
|
||||
async def get_audiobook_export(book: str, filename: str):
|
||||
async def get_audiobook_export(book: str, filename: str, folder: str | None = None):
|
||||
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
path = _audiobook_export_book_dir(book) / filename
|
||||
path = _audiobook_export_book_dir(book, folder) / filename
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(404, "Not found")
|
||||
media_type = "audio/mpeg" if path.suffix.lower() == ".mp3" else "audio/wav"
|
||||
@ -1431,12 +1459,12 @@ async def get_audiobook_export(book: str, filename: str):
|
||||
|
||||
|
||||
@router.post("/api/audiobook-export/{book}/{filename}")
|
||||
async def put_audiobook_export(book: str, filename: str, request: Request):
|
||||
async def put_audiobook_export(book: str, filename: str, request: Request, folder: str | None = None):
|
||||
if not _EXPORT_FILENAME_RE.match(filename) or filename in (".", ".."):
|
||||
raise HTTPException(400, "Invalid filename")
|
||||
audio_bytes = await request.body()
|
||||
if not audio_bytes:
|
||||
raise HTTPException(400, "Empty request body")
|
||||
path = _audiobook_export_book_dir(book) / filename
|
||||
path = _audiobook_export_book_dir(book, folder) / filename
|
||||
path.write_bytes(audio_bytes)
|
||||
return {"ok": True, "path": str(path)}
|
||||
|
||||
62
scripts/backup_characters.py
Normal file
62
scripts/backup_characters.py
Normal file
@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Snapshot a book's character-library records to a JSON file for later comparison.
|
||||
|
||||
python3 scripts/backup_characters.py "<book title>" characters_auto.json
|
||||
python3 scripts/backup_characters.py "<book title>" characters_manual.json --base-url http://127.0.0.1:7890
|
||||
|
||||
Matches records the same way the app does: exact `book` field OR `book`
|
||||
present in the comma-separated `tags` field (case-insensitive) — this mirrors
|
||||
`_characters_for_title` in routes/reader.py / clGetAllByTagOrBook in
|
||||
characters-library.js, so the snapshot contains exactly what the UI would
|
||||
show for that book.
|
||||
|
||||
Run this once right after "Define Characters" (step 5) for each version you
|
||||
want to compare — e.g. once for the automatic pass, once for the manually
|
||||
corrected KONTROLLE version — before either one gets overwritten by a later
|
||||
run. compare_characters.py then diffs two such snapshots.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def characters_for_title(all_chars: list[dict], title: str) -> list[dict]:
|
||||
key = title.strip().lower()
|
||||
out = []
|
||||
for rec in all_chars:
|
||||
book = str(rec.get("book") or "").strip().lower()
|
||||
tags = [t.strip().lower() for t in str(rec.get("tags") or "").split(",")]
|
||||
if book == key or key in tags:
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("book", help="Book title as it appears in the character's `book` field / tags")
|
||||
ap.add_argument("outfile", help="Path to write the JSON snapshot to")
|
||||
ap.add_argument("--base-url", default="http://127.0.0.1:7890")
|
||||
args = ap.parse_args()
|
||||
|
||||
resp = requests.get(f"{args.base_url}/api/characters", timeout=60)
|
||||
resp.raise_for_status()
|
||||
all_chars = resp.json()
|
||||
if isinstance(all_chars, dict):
|
||||
all_chars = all_chars.get("characters") or all_chars.get("items") or []
|
||||
|
||||
matched = characters_for_title(all_chars, args.book)
|
||||
if not matched:
|
||||
print(f"No characters found for book {args.book!r}. Known books: "
|
||||
f"{sorted({str(c.get('book') or '') for c in all_chars} - {''})}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
matched.sort(key=lambda r: str(r.get("name") or "").lower())
|
||||
with open(args.outfile, "w", encoding="utf-8") as f:
|
||||
json.dump(matched, f, ensure_ascii=False, indent=2)
|
||||
print(f"Wrote {len(matched)} characters for {args.book!r} -> {args.outfile}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
125
scripts/compare_characters.py
Normal file
125
scripts/compare_characters.py
Normal file
@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare an automated character-library snapshot against a manually-corrected one.
|
||||
|
||||
python3 scripts/compare_characters.py manual.json auto.json
|
||||
|
||||
Snapshots come from backup_characters.py. Characters are matched by name OR by
|
||||
any shared alias (an LLM may canonicalise a character under a different name
|
||||
each run), then their sheets are diffed field by field. This is the input for
|
||||
tightening the character-extraction / alias-detection prompt in
|
||||
static/js/character-sheets.js — keep any rule change general (it must not
|
||||
reference names specific to one book) and re-check it against a different
|
||||
book afterwards.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
FIELDS = [
|
||||
"aliases", "first_name", "last_name", "title", "profession",
|
||||
"age_estimate", "race_species", "languages", "nationality_background",
|
||||
"social_class", "archetype", "gender", "physical", "clothing", "alignment",
|
||||
]
|
||||
|
||||
|
||||
def norm(t: str) -> str:
|
||||
return re.sub(r"\s+", " ", str(t or "")).strip().lower()
|
||||
|
||||
|
||||
def identity_tokens(rec: dict) -> set[str]:
|
||||
sheet = rec.get("sheet") or {}
|
||||
toks = {norm(rec.get("name"))}
|
||||
for part in re.split(r"[,;|]", str(sheet.get("aliases") or "")):
|
||||
p = norm(part)
|
||||
if p:
|
||||
toks.add(p)
|
||||
toks.discard("")
|
||||
return toks
|
||||
|
||||
|
||||
def load(path: str) -> list[dict]:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def match(manual: list[dict], auto: list[dict]):
|
||||
"""Greedy match by best token overlap; returns (pairs, only_manual, only_auto)."""
|
||||
auto_left = list(range(len(auto)))
|
||||
pairs = []
|
||||
for mi, m in enumerate(manual):
|
||||
mt = identity_tokens(m)
|
||||
best, best_score = None, 0
|
||||
for ai in auto_left:
|
||||
score = len(mt & identity_tokens(auto[ai]))
|
||||
if score > best_score:
|
||||
best, best_score = ai, score
|
||||
if best is not None and best_score > 0:
|
||||
pairs.append((m, auto[best]))
|
||||
auto_left.remove(best)
|
||||
matched_manual = {id(p[0]) for p in pairs}
|
||||
only_manual = [m for m in manual if id(m) not in matched_manual]
|
||||
only_auto = [auto[ai] for ai in auto_left]
|
||||
return pairs, only_manual, only_auto
|
||||
|
||||
|
||||
def field_val(rec: dict, key: str) -> str:
|
||||
v = (rec.get("sheet") or {}).get(key)
|
||||
if isinstance(v, list):
|
||||
return ", ".join(str(x) for x in v if x)
|
||||
return str(v or "").strip()
|
||||
|
||||
|
||||
def main(man_path: str, auto_path: str) -> None:
|
||||
manual, auto = load(man_path), load(auto_path)
|
||||
print("=" * 78)
|
||||
print(f"MANUAL characters: {len(manual)} AUTO characters: {len(auto)}")
|
||||
print("=" * 78)
|
||||
|
||||
pairs, only_manual, only_auto = match(manual, auto)
|
||||
|
||||
print(f"\nmatched by name/alias: {len(pairs)}")
|
||||
print(f"\n--- characters AUTO missed entirely ({len(only_manual)}) ---")
|
||||
for m in only_manual:
|
||||
print(f" {m.get('name')!r} (aliases: {field_val(m, 'aliases') or '-'})")
|
||||
|
||||
print(f"\n--- characters AUTO invented / split out (not in manual) ({len(only_auto)}) ---")
|
||||
for a in only_auto:
|
||||
print(f" {a.get('name')!r} (aliases: {field_val(a, 'aliases') or '-'})")
|
||||
|
||||
field_diff_count = {k: 0 for k in FIELDS}
|
||||
detailed = []
|
||||
for m, a in pairs:
|
||||
diffs = []
|
||||
for k in FIELDS:
|
||||
mv, av = field_val(m, k), field_val(a, k)
|
||||
if norm(mv) != norm(av):
|
||||
field_diff_count[k] += 1
|
||||
diffs.append((k, mv, av))
|
||||
if diffs:
|
||||
detailed.append((m.get("name"), a.get("name"), diffs))
|
||||
|
||||
print(f"\n--- field mismatch counts across {len(pairs)} matched characters ---")
|
||||
for k, c in sorted(field_diff_count.items(), key=lambda x: -x[1]):
|
||||
if c:
|
||||
print(f" {k:22s} {c:3d}/{len(pairs)}")
|
||||
|
||||
print("\n--- per-character diffs ---")
|
||||
for mname, aname, diffs in detailed:
|
||||
label = mname if mname == aname else f"{mname!r} (auto named {aname!r})"
|
||||
print(f"\n {label}")
|
||||
for k, mv, av in diffs:
|
||||
print(f" {k:22s} manual={mv!r:40s} auto={av!r}")
|
||||
|
||||
tot = len(pairs)
|
||||
exact_matches = tot - len(detailed)
|
||||
print("\n" + "=" * 78)
|
||||
print(f"summary: {len(manual)} manual / {len(auto)} auto | "
|
||||
f"missed={len(only_manual)} invented={len(only_auto)} | "
|
||||
f"matched={tot} exact={exact_matches} field-diffs={len(detailed)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
31
sonar-project.properties
Normal file
31
sonar-project.properties
Normal file
@ -0,0 +1,31 @@
|
||||
# SonarQube Community Build — project configuration
|
||||
# Server: https://sq.martin-bierschenk.de (set as SONAR_HOST_URL in CI, never here)
|
||||
# Tokens are supplied only via the SONAR_TOKEN CI secret — never in this file.
|
||||
|
||||
sonar.projectKey=tts-voice-creator-clone-and-design-2
|
||||
sonar.projectName=TTS Voice Creator (Clone & Design)
|
||||
|
||||
# Hand-written source only. Everything excluded below is either generated,
|
||||
# vendored, third-party data, or runtime state — scanning it produces noise
|
||||
# that buries real findings.
|
||||
sonar.sources=core,routes,static/js,scripts,server.py,ttsvc_cli.py,hotkey_daemon.py,guided_installer.py
|
||||
sonar.tests=tests
|
||||
|
||||
sonar.exclusions=\
|
||||
static/dist/**,\
|
||||
static/vendor/**,\
|
||||
node_modules/**,\
|
||||
**/__pycache__/**,\
|
||||
**/*.min.js,\
|
||||
**/*.min.css,\
|
||||
workflows/*.json,\
|
||||
config/**,\
|
||||
scratch/**,\
|
||||
venv/**,\
|
||||
.venv/**
|
||||
|
||||
# Generated bundle and ComfyUI graphs must never count toward duplication.
|
||||
sonar.cpd.exclusions=static/dist/**,workflows/*.json
|
||||
|
||||
sonar.python.version=3.11
|
||||
sonar.sourceEncoding=UTF-8
|
||||
124
static/dist/main.min.js
vendored
124
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -335,7 +335,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Global Directory Browser Modal -->
|
||||
<div class="reh-modal-overlay" id="global-dir-browser-modal" hidden style="z-index:9999;">
|
||||
<!-- z-index above any single-purpose modal (e.g. #audiobook-export-modal, also
|
||||
z-index:9999) that might open this picker on one of its own fields — those
|
||||
get re-appended to the end of <body> on open, which at an equal z-index
|
||||
would otherwise paint over this one and hide it (confirmed live: opening
|
||||
Browse from inside the Export Audiobook dialog). -->
|
||||
<div class="reh-modal-overlay" id="global-dir-browser-modal" hidden style="z-index:10000;">
|
||||
<div class="reh-modal-box" style="width:500px; max-width:90vw; max-height:80vh;">
|
||||
<div class="reh-modal-head">
|
||||
<span>Browse Container Folders</span>
|
||||
@ -343,10 +348,19 @@
|
||||
</div>
|
||||
<div style="padding:16px; overflow-y:auto; flex:1;">
|
||||
<div class="vef-dir-browser" style="margin-top:0; border:1px solid var(--border); border-radius:var(--radius); background:var(--bg-card); display:flex; flex-direction:column; min-height: 250px;">
|
||||
<div class="vef-breadcrumb" id="global-dir-browser-crumb" style="padding:8px 12px; border-bottom:1px solid var(--border); background:var(--bg-sunken); overflow-x:auto; white-space:nowrap;"></div>
|
||||
<div style="display:flex; align-items:center; gap:8px; padding:8px 12px; border-bottom:1px solid var(--border); background:var(--bg-sunken);">
|
||||
<div class="vef-breadcrumb" id="global-dir-browser-crumb" style="flex:1; overflow-x:auto; white-space:nowrap;"></div>
|
||||
<button type="button" class="btn-icon" id="global-dir-browser-newfolder-btn" title="Create a new folder here"><span class="mdi mdi-folder-plus-outline"></span></button>
|
||||
</div>
|
||||
<div class="vef-newfolder-row" id="global-dir-browser-newfolder-row" hidden style="display:flex; gap:8px; padding:8px 12px; border-bottom:1px solid var(--border); background:var(--bg-sunken);">
|
||||
<input type="text" id="global-dir-browser-newfolder-input" placeholder="New folder name" style="flex:1; min-width:0;">
|
||||
<button type="button" class="btn-secondary btn-sm" id="global-dir-browser-newfolder-cancel">Cancel</button>
|
||||
<button type="button" class="btn-primary btn-sm" id="global-dir-browser-newfolder-create">Create</button>
|
||||
</div>
|
||||
<div class="vef-dir-list" id="global-dir-browser-list" style="padding:8px; overflow-y:auto; flex:1; max-height:300px;"><span class="vef-loading" style="padding:8px; color:var(--text-muted);">Loading…</span></div>
|
||||
</div>
|
||||
<p class="note" style="margin-top:12px;">This browses the filesystem <strong>inside</strong> the Docker container, not your host computer. Ensure volumes are mapped correctly.</p>
|
||||
<p class="note vef-mount-hint" hidden style="margin-top:6px;"></p>
|
||||
</div>
|
||||
<div style="padding:16px; border-top:1px solid var(--border); display:flex; gap:12px; align-items:center;">
|
||||
<span class="note" id="global-dir-browser-path" style="flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:monospace;">/</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -27,7 +27,7 @@ For each character output these fields:
|
||||
- social_class: rank or class if the text makes it clear (noble, soldier, slave, merchant, priesthood, etc.)
|
||||
- archetype: a two-word role summary (e.g. 'Ruthless Scholar')
|
||||
- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.
|
||||
- physical: height, weight, build, hair, eyes, skin, posture, gait, distinguishing features, physical disabilities, fantasy-specific extras, and any other bodily appearance details. Use metric units when size/weight is known.
|
||||
- physical: the character's BASELINE appearance — how they normally look through most of the book. Height, weight, build, hair, eyes, skin, posture, gait, permanent distinguishing features (scars they carry throughout, lasting disabilities), fantasy-specific extras. Use metric units when size/weight is known. Do NOT describe a temporary state: wounds received in one scene, blood, gore, nudity, dirt, being maimed, flayed, transformed, dying or dead, unconscious, or how they look at their worst or most monstrous moment. Those belong in 'notes' or 'arc_note'. If the book only ever describes this character vividly during such a scene, still write their ORDINARY appearance here (clothed, uninjured, in their normal form) and infer it from their role, rank and origin, marking inferred traits with '*'. This field is used verbatim to generate the character's portrait, so a transient injury or transformation written here becomes their permanent face.
|
||||
- clothing: day-to-day wear, work attire, formal wear, sleepwear, undergarments, accessories, and visible weapons/gear if they define the look
|
||||
- alignment: strict moral code + the one line they will never cross
|
||||
- moral_alignment_score: integer 0–100. 100 = purely good/heroic, 0 = purely evil/villainous, 50 = neutral/ambiguous
|
||||
@ -44,7 +44,7 @@ For each character output these fields:
|
||||
- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.
|
||||
- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)
|
||||
- voice_design_prompt: concise English voice-design prompt, maximum 480 characters. Use only objective, audible traits; no lore-only labels or repeated synonyms. Include gender as a hard audible constraint, age impression, pitch, timbre, pace, accent/register, emotion and audiobook use. Translate species/role into audible cues (orc: chest-led/gravel-edged; vampire: cool/velvety/controlled; elf: airy/precise/adult; demon: dark/subtly otherworldly; warrior: disciplined command cadence; blacksmith: work-worn/practical; mage: measured/precise). Give one distinctive vocal fingerprint. Do NOT mention plot spoilers.
|
||||
- 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 '*'.
|
||||
- image_prompt: detailed English image-generation prompt describing WHO this character is: face, age impression, build, hair/eyes/skin if known, clothing, posture, props, visible symbols, and overall bearing. Mark inferred traits with '*'. Describe their normal appearance, never a wound or death scene. Describe ONLY the person: face, hair, build, clothing and what they carry. Do NOT describe a setting, location, landscape, room, weather, background, or an action they are performing (no "standing in a grassy landscape", "riding a horse", "the background shows a campfire"), and do NOT specify an art style, medium, rendering technique, framing or lighting (no "painterly", "illustration", "concept art", "dramatic lighting"). The application appends its own framing and rendering directive; scene or style written here fights it and turns the casting portrait into a full-body scene.
|
||||
- inventory: 1-3 defining items/props/clothing (array of short strings)
|
||||
- secret: dark secret or fatal flaw
|
||||
- conflict_style: fight, flight, or manipulate — how they act when cornered
|
||||
@ -55,6 +55,7 @@ For each character output these fields:
|
||||
- tier: 'main' or 'supporting'
|
||||
- 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-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.
|
||||
IDENTITY MERGING: A character may appear under multiple names in the book (first name, last name, title, role, alias, nickname). Examples: 'Marcus', 'the Executioner', and 'Bloodfang' 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 and keep one canonical name.
|
||||
STRICT PER-CHARACTER ATTRIBUTION: a passage often describes several characters together (a leader addressing followers, a group scene, a comparison between two people). Attribute every fact — title, role, profession, race/species, physical trait, relationship, backstory detail, or any other field — ONLY to the character it explicitly and specifically describes. NEVER copy a detail onto another character's sheet just because that character appears in the same scene, sentence, or paragraph, is compared or contrasted with them, or belongs to the same faction/group (e.g. only the actual leader of a group gets 'leader' in their profession/title — the group's other members do NOT). If it is ambiguous which character a detail belongs to, leave it out of every sheet rather than guessing.
|
||||
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.
|
||||
Respond with STRICT JSON only:
|
||||
{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","title":"","profession":"","age_estimate":"","race_species":"","languages":"","nationality_background":"","social_class":"","archetype":"","gender":"","physical":"","clothing":"","alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"","attribute_high":"","attribute_low":"","skills":"","capabilities":"","backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","communication_style":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"","inventory":[],"secret":"","conflict_style":"","win_condition":"","reputation":"","religious_beliefs":"","notes":"","tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}
|
||||
@ -209,13 +210,30 @@ function csSplitIdentityTokens(v, opts = {}) {
|
||||
return parts.slice(0, opts.aliases ? CS_ALIAS_MAX_TOKENS : undefined);
|
||||
}
|
||||
|
||||
function csCleanRoster(names) {
|
||||
// Bit parts labelled only by role ("Wache", "Mann", "Menge", "Zauberer") that
|
||||
// speak a line or two cannot support a character profile — there is nothing in
|
||||
// the book to fill one from. Measured on the hand-corrected reference: 85% of
|
||||
// generic-role-label records came back completely empty (24 of 28), against
|
||||
// 13% for records naming an actual person. Those empty shells cost an LLM call
|
||||
// each, clutter the character library, and count as invented characters when
|
||||
// comparing against a human cast.
|
||||
//
|
||||
// They are only skipped for PROFILE GENERATION — they stay in the cast and
|
||||
// still get voices, since they do speak. A role label with real presence in
|
||||
// the book (4+ lines) is kept: "Bäckerstochter" and "Der Zwerg" have four
|
||||
// lines each and both produced complete, useful profiles.
|
||||
const CS_ROLE_LABEL_MIN_LINES = 4;
|
||||
|
||||
function csCleanRoster(names, lineCounts) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const counts = lineCounts instanceof Map ? lineCounts : null;
|
||||
const isGeneric = typeof _abIsGenericRoleLabel === 'function' ? _abIsGenericRoleLabel : () => false;
|
||||
(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;
|
||||
if (counts && isGeneric(name) && (counts.get(key) || 0) < CS_ROLE_LABEL_MIN_LINES) return;
|
||||
seen.add(key);
|
||||
out.push(name);
|
||||
});
|
||||
@ -696,7 +714,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
|
||||
});
|
||||
// Pre-seed the roster with known characters from the casting run so the LLM
|
||||
// fills those profiles instead of treating the cast list as aliases to invent.
|
||||
const roster = csCleanRoster(initialRoster);
|
||||
const roster = csCleanRoster(initialRoster, lineCounts);
|
||||
const targetMode = roster.length > 0;
|
||||
const knownTotal = targetMode ? roster.length : null;
|
||||
roster.forEach(name => {
|
||||
@ -1356,8 +1374,28 @@ function csAlignmentBar(score, arcDirection, arcNote) {
|
||||
// Fallback only — the LLM-generated image_prompt (routes/conversation.py
|
||||
// character_generate_prompts) is preferred. This is deliberately a
|
||||
// photorealistic *profile portrait*; concept sheets use concept_art_prompt.
|
||||
// Shared world/era grounding, applied to LLM-written and fallback prompts alike.
|
||||
function csWorldGroundingClause(bookProfile) {
|
||||
const bp = bookProfile || {};
|
||||
const bits = [bp.genre, bp.setting, bp.era].filter(Boolean);
|
||||
if (!bits.length) return '';
|
||||
return `WORLD AND PERIOD (authoritative, overrides any era implied above): this character belongs to `
|
||||
+ `${bits.join(', ')}. Every visual choice — clothing, materials, cut and tailoring, armour, weapons, insignia, `
|
||||
+ `jewellery, technology level and architecture — must fit THAT world and period. A rank or occupation title `
|
||||
+ `(Colonel, Admiral, General, Captain) must NOT be drawn as a modern or 19th-century military uniform: no `
|
||||
+ `epaulettes, no braided frogging, no peaked caps, no industrial-era tailoring, no machine-made fabric, `
|
||||
+ `no zips or modern buttons. Use only what that period could actually make.`;
|
||||
}
|
||||
|
||||
function csBuildImagePrompt(s, bookProfile) {
|
||||
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
|
||||
// The world/era clause below used to sit AFTER an early return, so whenever the
|
||||
// LLM had produced an image_prompt — i.e. essentially always — neither the book
|
||||
// setting nor the anti-anachronism rule was ever applied. That is precisely how
|
||||
// "Oberst Alrik" came out in a 19th-century dress uniform with epaulettes in a
|
||||
// book explicitly dated 1420-1500. The grounding is now appended to the LLM
|
||||
// prompt as well, instead of only to the fallback.
|
||||
const llm = _csStr(s.image_prompt).trim();
|
||||
if (llm) return llm + ' ' + csWorldGroundingClause(bookProfile);
|
||||
const parts = [];
|
||||
if (s.gender) parts.push(`exactly ${s.gender}`);
|
||||
if (s.race_species) parts.push(s.race_species);
|
||||
|
||||
@ -186,7 +186,10 @@ function initElevenLabsBrowser() {
|
||||
: 'N';
|
||||
const base = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice';
|
||||
const voiceId = `EL_${lang}_${gender}_${base}`;
|
||||
// No "EL_" source prefix (requested live) — origin is already tracked via
|
||||
// the tag/group/origin fields sent below, so the prefix was purely
|
||||
// cosmetic clutter on the filename, not load-bearing for anything.
|
||||
const voiceId = `${lang}_${gender}_${base}`;
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/quick-import-voice', {
|
||||
|
||||
@ -3252,6 +3252,112 @@ async function _charAutoGenerateImage(rec, provider) {
|
||||
return d.image;
|
||||
}
|
||||
|
||||
// Live status panel for the (up to ~20-minute) consistent-image job below —
|
||||
// without this, the button just says "Generating…" for the whole run with no
|
||||
// way to tell a slow-but-working render apart from one that's stuck, which is
|
||||
// exactly what made a finished-but-unclaimed job look broken (confirmed live:
|
||||
// a page reload mid-render silently threw away a render that had actually
|
||||
// already succeeded on the ComfyUI side).
|
||||
// Backend-reported stages, in the order they actually run — used to render
|
||||
// each as a step (done/active/pending) rather than just replacing one line
|
||||
// of text, so at a glance you can tell which phase a still-running job is in.
|
||||
const _CIJ_STEPS = [
|
||||
{ key: 'identity', label: 'Identity portrait' },
|
||||
{ key: 'render', label: 'Turnaround, scene & props' },
|
||||
];
|
||||
|
||||
function _cijShowPanel(name) {
|
||||
document.getElementById('cij-progress-panel')?.remove();
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'cij-progress-panel';
|
||||
panel.className = 'cij-progress-panel';
|
||||
const stepsHtml = _CIJ_STEPS.map(function (s) {
|
||||
return '<div class="cij-step" data-step="' + s.key + '"><span class="mdi mdi-circle-outline cij-step-icon"></span><span>' + escHtml(s.label) + '</span></div>';
|
||||
}).join('');
|
||||
panel.innerHTML = '<div class="cij-progress-head">'
|
||||
+ '<span class="mdi mdi-face-recognition"></span>'
|
||||
+ '<span class="cij-progress-title">Generating images — ' + escHtml(name || 'Character') + '</span>'
|
||||
+ '<button type="button" class="cij-progress-close" title="Hide (keeps running in the background)"><span class="mdi mdi-close"></span></button>'
|
||||
+ '</div>'
|
||||
+ '<div class="cij-progress-steps">' + stepsHtml + '</div>'
|
||||
+ '<div class="cij-progress-detail">Starting…</div>'
|
||||
+ '<div class="cij-progress-elapsed">Elapsed 0:00</div>';
|
||||
document.body.appendChild(panel);
|
||||
panel.querySelector('.cij-progress-close').addEventListener('click', function () { panel.remove(); });
|
||||
return {
|
||||
update: function (stage, detail, elapsed, warn) {
|
||||
const p = document.getElementById('cij-progress-panel');
|
||||
if (!p) return;
|
||||
p.querySelector('.cij-progress-detail').textContent = detail || 'Working…';
|
||||
p.querySelector('.cij-progress-elapsed').textContent = 'Elapsed ' + _cijFmtElapsed(elapsed)
|
||||
+ (warn ? ' — no update in a while, it may be stuck' : '');
|
||||
p.classList.toggle('cij-progress-warn', !!warn);
|
||||
const activeIndex = _CIJ_STEPS.findIndex(function (s) { return s.key === stage; });
|
||||
p.querySelectorAll('.cij-step').forEach(function (el, i) {
|
||||
const icon = el.querySelector('.cij-step-icon');
|
||||
el.classList.remove('is-done', 'is-active', 'is-pending');
|
||||
if (activeIndex < 0 || i < activeIndex) {
|
||||
el.classList.add('is-done');
|
||||
icon.className = 'mdi mdi-check-circle cij-step-icon';
|
||||
} else if (i === activeIndex) {
|
||||
el.classList.add('is-active');
|
||||
icon.className = 'mdi mdi-loading mdi-spin cij-step-icon';
|
||||
} else {
|
||||
el.classList.add('is-pending');
|
||||
icon.className = 'mdi mdi-circle-outline cij-step-icon';
|
||||
}
|
||||
});
|
||||
},
|
||||
done: function (ok) {
|
||||
const p = document.getElementById('cij-progress-panel');
|
||||
if (!p) return;
|
||||
if (ok) {
|
||||
p.querySelectorAll('.cij-step').forEach(function (el) {
|
||||
el.classList.remove('is-active', 'is-pending');
|
||||
el.classList.add('is-done');
|
||||
el.querySelector('.cij-step-icon').className = 'mdi mdi-check-circle cij-step-icon';
|
||||
});
|
||||
}
|
||||
p.classList.add(ok ? 'cij-progress-ok' : 'cij-progress-err');
|
||||
setTimeout(function () { p.remove(); }, ok ? 2500 : 7000);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function _cijFmtElapsed(seconds) {
|
||||
seconds = Math.max(0, Math.floor(seconds || 0));
|
||||
const m = Math.floor(seconds / 60), s = seconds % 60;
|
||||
return m + ':' + String(s).padStart(2, '0');
|
||||
}
|
||||
|
||||
// Polls the background job until it finishes, updating the panel each tick.
|
||||
// Flagged "stuck" (not aborted — there's no server-side cancel wired up yet)
|
||||
// once 60s pass with no backend-side progress update, since the poll interval
|
||||
// itself is only ~1.5s so any longer gap means the worker thread stopped
|
||||
// reporting, not that ComfyUI is merely slow.
|
||||
async function _cijPollJob(jobId, panel) {
|
||||
const STALE_WARN_SECONDS = 60;
|
||||
for (;;) {
|
||||
await new Promise(function (res) { setTimeout(res, 1500); });
|
||||
let status;
|
||||
try {
|
||||
const r = await fetch('/api/character-generate-consistent-images/status/' + jobId);
|
||||
if (!r.ok) throw new Error('Lost track of the generation job (status ' + r.status + ')');
|
||||
status = await r.json();
|
||||
} catch (e) {
|
||||
panel.done(false);
|
||||
throw e;
|
||||
}
|
||||
const warn = status.stale_seconds > STALE_WARN_SECONDS;
|
||||
panel.update(status.stage, status.detail, status.elapsed, warn);
|
||||
if (status.done) {
|
||||
if (status.error) { panel.done(false); throw new Error(status.error); }
|
||||
panel.done(true);
|
||||
return status.result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate both visual assets through the shared-identity ComfyUI workflow.
|
||||
// The backend creates a clean identity anchor from structured book/character
|
||||
// facts. A legacy portrait is deliberately not reused because its hats,
|
||||
@ -3277,7 +3383,9 @@ async function _charAutoGenerateConsistentImages(rec, visualStyle) {
|
||||
}),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
const { job_id } = await r.json();
|
||||
const panel = _cijShowPanel(rec.name);
|
||||
const d = await _cijPollJob(job_id, panel);
|
||||
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
|
||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile);
|
||||
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null;
|
||||
|
||||
@ -195,7 +195,10 @@ async function readerImportFile(file) {
|
||||
toast('Loading PDF…', 'success');
|
||||
const loaded = await readerLoadPdfSkeleton(file);
|
||||
if (!loaded) return;
|
||||
readerShowExtractPrompt(loaded);
|
||||
readerUpdateToolbarVisibility(true);
|
||||
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
|
||||
await readerExtractPdfText(loaded);
|
||||
readerFinishLoadedDocument({ resume: false, successPrefix: 'Extracted' });
|
||||
} else {
|
||||
const text = await file.text();
|
||||
readerState.docText = text;
|
||||
@ -205,41 +208,6 @@ async function readerImportFile(file) {
|
||||
} 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();
|
||||
@ -283,7 +251,6 @@ function readerResetDoc() {
|
||||
readerState.savedId = null;
|
||||
readerState.savedAudioIdx = new Set();
|
||||
readerState.sourceUploaded = 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;
|
||||
@ -1695,7 +1662,7 @@ async function readerOpenLibraryDoc(id) {
|
||||
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; }
|
||||
|
||||
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
|
||||
readerState.savedId = id;
|
||||
readerState.sourceUploaded = true; // already on the server
|
||||
|
||||
@ -3063,8 +3063,26 @@ function paginateScript({ respectBreaks = false } = {}) {
|
||||
const TITLE_SPACE = 64; // page-1 title block allowance
|
||||
|
||||
let pageNum = 0, page = null, used = 0;
|
||||
const newPage = () => {
|
||||
pageNum++;
|
||||
let isFirstPage = true;
|
||||
|
||||
// Pre-scan for the first PDF page number to start numbering correctly
|
||||
if (respectBreaks) {
|
||||
for (const b of blocks) {
|
||||
if (b.classList.contains('reh-block-pagebreak')) {
|
||||
const i = parseInt(b.dataset.index);
|
||||
const l = rehState.lines[i];
|
||||
if (l && l.page) {
|
||||
// If the pagebreak is for page N, the content before it is N or N-1 depending on how breaks are placed.
|
||||
pageNum = Math.max(0, l.page - (b === blocks[0] ? 0 : 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newPage = (forcedNum) => {
|
||||
if (forcedNum !== undefined) pageNum = forcedNum;
|
||||
else pageNum++;
|
||||
page = document.createElement('div');
|
||||
page.className = 'reh-paper';
|
||||
const num = document.createElement('div');
|
||||
@ -3072,12 +3090,13 @@ function paginateScript({ respectBreaks = false } = {}) {
|
||||
num.textContent = pageNum + '.';
|
||||
page.appendChild(num);
|
||||
used = 0;
|
||||
if (pageNum === 1 && titleEl) {
|
||||
if (isFirstPage && titleEl) {
|
||||
const t = titleEl.cloneNode(true);
|
||||
t.style.display = '';
|
||||
page.appendChild(t);
|
||||
used += TITLE_SPACE;
|
||||
}
|
||||
isFirstPage = false;
|
||||
wrap.appendChild(page);
|
||||
};
|
||||
newPage();
|
||||
@ -3085,7 +3104,9 @@ function paginateScript({ respectBreaks = false } = {}) {
|
||||
for (const block of blocks) {
|
||||
// PDF page-break marker: always start a new page here
|
||||
if (respectBreaks && block.classList.contains('reh-block-pagebreak')) {
|
||||
newPage();
|
||||
const i = parseInt(block.dataset.index);
|
||||
const l = rehState.lines[i];
|
||||
newPage(l && l.page ? l.page : undefined);
|
||||
continue; // don't move the marker div into the page
|
||||
}
|
||||
|
||||
@ -3095,7 +3116,7 @@ function paginateScript({ respectBreaks = false } = {}) {
|
||||
const h = block.offsetHeight + (parseFloat(cs.marginTop) || 0) + (parseFloat(cs.marginBottom) || 0);
|
||||
|
||||
// If adding this block overflows the page (and page already has content), move to a new page
|
||||
if (!respectBreaks && used + h > PAGE_CONTENT && used > (pageNum === 1 ? TITLE_SPACE : 0)) {
|
||||
if (!respectBreaks && used + h > PAGE_CONTENT && used > (isFirstPage ? TITLE_SPACE : 0)) {
|
||||
newPage();
|
||||
page.appendChild(block);
|
||||
}
|
||||
@ -3804,6 +3825,55 @@ function stopPlay() {
|
||||
|
||||
function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; }
|
||||
|
||||
let _rehPrefetchRunning = false;
|
||||
async function _rehPrefetchLines(fromIndex) {
|
||||
if (_rehPrefetchRunning) return;
|
||||
_rehPrefetchRunning = true;
|
||||
try {
|
||||
let prefetched = 0;
|
||||
for (let i = fromIndex; i < rehState.lines.length && prefetched < 2; i++) {
|
||||
if (!rehState.playing) break;
|
||||
const l = rehState.lines[i];
|
||||
if (!l || l.ignored || l.hidden || l.type === 'pagebreak') continue;
|
||||
|
||||
const isDialog = l.type === 'dialog';
|
||||
const cast = isDialog ? (rehState.cast[l.speaker] || { voice: '' }) : null;
|
||||
if (isDialog && (!cast.voice || cast.voice === 'me')) continue;
|
||||
if (!isDialog && (!rehState.narratorVoice || !(l.text || '').trim() || rehState.skipDescriptions)) continue;
|
||||
|
||||
if (rehState.synthCache.has(i) && !rehState.staleLines.has(i)) {
|
||||
prefetched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const voice = isDialog ? cast.voice : rehState.narratorVoice;
|
||||
const cleanTxt = stripMarkdown(l.text);
|
||||
const instruct = isDialog ? _buildInstruct((cast.instruct || '').trim(), l.emotion, voice) : '';
|
||||
const text = isDialog ? _rehInlineTone(cleanTxt, l.emotion) : cleanTxt;
|
||||
|
||||
const book = _lineAudioBookName();
|
||||
const cacheKey = await _lineAudioCacheKey(text, voice, instruct);
|
||||
let blob = await _lineAudioCacheGet(book, cacheKey);
|
||||
|
||||
if (!blob) {
|
||||
try {
|
||||
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
|
||||
_lineAudioCachePut(book, cacheKey, blob);
|
||||
} catch(e) {
|
||||
console.warn('Prefetch failed for line', i, e);
|
||||
continue; // skip counting as prefetched if it failed
|
||||
}
|
||||
}
|
||||
|
||||
rehState.synthCache.set(i, blob);
|
||||
rehState.staleLines.delete(i);
|
||||
_markSynthDot(i, 'ok');
|
||||
prefetched++;
|
||||
}
|
||||
} finally {
|
||||
_rehPrefetchRunning = false;
|
||||
}
|
||||
}
|
||||
async function playNextLine() {
|
||||
if (!rehState.playing) return;
|
||||
|
||||
@ -3879,6 +3949,8 @@ async function playNextLine() {
|
||||
const line = rehState.lines[rehState.lineIndex];
|
||||
highlightCurrentLine();
|
||||
|
||||
_rehPrefetchLines(myLineIndex + 1).catch(e => console.warn('Prefetch error:', e));
|
||||
|
||||
// Non-dialog lines
|
||||
if (line.type !== 'dialog') {
|
||||
// Any line with text can be narrated — direction/transition/scene/act/action all included
|
||||
|
||||
@ -1,25 +1,46 @@
|
||||
// ── TTS routing ──────────────────────────────────────────────────────────
|
||||
|
||||
let _ttsRoutes = [];
|
||||
// Any / Auto first (they are the routing-specific values), then every language
|
||||
// from the shared list in utils.js — the old hardcoded eight could not express
|
||||
// a route for anything else (reported live: a Filipino route had to be left on
|
||||
// "Auto"). Kept as a <select> rather than the free-text combobox used for voice
|
||||
// naming, deliberately: a route's language is matched EXACTLY against the
|
||||
// detected code, so a typo here would not look broken — the rule would simply
|
||||
// never match and traffic would fall through to another route. That is the same
|
||||
// class of silent misrouting this page already had to have fixed once.
|
||||
const ROUTE_LANGS = [
|
||||
['*', 'Any'],
|
||||
['AUTO', 'Auto'],
|
||||
['EN', 'English'],
|
||||
['DE', 'German'],
|
||||
['FR', 'French'],
|
||||
['ES', 'Spanish'],
|
||||
['IT', 'Italian'],
|
||||
['PT', 'Portuguese'],
|
||||
['NL', 'Dutch'],
|
||||
['PL', 'Polish'],
|
||||
...(typeof LANG_CODE_OPTIONS !== 'undefined' && Array.isArray(LANG_CODE_OPTIONS)
|
||||
? LANG_CODE_OPTIONS
|
||||
: [['EN','English'],['DE','German'],['FR','French'],['ES','Spanish'],
|
||||
['IT','Italian'],['PT','Portuguese'],['NL','Dutch'],['PL','Polish']]),
|
||||
];
|
||||
|
||||
// Status is shown in two places (inside the collapsible card, and in the page
|
||||
// header) so a pending/unsaved state stays visible when the card is closed.
|
||||
function _routingSetStatus(text) {
|
||||
['routing-status', 'routing-status-head'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = text;
|
||||
});
|
||||
}
|
||||
|
||||
function routeSelectOptions(value) {
|
||||
return ROUTE_LANGS.map(([code, label]) =>
|
||||
`<option value="${code}" ${code === value ? 'selected' : ''}>${escHtml(label)}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Upgrade every route-language <select> into the searchable flag dropdown.
|
||||
// Called after each re-render because the rows are rebuilt via innerHTML, which
|
||||
// discards the enhanced wrappers along with them.
|
||||
function enhanceRouteLangSelects() {
|
||||
if (typeof enhanceLangSelect !== 'function') return; // utils.js unavailable -> plain selects still work
|
||||
document.querySelectorAll('#routing-list .route-lang').forEach(enhanceLangSelect);
|
||||
}
|
||||
|
||||
const ROUTE_BACKENDS = [
|
||||
['voice_clone', 'Voice Clone'],
|
||||
['streaming', 'Streaming'],
|
||||
@ -241,7 +262,7 @@ function renderRoutingList() {
|
||||
refreshRoutingVoiceOptions();
|
||||
$('routing-proxy-url').textContent = getCreatorV1Url();
|
||||
updateCreatorUrlHints();
|
||||
$('routing-status').textContent = _ttsRoutes.length ? `${_ttsRoutes.length} route${_ttsRoutes.length === 1 ? '' : 's'}` : 'No routes yet.';
|
||||
_routingSetStatus(_ttsRoutes.length ? `${_ttsRoutes.length} route${_ttsRoutes.length === 1 ? '' : 's'}` : 'No routes yet.');
|
||||
if (!_ttsRoutes.length) {
|
||||
$('routing-list').innerHTML = '<div class="note" style="padding:10px">No routing rules yet. Add a route or add the Open WebUI default examples.</div>';
|
||||
return;
|
||||
@ -274,6 +295,7 @@ function renderRoutingList() {
|
||||
<button class="btn-secondary routing-delete" title="Delete route">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
enhanceRouteLangSelects();
|
||||
// Same searchable avatar dropdown used for voice selection elsewhere in the
|
||||
// app — the field itself stays a free-text input (routes can also target
|
||||
// vd_ Voice Design presets, which aren't part of the voice library).
|
||||
@ -304,7 +326,7 @@ async function loadRoutingTab() {
|
||||
if (!$('routing-list')) return;
|
||||
$('routing-proxy-url').textContent = getCreatorV1Url();
|
||||
updateCreatorUrlHints();
|
||||
$('routing-status').textContent = 'Loading routing…';
|
||||
_routingSetStatus('Loading routing…');
|
||||
$('routing-list').innerHTML = loadingMarkup('Loading routing', 'Loading active voices and routing rules for the proxy.', 5);
|
||||
setBusyButton('routing-refresh-btn', true);
|
||||
// Load voices + sounds in parallel (non-fatal — routing still works without them)
|
||||
@ -321,7 +343,7 @@ async function loadRoutingTab() {
|
||||
status('Routing loaded');
|
||||
loadRoutingLog();
|
||||
} catch(e) {
|
||||
$('routing-status').textContent = 'Load failed';
|
||||
_routingSetStatus('Load failed');
|
||||
$('routing-list').innerHTML = `<div style="color:var(--red);padding:10px">Failed to load routes: ${escHtml(e.message)}</div>`;
|
||||
status('Routing load failed');
|
||||
} finally {
|
||||
@ -331,7 +353,7 @@ async function loadRoutingTab() {
|
||||
|
||||
async function saveRoutingTab() {
|
||||
readRoutingForm();
|
||||
$('routing-save-btn').disabled = true;
|
||||
['routing-save-btn','routing-save-btn-head'].forEach(id => { const b = $(id); if (b) b.disabled = true; });
|
||||
try {
|
||||
const r = await fetch('/api/tts-routes', {
|
||||
method:'POST',
|
||||
@ -347,7 +369,7 @@ async function saveRoutingTab() {
|
||||
} catch(e) {
|
||||
toast('Save routes failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
$('routing-save-btn').disabled = false;
|
||||
['routing-save-btn','routing-save-btn-head'].forEach(id => { const b = $(id); if (b) b.disabled = false; });
|
||||
}
|
||||
}
|
||||
|
||||
@ -554,6 +576,12 @@ $('routing-add-openwebui-btn')?.addEventListener('click', () => {
|
||||
renderRoutingList();
|
||||
});
|
||||
$('routing-save-btn')?.addEventListener('click', saveRoutingTab);
|
||||
|
||||
// The card holding the original toolbar is collapsible, so Add/Save are
|
||||
// duplicated into the page header where they are always reachable. Both copies
|
||||
// share one handler rather than each having their own, so they can never drift.
|
||||
$('routing-save-btn-head')?.addEventListener('click', saveRoutingTab);
|
||||
$('routing-add-btn-head')?.addEventListener('click', () => $('routing-add-btn')?.click());
|
||||
$('routing-test-btn')?.addEventListener('click', testRouting);
|
||||
$('routing-log-refresh-btn')?.addEventListener('click', loadRoutingLog);
|
||||
$('routing-log-clear-btn')?.addEventListener('click', clearRoutingLog);
|
||||
|
||||
@ -894,17 +894,84 @@ $('helper-apply-btn')?.addEventListener('click', () => {
|
||||
const list = document.getElementById('global-dir-browser-list');
|
||||
const pathEl = document.getElementById('global-dir-browser-path');
|
||||
const selectBtn = document.getElementById('global-dir-browser-select');
|
||||
const newFolderBtn = document.getElementById('global-dir-browser-newfolder-btn');
|
||||
const newFolderRow = document.getElementById('global-dir-browser-newfolder-row');
|
||||
const newFolderInput = document.getElementById('global-dir-browser-newfolder-input');
|
||||
const newFolderCancel = document.getElementById('global-dir-browser-newfolder-cancel');
|
||||
const newFolderCreate = document.getElementById('global-dir-browser-newfolder-create');
|
||||
if (!modal || !crumb || !list || !pathEl || !selectBtn) return;
|
||||
|
||||
let currentTargetInput = null;
|
||||
let currentPath = '/';
|
||||
let currentIsPersisted = false;
|
||||
// Set from the triggering button's data-mounted-only="1" — a folder picked
|
||||
// inside a running container only means anything to the user if it's on a
|
||||
// real bind/volume mount: otherwise it's gone the moment the container is
|
||||
// recreated, and was never visible from their host machine to begin with
|
||||
// (confirmed live: reported as a real problem for the audiobook export
|
||||
// dialog's "Save Location" field, whose Browse button sets this).
|
||||
let currentMountedOnly = false;
|
||||
const hintEl = modal.querySelector('.vef-mount-hint');
|
||||
|
||||
function closeNewFolderRow() {
|
||||
if (newFolderRow) newFolderRow.hidden = true;
|
||||
if (newFolderInput) newFolderInput.value = '';
|
||||
}
|
||||
|
||||
if (newFolderBtn) {
|
||||
newFolderBtn.addEventListener('click', () => {
|
||||
if (!newFolderRow) return;
|
||||
newFolderRow.hidden = !newFolderRow.hidden;
|
||||
if (!newFolderRow.hidden && newFolderInput) { newFolderInput.value = ''; newFolderInput.focus(); }
|
||||
});
|
||||
}
|
||||
if (newFolderCancel) newFolderCancel.addEventListener('click', closeNewFolderRow);
|
||||
async function createFolder() {
|
||||
const name = (newFolderInput?.value || '').trim();
|
||||
if (!name) { newFolderInput?.focus(); return; }
|
||||
if (newFolderCreate) newFolderCreate.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/browse-dirs/mkdir', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: currentPath, name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { toast('Could not create folder: ' + (data.detail || res.statusText), 'error'); return; }
|
||||
closeNewFolderRow();
|
||||
await navigateTo(data.path);
|
||||
} catch (e) {
|
||||
toast('Could not create folder: ' + (e.message || e), 'error');
|
||||
} finally {
|
||||
if (newFolderCreate) newFolderCreate.disabled = false;
|
||||
}
|
||||
}
|
||||
if (newFolderCreate) newFolderCreate.addEventListener('click', createFolder);
|
||||
if (newFolderInput) newFolderInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); createFolder(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); closeNewFolderRow(); }
|
||||
});
|
||||
|
||||
async function navigateTo(path) {
|
||||
currentPath = path;
|
||||
list.innerHTML = '<span class="vef-loading" style="padding:8px; color:var(--text-muted);">Loading…</span>';
|
||||
pathEl.textContent = path;
|
||||
selectBtn.disabled = false;
|
||||
if (hintEl) hintEl.hidden = true;
|
||||
closeNewFolderRow();
|
||||
try {
|
||||
const data = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path)).then(r => r.json());
|
||||
const res = await fetch('/api/browse-dirs?path=' + encodeURIComponent(path));
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
// The target input's current value seeded this path (e.g. a relative
|
||||
// default like "exports/" that hasn't been created inside the
|
||||
// container yet) — /api/browse-dirs 404s on that, and data.path is
|
||||
// absent, not an unhelpful raw TypeError (confirmed live: the
|
||||
// audiobook export dialog's "Save Location" field browse button hit
|
||||
// exactly this). Fall back to root instead.
|
||||
if (path !== '/') return navigateTo('/');
|
||||
list.innerHTML = `<span class="vef-error" style="padding:8px; color:var(--color-danger); display:block;">Error: ${escHtml(data.detail || res.statusText)}</span>`;
|
||||
return;
|
||||
}
|
||||
const parts = data.path.split('/').filter(Boolean);
|
||||
const crumbs = [{ label: '/', path: '/' }];
|
||||
parts.forEach((p, i) => crumbs.push({ label: p, path: '/' + parts.slice(0, i + 1).join('/') }));
|
||||
@ -914,15 +981,38 @@ $('helper-apply-btn')?.addEventListener('click', () => {
|
||||
: `<span class="vef-crumb-cur" style="padding:2px;">${escHtml(c.label)}</span>`
|
||||
).join('');
|
||||
crumb.querySelectorAll('.vef-crumb-btn').forEach(b => b.addEventListener('click', () => navigateTo(b.dataset.path)));
|
||||
|
||||
if (!data.dirs || !data.dirs.length) {
|
||||
list.innerHTML = '<span class="vef-empty" style="padding:8px; color:var(--text-muted); display:block;">No subdirectories here.</span>';
|
||||
|
||||
const persistedSet = new Set(data.persisted_dirs || []);
|
||||
// Mounted-only: list only folders worth entering — either a real
|
||||
// persisted destination, or a plain waypoint (e.g. /home, or a dotdir
|
||||
// like .config) that leads to one further down. Everything else in
|
||||
// `data.dirs` is filtered out rather than just greyed out, per the
|
||||
// explicit restrict-to-mounted choice this mode implements.
|
||||
const dirNames = currentMountedOnly ? (data.reachable_dirs || []) : (data.dirs || []);
|
||||
if (currentMountedOnly && hintEl) {
|
||||
hintEl.hidden = false;
|
||||
hintEl.textContent = data.is_persisted
|
||||
? 'This folder is saved outside the container and visible on your host.'
|
||||
: 'Keep browsing — folders marked 📌 below persist outside the container; this one doesn\'t.';
|
||||
}
|
||||
selectBtn.disabled = currentMountedOnly && !data.is_persisted;
|
||||
currentIsPersisted = !!data.is_persisted;
|
||||
// A new folder created inside a non-persisted waypoint (e.g. /home, on
|
||||
// the way to a mounted config dir further down) would just be more
|
||||
// ephemeral container storage — only offer it once already standing
|
||||
// inside a real persisted location.
|
||||
if (newFolderBtn) newFolderBtn.disabled = currentMountedOnly && !currentIsPersisted;
|
||||
|
||||
if (!dirNames.length) {
|
||||
list.innerHTML = `<span class="vef-empty" style="padding:8px; color:var(--text-muted); display:block;">${currentMountedOnly ? 'No persisted subfolders reachable from here.' : 'No subdirectories here.'}</span>`;
|
||||
} else {
|
||||
list.innerHTML = data.dirs.map(d =>
|
||||
`<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}" style="display:flex; width:100%; text-align:left; background:none; border:none; padding:6px 8px; border-radius:4px; cursor:pointer; color:var(--text); align-items:center; gap:8px;">
|
||||
<span class="mdi mdi-folder-outline" style="color:var(--text-muted);"></span> ${escHtml(d)}
|
||||
</button>`
|
||||
).join('');
|
||||
list.innerHTML = dirNames.map(d => {
|
||||
const persisted = persistedSet.has(d);
|
||||
const pin = currentMountedOnly ? `<span title="${persisted ? 'Persists outside the container' : 'Container-only — keep browsing'}" style="opacity:${persisted ? '1' : '.35'}">📌</span>` : '';
|
||||
return `<button class="vef-dir-item" data-path="${escHtml(data.path === '/' ? '/' + d : data.path + '/' + d)}" style="display:flex; width:100%; text-align:left; background:none; border:none; padding:6px 8px; border-radius:4px; cursor:pointer; color:var(--text); align-items:center; gap:8px;">
|
||||
<span class="mdi mdi-folder-outline" style="color:var(--text-muted);"></span> ${escHtml(d)} ${pin}
|
||||
</button>`;
|
||||
}).join('');
|
||||
list.querySelectorAll('.vef-dir-item').forEach(b => {
|
||||
b.addEventListener('click', () => navigateTo(b.dataset.path));
|
||||
b.addEventListener('mouseover', () => b.style.background = 'var(--bg-hover)');
|
||||
@ -941,6 +1031,7 @@ $('helper-apply-btn')?.addEventListener('click', () => {
|
||||
if (!btn) return;
|
||||
currentTargetInput = document.getElementById(btn.dataset.target);
|
||||
if (!currentTargetInput) return;
|
||||
currentMountedOnly = btn.dataset.mountedOnly === '1';
|
||||
const startPath = currentTargetInput.value.trim() || currentTargetInput.placeholder || '/';
|
||||
modal.hidden = false;
|
||||
navigateTo(startPath);
|
||||
|
||||
@ -375,6 +375,27 @@ document.getElementById('stu-change-book-btn')?.addEventListener('click', functi
|
||||
showStudioPhase(1);
|
||||
});
|
||||
|
||||
// Reader hides #reader-dropzone/#reader-import-area once a document loads
|
||||
// (see reader.js's readerFinishLoadedDocument/readerOpenLibraryDoc) — correct
|
||||
// for its own single-document flow, but leaves Studio with no way to add a
|
||||
// book beyond whichever one auto-resumed. Reveal them again on demand; the
|
||||
// actual import (readerImportFile/readerImportPastedText) resets the loaded
|
||||
// document itself once the user picks a file or pastes text.
|
||||
// Lives in the tab row so it's reachable from any phase — jump to Source
|
||||
// first (showStudioPhase borrows #reader-main-view into the DOM if it
|
||||
// hasn't been already) before revealing the controls inside it.
|
||||
document.getElementById('stu-add-book-btn')?.addEventListener('click', function () {
|
||||
showStudioPhase(1);
|
||||
const dz = document.getElementById('reader-dropzone'); if (dz) dz.hidden = false;
|
||||
const importArea = document.getElementById('reader-import-area'); if (importArea) importArea.hidden = false;
|
||||
const card = document.getElementById('reader-config-card');
|
||||
if (card) {
|
||||
const body = card.querySelector('.card-col-body');
|
||||
if (body && body.hidden) { body.hidden = false; card.classList.remove('card-col-closed'); }
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
|
||||
// The borrowed panel's own footer (#ab-cv-foot: "Identify Characters ⌄" /
|
||||
// "Cast Characters ⌄" / "Cast ⌄") is a richer, real action bar (Scan/Verify,
|
||||
// Cast all/Continue uncasted/Cast selected/recast, view/export) that Studio's
|
||||
@ -560,6 +581,12 @@ function studioOnShow() {
|
||||
const resumeBookId = resume.bookId;
|
||||
const hasCurrentBook = !!(window.readerState?.sentences?.length && window.readerState?.savedId);
|
||||
if (resumeBookId && !hasCurrentBook && typeof window.readerOpenLibraryDoc === 'function') {
|
||||
// Show the Source tab (paste/dropzone + the reload's own progress pill)
|
||||
// right away instead of leaving it blank while the resumed book reloads
|
||||
// in the background — #reader-main-view only lives in the visible DOM
|
||||
// once borrowed, and large PDFs (with OCR headings) can take a while
|
||||
// (confirmed live: a 297-page book left the tab empty for 6+ seconds).
|
||||
showStudioPhase(_stuActive);
|
||||
window.readerOpenLibraryDoc(resumeBookId).then(function () {
|
||||
showStudioPhase(_stuActive);
|
||||
});
|
||||
|
||||
@ -126,7 +126,8 @@ function statusActiveStt() {
|
||||
try {
|
||||
all = Array.isArray(_sttBackends) ? _sttBackends : [];
|
||||
} catch (_) {}
|
||||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
|
||||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value
|
||||
|| (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || '';
|
||||
const available = all.filter(b => b.available);
|
||||
const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null;
|
||||
return {
|
||||
@ -264,10 +265,19 @@ async function _statusOpenLlmFlyup(anchor) {
|
||||
|
||||
function _statusOpenSttFlyup(anchor) {
|
||||
const all = Array.isArray(_sttBackends) ? _sttBackends : [];
|
||||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
|
||||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value
|
||||
|| (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || '';
|
||||
_statusFlyupList(anchor, all.filter(b => b.available).map(b => ({
|
||||
id: b.id, label: b.label || b.id, active: b.id === preferred,
|
||||
})), (picked) => {
|
||||
// Persisting this (not just pushing it into a few unrelated <select>s on
|
||||
// other pages) is what makes the pick actually take effect anywhere that
|
||||
// resolves the STT backend server-side without an explicit one of its
|
||||
// own — e.g. the Voice Library's "Re-recognise text" — which otherwise
|
||||
// silently kept using the stale legacy "configured" Whisper URL no
|
||||
// matter what was chosen here (confirmed live).
|
||||
if (typeof _appSettings !== 'undefined') _appSettings.stt_preferred_backend = picked;
|
||||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stt_preferred_backend: picked }) }).catch(() => {});
|
||||
_statusApplyToSelects(['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'], picked);
|
||||
updateStatusBar();
|
||||
toast('STT backend set to ' + picked, 'success');
|
||||
@ -1162,3 +1172,214 @@ function _wfUpdateHeaderNav(anyState, curIdx) {
|
||||
window.onRehearserPhaseChange = function (n) {
|
||||
refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser');
|
||||
};
|
||||
|
||||
// ── Language code combobox ──────────────────────────────────────────────────
|
||||
// The language pickers used to be <select>s with ~17 hardcoded codes, so any
|
||||
// language outside that list simply could not be entered (reported live:
|
||||
// Filipino). A fixed list will always be missing something, so these are now
|
||||
// editable comboboxes: type to search, or type a code that isn't listed at all.
|
||||
// One shared <datalist> is injected once here rather than duplicated into each
|
||||
// section fragment, which would mean three copies of the same element id.
|
||||
// ISO 639-1 language codes, with regional variants expressed as BCP 47
|
||||
// subtags (EN-GB, DE-AT, PT-BR) rather than bare country codes. That removes
|
||||
// the namespace collision the earlier attempt created: a bare "CA" cannot mean
|
||||
// both Canada and Catalan, and a bare "AR" cannot mean both Argentina and
|
||||
// Arabic — under BCP 47 those are EN-CA and ES-AR, while CA/AR keep their ISO
|
||||
// meanings. core/routing.py collapses any variant onto its base language.
|
||||
const LANG_CODE_OPTIONS = [
|
||||
['EN','English'],
|
||||
['EN-GB','English · British'],
|
||||
['EN-US','English · American'],
|
||||
['EN-AU','English · Australian'],
|
||||
['EN-CA','English · Canadian'],
|
||||
['EN-IE','English · Irish'],
|
||||
['EN-SCT','English · Scottish'],
|
||||
['EN-NZ','English · New Zealand'],
|
||||
['EN-ZA','English · South African'],
|
||||
['EN-IN','English · Indian'],
|
||||
['DE','German · Deutsch'],['DE-AT','German · Austrian'],['DE-CH','German · Swiss'],
|
||||
['FR','French · Français'],['FR-CA','French · Québécois'],
|
||||
['ES','Spanish · Español'],['ES-MX','Spanish · Mexican'],['ES-AR','Spanish · Argentinian'],
|
||||
['IT','Italian · Italiano'],
|
||||
['PT','Portuguese · Português'],['PT-BR','Portuguese · Brazilian'],
|
||||
['NL','Dutch · Nederlands'],['PL','Polish · Polski'],['RU','Russian · Русский'],['UK','Ukrainian'],
|
||||
['CS','Czech'],['SK','Slovak'],['SL','Slovenian'],['HR','Croatian'],['SR','Serbian'],['BG','Bulgarian'],
|
||||
['RO','Romanian'],['HU','Hungarian'],['EL','Greek'],['TR','Turkish · Türkçe'],
|
||||
['SV','Swedish · Svenska'],['NO','Norwegian'],['DA','Danish'],['FI','Finnish'],['IS','Icelandic'],
|
||||
['ET','Estonian'],['LV','Latvian'],['LT','Lithuanian'],
|
||||
['ZH','Chinese · 中文'],['JA','Japanese · 日本語'],['KO','Korean · 한국어'],
|
||||
['TL','Filipino · Tagalog'],['ID','Indonesian'],['MS','Malay'],['VI','Vietnamese'],
|
||||
['TH','Thai'],['KM','Khmer'],['MY','Burmese'],['LO','Lao'],
|
||||
['HI','Hindi'],['BN','Bengali'],['UR','Urdu'],['PA','Punjabi'],['TA','Tamil'],['TE','Telugu'],
|
||||
['ML','Malayalam'],['KN','Kannada'],['MR','Marathi'],['GU','Gujarati'],['NE','Nepali'],['SI','Sinhala'],
|
||||
['AR','Arabic · العربية'],['HE','Hebrew'],['FA','Persian · Farsi'],['KU','Kurdish'],
|
||||
['SW','Swahili'],['AM','Amharic'],['HA','Hausa'],['YO','Yoruba'],['IG','Igbo'],['ZU','Zulu'],['AF','Afrikaans'],
|
||||
['CA','Catalan'],['EU','Basque'],['GL','Galician'],['CY','Welsh'],['GA','Irish'],['MT','Maltese'],
|
||||
['SQ','Albanian'],['MK','Macedonian'],['KA','Georgian'],['HY','Armenian'],['AZ','Azerbaijani'],
|
||||
['KK','Kazakh'],['UZ','Uzbek'],['MN','Mongolian'],['LA','Latin'],
|
||||
];
|
||||
|
||||
function ensureLangDatalist() {
|
||||
if (document.getElementById('lang-codes')) return;
|
||||
const dl = document.createElement('datalist');
|
||||
dl.id = 'lang-codes';
|
||||
dl.innerHTML = LANG_CODE_OPTIONS
|
||||
.map(([code, name]) => `<option value="${code}">${escHtml(name)}</option>`).join('');
|
||||
document.body.appendChild(dl);
|
||||
}
|
||||
|
||||
// The code becomes part of the voice ID (EN_F_Anna), so a free-text field has
|
||||
// to be constrained to something that can live in a filename: letters only,
|
||||
// upper-cased, and short enough to stay readable as a prefix.
|
||||
function normalizeLangCodeInput(el) {
|
||||
if (!el) return;
|
||||
// Allows a BCP 47 subtag (EN-GB) as well as a bare code, since regional
|
||||
// accents are now expressed that way.
|
||||
const clean = String(el.value || '').replace(/[^A-Za-z-]/g, '').toUpperCase().slice(0, 8);
|
||||
if (clean !== el.value) el.value = clean;
|
||||
}
|
||||
|
||||
document.addEventListener('input', (e) => {
|
||||
const t = e.target;
|
||||
if (t && t.matches && t.matches('input[list="lang-codes"]')) normalizeLangCodeInput(t);
|
||||
});
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureLangDatalist);
|
||||
else ensureLangDatalist();
|
||||
|
||||
// Language code -> country code for the flag emoji. LANG_FLAG_DEFAULT only
|
||||
// covered the original 16 languages; this extends the mapping to everything in
|
||||
// LANG_CODE_OPTIONS so no entry falls back to a bare globe.
|
||||
const LANG_CC = {
|
||||
// BCP 47 regional subtags — the region decides the flag.
|
||||
'EN-GB':'GB','EN-US':'US','EN-AU':'AU','EN-CA':'CA','EN-IE':'IE','EN-SCT':'GB',
|
||||
'EN-NZ':'NZ','EN-ZA':'ZA','EN-IN':'IN','DE-AT':'AT','DE-CH':'CH','FR-CA':'CA',
|
||||
'ES-MX':'MX','ES-AR':'AR','PT-BR':'BR',
|
||||
EN:'GB', DE:'DE', FR:'FR', ES:'ES', IT:'IT', PT:'BR', NL:'NL', PL:'PL', RU:'RU', UK:'UA',
|
||||
CS:'CZ', SK:'SK', SL:'SI', HR:'HR', SR:'RS', BG:'BG', RO:'RO', HU:'HU', EL:'GR', TR:'TR',
|
||||
SV:'SE', NO:'NO', DA:'DK', FI:'FI', IS:'IS', ET:'EE', LV:'LV', LT:'LT',
|
||||
ZH:'CN', JA:'JP', KO:'KR', TL:'PH', ID:'ID', MS:'MY', VI:'VN', TH:'TH', KM:'KH', MY:'MM', LO:'LA',
|
||||
HI:'IN', BN:'BD', UR:'PK', PA:'IN', TA:'IN', TE:'IN', ML:'IN', KN:'IN', MR:'IN', GU:'IN', NE:'NP', SI:'LK',
|
||||
AR:'SA', HE:'IL', FA:'IR', KU:'IQ', SW:'KE', AM:'ET', HA:'NG', YO:'NG', IG:'NG', ZU:'ZA', AF:'ZA',
|
||||
CA:'ES', EU:'ES', GL:'ES', CY:'GB', GA:'IE', MT:'MT', SQ:'AL', MK:'MK', KA:'GE', HY:'AM', AZ:'AZ',
|
||||
KK:'KZ', UZ:'UZ', MN:'MN', LA:'VA',
|
||||
};
|
||||
|
||||
const LANG_NAME_CC = {
|
||||
english:'GB', german:'DE', french:'FR', spanish:'ES', italian:'IT', portuguese:'BR',
|
||||
dutch:'NL', polish:'PL', russian:'RU', chinese:'CN', japanese:'JP', korean:'KR',
|
||||
arabic:'SA', hindi:'IN', turkish:'TR', swedish:'SE', filipino:'PH', tagalog:'PH',
|
||||
};
|
||||
|
||||
function langFlag(code) {
|
||||
const c = String(code || '').toUpperCase();
|
||||
if (c === '*' ) return '<span class="mdi mdi-asterisk"></span>';
|
||||
if (c === 'AUTO') return '<span class="mdi mdi-auto-fix"></span>';
|
||||
let cc = LANG_CC[c] || (typeof LANG_FLAG_DEFAULT !== 'undefined' ? LANG_FLAG_DEFAULT[c] : '');
|
||||
// Name-valued selects ("English", "German") resolve through their own map.
|
||||
if (!cc) cc = LANG_NAME_CC[String(code || '').trim().toLowerCase()] || '';
|
||||
// Unknown BCP 47 tag: fall back to the base language's flag (EN-XX -> EN).
|
||||
if (!cc && c.includes('-')) cc = LANG_CC[c.split('-')[0]] || '';
|
||||
return cc ? cc2flag(cc) : '<span class="mdi mdi-earth"></span>';
|
||||
}
|
||||
|
||||
// Turns a plain <select> of language codes into a searchable dropdown with
|
||||
// flags, WITHOUT replacing it: the <select> stays in the DOM as the single
|
||||
// source of truth (hidden), so every existing `.value` read/write and change
|
||||
// listener keeps working untouched. That matters here because routing reads
|
||||
// `row.querySelector('.route-lang').value` when saving — swapping in a custom
|
||||
// element would have silently broken saving rather than looking broken.
|
||||
function enhanceLangSelect(sel) {
|
||||
if (!sel || sel.dataset.enhanced === '1') return;
|
||||
sel.dataset.enhanced = '1';
|
||||
const opts = [...sel.options].map(o => ({ value: o.value, label: o.textContent.trim() }));
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'langsel';
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'langsel-btn';
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'langsel-pop';
|
||||
pop.hidden = true;
|
||||
pop.innerHTML = `<div class="langsel-search"><span class="mdi mdi-magnify"></span>`
|
||||
+ `<input type="text" placeholder="Search language…" autocomplete="off" spellcheck="false"></div>`
|
||||
+ `<div class="langsel-list"></div>`;
|
||||
sel.parentNode.insertBefore(wrap, sel);
|
||||
wrap.appendChild(btn); wrap.appendChild(pop); wrap.appendChild(sel);
|
||||
sel.classList.add('langsel-native');
|
||||
|
||||
const listEl = pop.querySelector('.langsel-list');
|
||||
const searchEl = pop.querySelector('input');
|
||||
const paint = () => {
|
||||
const cur = opts.find(o => o.value === sel.value) || opts[0] || { value: '', label: '' };
|
||||
// Show the CODE, not the name: this control sits in a narrow table column,
|
||||
// where a name truncates to something useless ("German · Deutsch" -> "G.").
|
||||
// The flag carries recognition, the code is unambiguous, and the full name
|
||||
// is on the tooltip and in the open list.
|
||||
const short = cur.value === '*' ? 'Any' : cur.value === 'AUTO' ? 'Auto' : cur.value;
|
||||
btn.innerHTML = `<span class="langsel-flag">${langFlag(cur.value)}</span>`
|
||||
+ `<span class="langsel-label">${escHtml(short)}</span><span class="mdi mdi-chevron-down"></span>`;
|
||||
btn.title = cur.label;
|
||||
};
|
||||
const renderList = (q = '') => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const shown = opts.filter(o => !needle
|
||||
|| o.label.toLowerCase().includes(needle) || o.value.toLowerCase().includes(needle));
|
||||
listEl.innerHTML = shown.length
|
||||
? shown.map(o => `<button type="button" class="langsel-opt${o.value === sel.value ? ' is-sel' : ''}" data-val="${escHtml(o.value)}">`
|
||||
+ `<span class="langsel-flag">${langFlag(o.value)}</span><span>${escHtml(o.label)}</span></button>`).join('')
|
||||
: '<div class="langsel-empty">No language matches</div>';
|
||||
};
|
||||
const close = () => { pop.hidden = true; };
|
||||
const open = () => {
|
||||
document.querySelectorAll('.langsel-pop').forEach(p => { if (p !== pop) p.hidden = true; });
|
||||
renderList(''); searchEl.value = ''; pop.hidden = false; searchEl.focus();
|
||||
};
|
||||
btn.addEventListener('click', e => { e.stopPropagation(); pop.hidden ? open() : close(); });
|
||||
searchEl.addEventListener('input', () => renderList(searchEl.value));
|
||||
searchEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') { close(); btn.focus(); }
|
||||
if (e.key === 'Enter') { const first = listEl.querySelector('.langsel-opt'); if (first) first.click(); }
|
||||
e.stopPropagation();
|
||||
});
|
||||
listEl.addEventListener('click', e => {
|
||||
const o = e.target.closest('.langsel-opt');
|
||||
if (!o) return;
|
||||
sel.value = o.dataset.val;
|
||||
// Dispatch on the real <select> so existing listeners (and save-on-change) fire.
|
||||
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
paint(); close();
|
||||
});
|
||||
sel.addEventListener('change', paint);
|
||||
paint();
|
||||
}
|
||||
|
||||
document.addEventListener('click', () => document.querySelectorAll('.langsel-pop').forEach(p => { p.hidden = true; }));
|
||||
|
||||
// Applies the searchable flag dropdown to every language picker in the app.
|
||||
// data-langsel -> fill from LANG_CODE_OPTIONS, then enhance
|
||||
// data-langsel-keep -> enhance only, keeping the options already present
|
||||
// (used where the value is a language NAME the TTS
|
||||
// backend expects, e.g. "German" — repopulating with
|
||||
// ISO codes there would break that contract)
|
||||
// Safe to call repeatedly: enhanceLangSelect no-ops on an already-enhanced
|
||||
// element, and sections are loaded as fragments at unpredictable times.
|
||||
function initLangSelects(root) {
|
||||
const scope = root && root.querySelectorAll ? root : document;
|
||||
scope.querySelectorAll('select[data-langsel]').forEach(sel => {
|
||||
if (sel.dataset.enhanced === '1') return;
|
||||
const cur = sel.value || sel.getAttribute('data-default') || 'EN';
|
||||
sel.innerHTML = LANG_CODE_OPTIONS
|
||||
.map(([c, n]) => `<option value="${c}"${c === cur ? ' selected' : ''}>${escHtml(n)}</option>`).join('');
|
||||
if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
|
||||
enhanceLangSelect(sel);
|
||||
});
|
||||
scope.querySelectorAll('select[data-langsel-keep]').forEach(enhanceLangSelect);
|
||||
}
|
||||
|
||||
// Sections arrive as fragments after load, so re-run on DOM insertions.
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
new MutationObserver(() => initLangSelects(document))
|
||||
.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initLangSelects(document));
|
||||
else initLangSelects(document);
|
||||
|
||||
@ -762,7 +762,7 @@ function _updateDVoiceId() {
|
||||
$('d-voice-id').value = name ? `${lang}_${gender}_${name}` : '';
|
||||
}
|
||||
|
||||
['d-lang','d-gender'].forEach(id => $(id).addEventListener('change', _updateDVoiceId));
|
||||
['d-lang','d-gender'].forEach(id => ['change','input'].forEach(ev => $(id).addEventListener(ev, _updateDVoiceId)));
|
||||
$('d-name').addEventListener('input', () => { _dVoiceIdManual = false; _updateDVoiceId(); });
|
||||
$('d-voice-id').addEventListener('input', () => { _dVoiceIdManual = true; });
|
||||
|
||||
@ -992,7 +992,9 @@ $('transcribe-btn').addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
// Live ID building from lang / gender / name — no "Apply" click needed
|
||||
['lang-select', 'gender-select'].forEach(id => g(id)?.addEventListener('change', buildVoiceId));
|
||||
// 'input' as well as 'change': the language field is a free-text combobox now,
|
||||
// and change alone would not update the generated ID until the field lost focus.
|
||||
['lang-select', 'gender-select'].forEach(id => ['change', 'input'].forEach(ev => g(id)?.addEventListener(ev, buildVoiceId)));
|
||||
g('name-input')?.addEventListener('input', buildVoiceId);
|
||||
// Respect a manually edited final ID (only real keystrokes set the manual flag)
|
||||
g('voice-id-input')?.addEventListener('input', e => { if (e.isTrusted) _idManual = true; scheduleAutoSave(); });
|
||||
|
||||
@ -251,9 +251,18 @@ function selectVoice(wrap) {
|
||||
const val = nameEditEl.value.trim();
|
||||
if (mode === 'id') {
|
||||
if (!val || val === voiceId) return;
|
||||
const rowInput = wrap.querySelector('.vr-name-input');
|
||||
const rowOk = wrap.querySelector('.rename-ok');
|
||||
// selectVoice() physically moves .vr-main-row (which contains these
|
||||
// rename controls) out of `wrap` and into #voices-inspector once a
|
||||
// voice is open in the single-voice detail view — the same relocation
|
||||
// already documented as the cause of a similar stale-`wrap` bug for
|
||||
// the benchmark cell (see runBenchmark's fallback in voice-library.js).
|
||||
// By the time "rename filename" is clicked here, `wrap` is the now-
|
||||
// emptied original list container, so wrap.querySelector always
|
||||
// returned null and the rename silently did nothing (confirmed live).
|
||||
const rowInput = wrap.querySelector('.vr-name-input') || document.querySelector('#voices-inspector .vr-name-input');
|
||||
const rowOk = wrap.querySelector('.rename-ok') || document.querySelector('#voices-inspector .rename-ok');
|
||||
if (rowInput && rowOk) { rowInput.value = val; rowOk.click(); }
|
||||
else if (typeof toast === 'function') toast('Rename control not found — try renaming from the voice list instead', 'error');
|
||||
return;
|
||||
}
|
||||
// mode === 'name' → save a display name to the voice's metadata
|
||||
|
||||
@ -1342,7 +1342,7 @@ $('add-new-voice-btn').addEventListener('click', () => {
|
||||
$('lib-add-panel').classList.toggle('open');
|
||||
});
|
||||
$('lib-add-sample-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-sample-lang').value));
|
||||
$('lib-add-lang').addEventListener('change', () => updateLibAddSampleLanguage($('lib-add-lang').value));
|
||||
['change','input'].forEach(ev => $('lib-add-lang').addEventListener(ev, () => updateLibAddSampleLanguage($('lib-add-lang').value)));
|
||||
$('lib-add-sample-text').addEventListener('input', debounce(saveLibAddSampleText, 500));
|
||||
$('lib-add-use-sample').addEventListener('click', () => {
|
||||
$('lib-add-transcript').value = $('lib-add-sample-text').value.trim();
|
||||
@ -2144,7 +2144,15 @@ function makeVoiceRow(v) {
|
||||
: `<span style="font-size:12px;color:var(--subtext);padding:3px 6px">No regional variants</span>`;
|
||||
|
||||
// Compact list item elements (visible in list pane, hidden in inspector)
|
||||
const isClone = v.has_ref && v.origin !== 'designed';
|
||||
// Whether a voice plays through voice_clone vs voice_design should depend
|
||||
// on whether it HAS a reference clip right now, not on how it was first
|
||||
// created — a voice originally made via Voice Design that has since had a
|
||||
// real reference WAV + transcript attached is fully clone-capable, but the
|
||||
// stale 'designed' origin tag kept routing its playback to the (often
|
||||
// unreachable) Voice Design service forever regardless, producing a
|
||||
// confusing 404 for a voice that clearly has reference audio in its own
|
||||
// panel (confirmed live).
|
||||
const isClone = !!v.has_ref;
|
||||
const initial = v.id[0] ? v.id[0].toUpperCase() : '?';
|
||||
const flagIconHtml = currentFlag
|
||||
? `<span class="fi fi-${currentFlag.toLowerCase()}" role="img" aria-label="${langCode}"></span>`
|
||||
|
||||
@ -197,7 +197,14 @@ const SOURCE_LANGUAGE_CODES = {
|
||||
};
|
||||
function sourceLanguageCode(language) {
|
||||
const raw = String(language || '').trim();
|
||||
if (/^[A-Z]{2}$/.test(raw)) return raw;
|
||||
// ElevenLabs' API returns language as a lowercase ISO code ("fr", "de"),
|
||||
// not the uppercase form this used to require — the case-sensitive regex
|
||||
// rejected it, and the name-lookup table below only has full language
|
||||
// NAMES as keys ("french"), not ISO codes, so it silently fell through to
|
||||
// the 'EN' default for every non-English ElevenLabs import (confirmed
|
||||
// live: a voice tagged "FR" throughout the browse UI still imported as
|
||||
// EN_...). Match the 2-letter code regardless of case.
|
||||
if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase();
|
||||
return SOURCE_LANGUAGE_CODES[raw.toLowerCase()] || 'EN';
|
||||
}
|
||||
function sourceGenderCode(gender, name = '') {
|
||||
|
||||
@ -25,6 +25,17 @@
|
||||
system; see the Studio build plan for why. Numbered so the tab bar
|
||||
itself reads as an ordered flow, not just 4 unrelated sections. -->
|
||||
<div class="stu-subtabs" id="stu-subtabs">
|
||||
<!-- The paste/drag-drop controls in Phase 1 (borrowed from Read Aloud) hide
|
||||
themselves once a document is loaded — fine for a single-book reading
|
||||
session, but Studio resumes your last book automatically, so there was
|
||||
no visible way back to them to add another book (confirmed live:
|
||||
reported as "where's the upload area?"). This button reveals them
|
||||
again without disturbing the loaded book. Placed first in the tab row
|
||||
(not as a numbered phase) per live UI feedback, so it's visible
|
||||
without scrolling no matter which phase is open. -->
|
||||
<button type="button" class="stu-book-context-btn" id="stu-add-book-btn" style="margin-right:10px">
|
||||
<span class="mdi mdi-file-document-plus-outline"></span> Add a new book
|
||||
</button>
|
||||
<button class="stu-subtab active" data-stu-phase="1"><span class="stu-subtab-num">1</span> <span class="mdi mdi-file-document-outline"></span> <span>Source</span></button>
|
||||
<button class="stu-subtab" data-stu-phase="2"><span class="stu-subtab-num">2</span> <span class="mdi mdi-drama-masks"></span> <span>Characters</span></button>
|
||||
<button class="stu-subtab" data-stu-phase="3"><span class="stu-subtab-num">3</span> <span class="mdi mdi-account-voice"></span> <span>Voices</span></button>
|
||||
|
||||
@ -115,12 +115,7 @@
|
||||
<div class="clone-id-row">
|
||||
<div class="field">
|
||||
<label for="lang-select">Language</label>
|
||||
<select id="lang-select" aria-label="Voice language">
|
||||
<option>EN</option><option>DE</option><option>ZH</option><option>FR</option>
|
||||
<option>ES</option><option>JA</option><option>KO</option><option>IT</option>
|
||||
<option>PT</option><option>RU</option><option>AR</option><option>PL</option>
|
||||
<option>NL</option><option>SV</option><option>TR</option><option>HI</option>
|
||||
</select>
|
||||
<select id="lang-select" data-langsel data-default="EN" aria-label="Voice language" title="Search by name or code; regional accents use BCP 47 subtags (EN-GB, EN-AU)"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gender-select">Gender</label>
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Language</label>
|
||||
<select class="lang-select" id="design-language" aria-label="Voice language">
|
||||
<select class="lang-select" id="design-language" data-langsel-keep aria-label="Voice language">
|
||||
<option value="Auto">Auto-detect</option>
|
||||
<option value="English">English</option><option value="Chinese">Chinese</option>
|
||||
<option value="Japanese">Japanese</option><option value="Korean">Korean</option>
|
||||
@ -84,14 +84,7 @@
|
||||
<div class="d-naming-row">
|
||||
<div class="field">
|
||||
<label>Language</label>
|
||||
<select id="d-lang">
|
||||
<option value="EN">EN</option><option value="DE">DE</option><option value="ZH">ZH</option>
|
||||
<option value="FR">FR</option><option value="ES">ES</option><option value="JA">JA</option>
|
||||
<option value="KO">KO</option><option value="IT">IT</option><option value="PT">PT</option>
|
||||
<option value="RU">RU</option><option value="AR">AR</option><option value="PL">PL</option>
|
||||
<option value="NL">NL</option><option value="SV">SV</option><option value="TR">TR</option>
|
||||
<option value="HI">HI</option>
|
||||
</select>
|
||||
<select id="d-lang" data-langsel data-default="EN" aria-label="Voice language" title="Search by name or code; regional accents use BCP 47 subtags (EN-GB, EN-AU)"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Gender</label>
|
||||
|
||||
@ -91,25 +91,14 @@
|
||||
<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>
|
||||
<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. Only applies to PDF imports." style="margin:6px 0 0; font-size:11px">
|
||||
<input type="checkbox" id="reader-ocr-headings" checked> OCR headings
|
||||
</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">
|
||||
|
||||
@ -196,7 +196,7 @@
|
||||
<div class="engine-setup-col no-border" style="flex:0.3">
|
||||
<label class="engine-setup-label"><span class="mdi mdi-earth"></span> Language</label>
|
||||
<div class="engine-setup-controls">
|
||||
<select id="reh-design-lang" aria-label="Voice language">
|
||||
<select id="reh-design-lang" data-langsel-keep aria-label="Voice language">
|
||||
<option value="English">English</option>
|
||||
<option value="German">German</option>
|
||||
<option value="Auto">Auto</option>
|
||||
@ -567,8 +567,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Audiobook Export Modal -->
|
||||
<div id="audiobook-export-modal" class="audiobook-overlay" hidden style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;">
|
||||
<div class="audiobook-box card" style="max-width:600px; width:95%; max-height:90vh; overflow-y:auto; text-align:left; background:var(--bg-card); padding:20px; border-radius:8px;">
|
||||
<div id="audiobook-export-modal" class="audiobook-overlay" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:none;align-items:center;justify-content:center;">
|
||||
<div class="audiobook-box card" style="max-width:600px; width:95%; max-height:90vh; overflow-y:auto; text-align:left; background:var(--surface); padding:20px; border-radius:8px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||
<h3 style="margin:0; font-size:1.25em"><span class="mdi mdi-book-music-outline"></span> Export Audiobook</h3>
|
||||
<button class="btn-secondary btn-sm" id="ab-export-modal-close" style="padding:4px"><span class="mdi mdi-close"></span></button>
|
||||
@ -618,6 +618,7 @@
|
||||
<label>Save Location (Server)</label>
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-top:8px">
|
||||
<input type="text" id="ab-export-server-folder" style="flex:1" value="exports/" placeholder="Folder on server (e.g. exports/)">
|
||||
<button type="button" class="btn-secondary s-browse-dir-btn" data-target="ab-export-server-folder" data-mounted-only="1" title="Browse folders that persist outside the container"><span class="mdi mdi-folder-search-outline"></span></button>
|
||||
</div>
|
||||
<span class="s-hint">Where the files will be saved on the server. Browser downloads will also still trigger.</span>
|
||||
</div>
|
||||
|
||||
@ -4,6 +4,14 @@
|
||||
<h2>App Routing</h2>
|
||||
<p>Map incoming requests from apps like Open WebUI or SillyTavern to specific voices and backends.</p>
|
||||
</div>
|
||||
<!-- Add/Save live here rather than inside the card below, because that
|
||||
card is collapsible — collapsing it used to hide the only way to
|
||||
save, with no hint that unsaved edits were still pending. -->
|
||||
<div class="routing-head-actions">
|
||||
<span class="note" id="routing-status-head"></span>
|
||||
<button class="btn-primary" id="routing-add-btn-head"><span class="mdi mdi-plus"></span> Add route</button>
|
||||
<button class="btn-green" id="routing-save-btn-head"><span class="mdi mdi-content-save"></span> Save routes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-routing">
|
||||
@ -25,8 +33,8 @@
|
||||
</div>
|
||||
<div class="routing-toolbar">
|
||||
<button class="btn-secondary" id="routing-refresh-btn">Refresh</button>
|
||||
<button class="btn-primary" id="routing-add-btn">Add route</button>
|
||||
<button class="btn-green" id="routing-save-btn">Save routes</button>
|
||||
<button class="btn-secondary" id="routing-add-btn">Add route</button>
|
||||
<button class="btn-secondary" id="routing-save-btn">Save routes</button>
|
||||
<button class="btn-secondary" id="routing-add-openwebui-btn">Add Open WebUI default examples</button>
|
||||
<span class="note" id="routing-status">No routes loaded.</span>
|
||||
</div>
|
||||
|
||||
@ -308,11 +308,7 @@
|
||||
<div class="lib-add-section">
|
||||
<h2 style="margin-bottom:10px">2 Name and save voice</h2>
|
||||
<div class="lib-add-name-grid">
|
||||
<div class="opt-field"><label>Language</label><select id="lib-add-lang">
|
||||
<option value="EN">EN</option><option value="DE">DE</option><option value="IT">IT</option>
|
||||
<option value="ES">ES</option><option value="FR">FR</option><option value="PT">PT</option>
|
||||
<option value="NL">NL</option><option value="PL">PL</option>
|
||||
</select></div>
|
||||
<div class="opt-field"><label>Language</label><select id="lib-add-lang" data-langsel data-default="EN" aria-label="Voice language" title="Search by name or code; regional accents use BCP 47 subtags (EN-GB, EN-AU)"></select></div>
|
||||
<div class="opt-field"><label>Gender</label><input id="lib-add-gender" value="N"></div>
|
||||
<div class="opt-field"><label>Voice ID</label><input id="lib-add-voice-id" placeholder="EN_N_NewVoice"></div>
|
||||
</div>
|
||||
|
||||
195
static/style.css
195
static/style.css
@ -1965,7 +1965,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
|
||||
/* ── Routing ────────────────────────────────────────────────────────────── */
|
||||
.routing-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.routing-grid { display: grid; grid-template-columns: 42px minmax(104px,.7fr) minmax(92px,.55fr) 82px minmax(112px,.6fr) minmax(140px,1fr) 96px minmax(120px,.65fr) minmax(120px,.65fr) 42px; gap: 8px; align-items: center; }
|
||||
.routing-grid { display: grid; grid-template-columns: 42px minmax(104px,.7fr) minmax(74px,.38fr) minmax(118px,.5fr) minmax(112px,.6fr) minmax(140px,1fr) 96px minmax(120px,.65fr) minmax(120px,.65fr) 42px; gap: 8px; align-items: center; }
|
||||
/* Input voice holds short tokens (default, DE_M) so it can be narrow; the
|
||||
language cell now carries a flag plus a code and needs the room instead. */
|
||||
.route-speed-cell { display: flex; align-items: center; gap: 4px; }
|
||||
.route-speed-cell input { width: 100%; min-width: 0; text-align: center; }
|
||||
.route-speed-preview { flex-shrink: 0; width: 30px; height: 30px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
@ -7509,3 +7511,194 @@ body.ab-hide-quotes .ab-q { display: none; }
|
||||
.ab-book-context-copy { display: flex; align-items: baseline; gap: 8px; flex: 1; flex-wrap: wrap; font-size: 12px; }
|
||||
.ab-book-context-copy > span:last-child { color: var(--subtext); line-height: 1.4; }
|
||||
@media (max-width: 700px) { .ab-book-context-banner { align-items: flex-start; flex-direction: column; } }
|
||||
|
||||
/* ── Consistent-image generation status panel ────────────────────────────────
|
||||
Live progress for the "Consistent profile + reference" render, which can
|
||||
legitimately run 10-20+ minutes on a local GPU. A non-blocking corner panel
|
||||
(not a modal) so the user can keep working while it shows elapsed time and
|
||||
flags when the backend has gone quiet for too long. */
|
||||
.cij-progress-panel {
|
||||
position: fixed; right: 20px; bottom: 20px; z-index: 10000;
|
||||
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 10px 14px; min-width: 260px; max-width: 340px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.35); font-size: 0.85em;
|
||||
}
|
||||
.cij-progress-head { display: flex; align-items: center; gap: 6px; font-weight: 700; margin-bottom: 8px; }
|
||||
.cij-progress-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cij-progress-close { background: none; border: none; cursor: pointer; color: inherit; opacity: 0.7; padding: 2px; }
|
||||
.cij-progress-close:hover { opacity: 1; }
|
||||
.cij-progress-steps { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
|
||||
.cij-step { display: flex; align-items: center; gap: 6px; opacity: 0.5; }
|
||||
.cij-step.is-active, .cij-step.is-done { opacity: 1; }
|
||||
.cij-step-icon { font-size: 1.1em; }
|
||||
.cij-step.is-done .cij-step-icon { color: #4caf50; }
|
||||
.cij-step.is-active .cij-step-icon { color: var(--primary, #4a90d9); }
|
||||
.cij-progress-detail { opacity: 0.85; margin-bottom: 4px; }
|
||||
.cij-progress-elapsed { font-variant-numeric: tabular-nums; opacity: 0.7; }
|
||||
.cij-progress-panel.cij-progress-warn .cij-progress-elapsed { color: #e0a030; opacity: 1; }
|
||||
.cij-progress-panel.cij-progress-ok { border-color: #4caf50; }
|
||||
.cij-progress-panel.cij-progress-err { border-color: #e05a4e; }
|
||||
|
||||
/* ── Attribution confidence: colour-coded certainty + review navigation ──────
|
||||
Green >=80 is measured at ~95% correct, orange 50-79 at ~77%, red <50 at
|
||||
~54%. Reviewing only orange+red covers ~90% of real errors while reading
|
||||
just over half the book, which is the entire point of showing this. */
|
||||
.ab-cv-conf {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
min-width: 92px;
|
||||
margin-right: 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
cursor: help;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.ab-conf-green { background: rgba(34,160,90,.14); color: #1f8a4c; border-color: rgba(34,160,90,.35); }
|
||||
.ab-conf-orange { background: rgba(217,138,10,.16); color: #b3760a; border-color: rgba(217,138,10,.4); }
|
||||
.ab-conf-red { background: rgba(206,60,55,.14); color: #c0392b; border-color: rgba(206,60,55,.38); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ab-conf-green { color: #56d98b; }
|
||||
.ab-conf-orange { color: #f0b44a; }
|
||||
.ab-conf-red { color: #ff8178; }
|
||||
}
|
||||
|
||||
.ab-review-ctl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.ab-review-ctl select {
|
||||
font-size: 11px; padding: 2px 4px; border-radius: 4px;
|
||||
border: 1px solid var(--border); background: var(--surface); color: var(--text); outline: none;
|
||||
}
|
||||
/* The jump button carries a count, so it is wider than the icon-only nav
|
||||
buttons it sits beside and needs its own padding rather than the square
|
||||
.ab-page-nav-btn box. */
|
||||
.ab-review-ctl .ab-review-jump {
|
||||
/* A read-out, not a control — the chevrons either side do the navigating. */
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
padding: 0 6px;
|
||||
gap: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #b3760a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { .ab-review-ctl .ab-review-jump { color: #f0b44a; } }
|
||||
|
||||
/* The row the reviewer was just sent to — must stand out from the character
|
||||
highlight, which uses a left border, so this uses a full-row wash. */
|
||||
.ab-cv-row.ab-review-focus {
|
||||
background: rgba(217,138,10,.13);
|
||||
outline: 2px solid rgba(217,138,10,.5);
|
||||
outline-offset: -2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Manually-checked marker: a human verdict outranks the confidence score, so a
|
||||
checked line reads as settled and drops out of the review queue. */
|
||||
.ab-conf-checked {
|
||||
background: rgba(34,160,90,.16);
|
||||
color: #1f8a4c;
|
||||
border-color: rgba(34,160,90,.4);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) { .ab-conf-checked { color: #56d98b; } }
|
||||
|
||||
.ab-cv-check {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
margin-right: 6px;
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--subtext);
|
||||
opacity: .45;
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ab-cv-check:hover { opacity: 1; color: #1f8a4c; }
|
||||
.ab-cv-check.is-checked { opacity: 1; color: #1f8a4c; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ab-cv-check:hover, .ab-cv-check.is-checked { color: #56d98b; }
|
||||
}
|
||||
/* Dim a settled row so the eye skips it while scrolling. */
|
||||
.ab-cv-row[data-mark="ok"] { opacity: .62; }
|
||||
.ab-cv-row[data-mark="wrong"] { opacity: 1; }
|
||||
.ab-cv-row[data-mark="wrong"] .ab-cv-txt { text-decoration: underline wavy rgba(206,60,55,.55); text-underline-offset: 3px; }
|
||||
.ab-conf-wrong { background: rgba(206,60,55,.16); color: #c0392b; border-color: rgba(206,60,55,.45); }
|
||||
@media (prefers-color-scheme: dark) { .ab-conf-wrong { color: #ff8178; } }
|
||||
|
||||
/* Language code combobox: free text with a searchable datalist, so any code
|
||||
can be entered (the old <select> could not express e.g. Filipino). Kept
|
||||
narrow like the <select> it replaced so the surrounding row still lines up. */
|
||||
input[list="lang-codes"] {
|
||||
width: 88px;
|
||||
text-transform: uppercase;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Searchable language dropdown with flags ─────────────────────────────────
|
||||
The underlying <select> stays in the DOM (hidden) as the source of truth so
|
||||
existing .value reads and change listeners keep working; this is only the
|
||||
presentation layer over it. */
|
||||
.langsel { position: relative; display: inline-block; min-width: 0; }
|
||||
.langsel-native { display: none !important; }
|
||||
|
||||
.langsel-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
width: 100%; min-height: 30px; padding: 3px 8px;
|
||||
border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface); color: var(--text);
|
||||
font-size: 13px; cursor: pointer; text-align: left;
|
||||
}
|
||||
.langsel-btn:hover { border-color: var(--accent, #4a90d9); }
|
||||
.langsel-btn .mdi-chevron-down { margin-left: auto; opacity: .6; }
|
||||
.langsel-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.langsel-flag { font-size: 15px; line-height: 1; flex: 0 0 auto; }
|
||||
|
||||
.langsel-pop {
|
||||
position: absolute; z-index: 60; top: calc(100% + 4px); left: 0;
|
||||
min-width: 240px; max-width: 320px;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--panel); box-shadow: 0 6px 22px rgba(0,0,0,.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
.langsel-search { display: flex; align-items: center; gap: 6px; padding: 7px 9px; border-bottom: 1px solid var(--border); }
|
||||
.langsel-search .mdi { opacity: .55; }
|
||||
.langsel-search input {
|
||||
flex: 1; border: none; outline: none; background: none; color: var(--text); font-size: 13px; min-width: 0;
|
||||
}
|
||||
.langsel-list { max-height: 300px; overflow-y: auto; padding: 4px; }
|
||||
.langsel-opt {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 6px 8px; border: none; border-radius: 5px;
|
||||
background: none; color: var(--text); font-size: 13px; text-align: left; cursor: pointer;
|
||||
}
|
||||
.langsel-opt:hover { background: rgba(127,127,127,.14); }
|
||||
.langsel-opt.is-sel { background: rgba(74,144,217,.18); font-weight: 600; }
|
||||
.langsel-empty { padding: 10px; font-size: 12px; color: var(--subtext); text-align: center; }
|
||||
|
||||
/* Routing actions duplicated into the page header: the card holding the
|
||||
original toolbar is collapsible, and collapsing it hid the only Save button. */
|
||||
.routing-head-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.routing-head-actions .note { font-size: 12px; color: var(--subtext); }
|
||||
/* Narrow column: the flag + code button must not force the row wider. */
|
||||
.routing-grid .langsel { width: 100%; }
|
||||
.routing-grid .langsel-btn { padding: 3px 6px; gap: 4px; }
|
||||
.routing-grid .langsel-label { font-variant-numeric: tabular-nums; }
|
||||
|
||||
@ -1,30 +1,86 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "flux1-dev-fp8.safetensors"}
|
||||
"inputs": {
|
||||
"ckpt_name": "flux1-dev-fp8.safetensors"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "", "clip": ["1", 1]}
|
||||
"inputs": {
|
||||
"text": "",
|
||||
"clip": [
|
||||
"1",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "blurry, low resolution, distorted face, duplicate person, extra limbs, malformed hands, cropped head, readable text, logo, watermark", "clip": ["1", 1]}
|
||||
"inputs": {
|
||||
"text": "blurry, low resolution, distorted face, duplicate person, extra limbs, malformed hands, cropped head, readable text, logo, watermark",
|
||||
"clip": [
|
||||
"1",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "EmptyLatentImage",
|
||||
"inputs": {"width": 1024, "height": 1024, "batch_size": 1}
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"seed": 0, "steps": 28, "cfg": 3.5, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], "latent_image": ["4", 0]}
|
||||
"inputs": {
|
||||
"seed": 0,
|
||||
"steps": 28,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1.0,
|
||||
"model": [
|
||||
"1",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"2",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"3",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"4",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["5", 0], "vae": ["1", 2]}
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"5",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"1",
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": "voice-creator/portrait", "images": ["6", 0]}
|
||||
"inputs": {
|
||||
"filename_prefix": "voice-creator/portrait",
|
||||
"images": [
|
||||
"6",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user