Improve Studio casting and character voice design (v1.20.36)
This commit is contained in:
parent
2013157bda
commit
62274a17fc
20
CHANGELOG.md
20
CHANGELOG.md
@ -5,6 +5,26 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.20.36] — 2026-08-15
|
||||
|
||||
### Added
|
||||
- **Character image workflow is now directly editable.** Click a character avatar or profile image to open a large editor with local upload, online-image download, and regeneration controls. Concept art opens in a wider preview/editor with the same three actions.
|
||||
- **Voice review is now faster in character profiles.** A compact blue play/stop control sits beside the assigned voice, and the adjacent circular regenerate control opens the editable Voice Design prompt before creating a replacement voice.
|
||||
- **Character table view now mirrors the Voice Library’s review data.** Each assigned voice has its own editable star rating and note, plus a Voice Design Prompt column sourced from the voice metadata (with the character-sheet prompt as fallback).
|
||||
- **Book context is generated during character-sheet casting when it is missing.** Genre, setting, era and language are shared with the profile, image, concept-art and voice-prompt workflows.
|
||||
- **Selective character casting has a larger, readable chooser.** The action is now labelled **“Select characters to cast or recast roles”**, and its list keeps names and line counts aligned.
|
||||
|
||||
### Changed
|
||||
- **Voice design prompts now use audible identity constraints.** They are concise English descriptions capped at 480 characters for CosyVoice compatibility, with explicit gender, age, native-language delivery, pitch/timbre/pace and distinctive vocal identity. Species and roles are translated into practical sound cues (for example Orc, vampire, elf, warrior, blacksmith and mage) rather than passed as lore labels alone.
|
||||
- **Concept-art prompt construction better anchors profile facts.** Gender, species, physical features, clothing, signature props and the book context are carried into a consistent front/side/back character-reference prompt.
|
||||
- **ElevenLabs voice imports remain on Get a Voice Online after importing**, enabling several voices to be collected in succession rather than forcing a jump back to the voice library.
|
||||
|
||||
### Fixed
|
||||
- **Studio casting controls could disappear completely.** The Studio toolbar used to hide its own controls after borrowing the casting footer; an asynchronous footer rebuild could then leave an empty bar. The real Identify / Cast Characters / Cast submenu bar is now relocated only after it has fully rendered, while the simple fallback controls remain visible until then.
|
||||
- **Selective casting could generate the wrong character.** Alias matching could resolve the selected roster name to another character’s profile. The selected sidebar name is now authoritative when a profile is generated and saved.
|
||||
- **Editing a Voice Design prompt could leave the character detail page blank.** The detail page now re-renders from the saved character record after generation instead of rebuilding from stale in-memory data.
|
||||
- **Google image generation reports provider failures instead of silently falling back**, and character-image generation skips sparse profiles rather than producing generic placeholder portraits.
|
||||
|
||||
## [1.20.8] — 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
@ -685,7 +685,7 @@ def _charsheets_prepare(data: dict) -> dict:
|
||||
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
|
||||
"- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.\n"
|
||||
"- voice_pattern: speech style — accent, pacing, vocabulary, register and verbal tics (for voice casting)\n"
|
||||
"- voice_design_prompt: concise English Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. "
|
||||
"- 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. "
|
||||
+ ("State the accent explicitly as an authentic native " + language + " accent — never American-accented English, even though the prompt itself is written in English. " if language and language.lower() != "english" else "State the accent explicitly as a neutral British or international English accent — never American/US-accented. ")
|
||||
+ "Do NOT mention plot spoilers.\n"
|
||||
"- image_prompt: detailed English image-generation prompt for this character. Include face, age impression, build, hair/eyes/skin if known, clothing, posture, props, mood, genre/style, and visible symbols. Mark inferred traits with '*'.\n"
|
||||
@ -861,6 +861,86 @@ async def character_sheets(request: Request):
|
||||
return _charsheets_parse(raw)
|
||||
|
||||
|
||||
@router.post("/api/book-profile/generate")
|
||||
async def generate_book_profile(request: Request):
|
||||
"""Infer missing book context from the source text and identified cast.
|
||||
|
||||
The result is deliberately a small, grounded metadata object rather than
|
||||
another character-sheet response. Existing user-entered fields win.
|
||||
"""
|
||||
data = await request.json()
|
||||
book = str(data.get("book") or "").strip()
|
||||
text = str(data.get("text") or "").strip()
|
||||
roster = [str(x).strip() for x in (data.get("known_characters") or []) if str(x).strip()]
|
||||
if not book or not text:
|
||||
raise HTTPException(400, "book and text are required")
|
||||
|
||||
from core.database import state_get, state_put
|
||||
key = "book_profile::" + book.lower()
|
||||
current = state_get(key, {}) or {}
|
||||
fields = ("genre", "setting", "era", "language")
|
||||
missing = [f for f in fields if not str(current.get(f) or "").strip()]
|
||||
if not missing:
|
||||
return {"ok": True, "profile": {f: str(current.get(f) or "").strip() for f in fields}, "generated": False}
|
||||
|
||||
settings = _load_settings()
|
||||
llm_url = (data.get("llm_url") or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
model = str(data.get("model") or settings.get("llm_model") or "").strip()
|
||||
language_hint = str(data.get("language") or "").strip()
|
||||
roster_text = ", ".join(roster[:120]) or "(no cast roster available)"
|
||||
prompt = f"""You are preparing production metadata for an audiobook and its character profiles.
|
||||
Infer ONLY the missing book-context fields from the source excerpt and the recognized cast.
|
||||
Do not invent a setting merely because a character name sounds familiar. Prefer evidence in the text.
|
||||
This is the German fantasy series Das Jahr des Greifen, but still use the text as the authority.
|
||||
|
||||
Missing fields: {', '.join(missing)}
|
||||
Recognized cast: {roster_text}
|
||||
Language hint: {language_hint or '(detect from the source)'}
|
||||
|
||||
Return ONLY one JSON object with exactly these keys:
|
||||
{{"genre":"...","setting":"...","era":"...","language":"..."}}
|
||||
Use concise values (at most a short phrase per field). Write metadata in the source language,
|
||||
except that language should be a plain language name such as German. Leave a field empty if the
|
||||
text does not support a reliable answer. Do not describe individual characters and do not include
|
||||
markdown or explanation.
|
||||
|
||||
SOURCE TEXT:
|
||||
{text[:120000]}"""
|
||||
payload = {"messages": [
|
||||
{"role": "system", "content": "Return valid JSON only. Ground every field in the supplied book text."},
|
||||
{"role": "user", "content": prompt},
|
||||
], "temperature": 0.1, "max_tokens": 300}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
timeout = _request_timeout_seconds(data.get("timeout_seconds"), 300.0)
|
||||
try:
|
||||
if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, timeout):
|
||||
raise HTTPException(503, "Attribution engine busy")
|
||||
try:
|
||||
resp = await asyncio.to_thread(
|
||||
_post_llm_chat_completion, llm_url, payload,
|
||||
{"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout,
|
||||
)
|
||||
finally:
|
||||
_attribution_llm_lock.release()
|
||||
resp.raise_for_status()
|
||||
msg = resp.json()["choices"][0]["message"]
|
||||
raw = (msg.get("content") or _reasoning_text(msg) or "").strip()
|
||||
parsed = json.loads(_extract_json_block(raw))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Book context generation failed: {e}")
|
||||
|
||||
generated = {f: str(parsed.get(f) or "").strip()[:200 if f != "language" else 60] for f in fields}
|
||||
merged = {f: str(current.get(f) or "").strip() for f in fields}
|
||||
for f in missing:
|
||||
if generated[f]:
|
||||
merged[f] = generated[f]
|
||||
state_put(key, merged)
|
||||
return {"ok": True, "profile": merged, "generated": True}
|
||||
|
||||
|
||||
@router.post("/api/character-sheets/stream")
|
||||
async def character_sheets_stream(request: Request):
|
||||
"""Same job as /api/character-sheets, but streams the LLM's raw output
|
||||
@ -1187,9 +1267,23 @@ async def character_generate_prompts(request: Request):
|
||||
preamble = (
|
||||
"You are a prompt engineer who turns a fiction character's profile into ready-to-paste prompts for "
|
||||
"other tools. Use ONLY details present in the profile below; mark anything you must reasonably infer "
|
||||
f"with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n\n"
|
||||
f"with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n"
|
||||
"CONTENT VS LOOK RULE: The profile is the sole source of truth for who this character is and what they "
|
||||
"visually possess. The prompt controls how that content is presented: sheet purpose, composition, camera, "
|
||||
"lighting, rendering medium, palette treatment, background, annotation policy, and output ratio. Never let "
|
||||
"a chosen art style silently change the character's gender, age, anatomy, species, face, clothing, colors, "
|
||||
"weapons, or props. Never add lifestyle items, underwear, cosmetics, brands, or extra gear unless the profile "
|
||||
"explicitly establishes them.\n\n"
|
||||
)
|
||||
user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\n"
|
||||
visual_identity_rule = (
|
||||
"VISUAL IDENTITY LOCK: Treat the profile as a production design bible. "
|
||||
"Gender is mandatory and must be stated explicitly; never reinterpret a male character as female or vice versa. "
|
||||
"Extract every visual fact available in the profile: gender, age impression, species, face shape, skin tone, "
|
||||
"hair, eyes, facial hair, scars or distinguishing marks, height/build, clothing layers, armor, colors, "
|
||||
"jewelry, weapons, tools, and signature props. Use one consistent face identity anchor across the portrait, "
|
||||
"turnaround, expression sheet, and concept art. Do not add visual details that contradict the profile.\n\n"
|
||||
)
|
||||
|
||||
# One independent call per field (not one four-field JSON object, nor even
|
||||
# the two-field pairing this used briefly) — the UI now has a Generate
|
||||
@ -1221,6 +1315,7 @@ async def character_generate_prompts(request: Request):
|
||||
),
|
||||
"image_prompt": (
|
||||
preamble
|
||||
+ visual_identity_rule
|
||||
+ "Produce exactly this field:\n"
|
||||
"- image_prompt: a detailed English image-generation prompt for a full CHARACTER REFERENCE SHEET "
|
||||
"(a single composite image, like a game/animation production turnaround), not just one portrait. "
|
||||
@ -1238,7 +1333,9 @@ async def character_generate_prompts(request: Request):
|
||||
"expression sheet with 3-5 headshots showing this character's typical emotional range "
|
||||
"(calm/determined/etc. — pick expressions that fit their personality), (4) a color palette swatch "
|
||||
"panel for hair/eyes/outfit, (5) callouts for their signature props, tools, or clothing details "
|
||||
"with short labels. Include age impression, build, hair/eyes/skin if known, and clothing "
|
||||
"with short labels. Include the explicit gender and every established visual trait: age impression, species, "
|
||||
"face shape, build, hair, eyes, skin, facial hair, distinguishing marks, clothing, armor, colors, "
|
||||
"jewelry, weapons, tools, and signature props when present. "
|
||||
"appropriate to the established setting. Specify a clean production-design/concept-art layout "
|
||||
"with a plain neutral background, and explicitly note this is an ORIGINAL character, not based on "
|
||||
"any copyrighted character. One dense paragraph, comma-separated descriptors are fine.\n\n"
|
||||
@ -1247,33 +1344,35 @@ async def character_generate_prompts(request: Request):
|
||||
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
|
||||
"concept_art_prompt": (
|
||||
preamble
|
||||
+ visual_identity_rule
|
||||
+ "Produce exactly this field:\n"
|
||||
"- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC "
|
||||
"reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
|
||||
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
|
||||
"(not a list). This mirrors a well-tested prompt-engineering pattern for these sheets; follow it "
|
||||
"precisely rather than writing free-form:\n"
|
||||
" Task: name the sheet type, e.g. 'Generate a character/NPC design sheet.'\n"
|
||||
" Subject: 'an original adult [role/archetype from the profile]' plus its 3-6 most visually "
|
||||
"distinctive, ALREADY-ESTABLISHED traits (skin/hair, signature garment layers, and — only if the "
|
||||
"profile actually gives this character a prop, weapon, or tool — its exact count, e.g. 'exactly one "
|
||||
"quiver' or 'exactly two throwing knives'; omit props entirely if the profile has none).\n"
|
||||
" Context: one clause on what the sheet is for and the story's genre/setting/era — infer this from "
|
||||
"the book/profile the same way you would for a portrait (a fantasy Admiral is NOT a real-world 20th-"
|
||||
"century Admiral) and state it explicitly, since an image model defaults to generic modern imagery "
|
||||
"otherwise.\n"
|
||||
" Style: a concept-art style matching that genre/setting (painterly game concept art / detailed "
|
||||
"semi-realistic concept art / hand-painted concept art — pick what fits), grounded materials, clear "
|
||||
"shape language.\n"
|
||||
" Composition: full-body front, side, and back views across the top; below them, ONE clean row of "
|
||||
"isolated callouts for this character's established props/costume components ONLY (skip this row "
|
||||
"entirely if the profile establishes no distinct props/costume pieces worth separating out) — state "
|
||||
"the exact number of callouts and name each one.\n"
|
||||
" Lighting: neutral studio/museum-style light, no cinematic color cast.\n"
|
||||
" Constraints: face/silhouette/garment/prop consistency across every view; the exact prop count "
|
||||
"restated; no duplicate gear, no extra limbs, no readable text/logos/watermark, no real-world/"
|
||||
"franchise references, this is an ORIGINAL character not based on any copyrighted one.\n"
|
||||
" Output: one production-ready 3:2 reference sheet.\n\n"
|
||||
"(not a list). Use the following two reference layouts as the structural model, but replace EVERY "
|
||||
"subject fact with the current character profile:\n"
|
||||
" Task: choose the best sheet purpose for the profile — a three-view character turnaround with "
|
||||
"accessory callouts, or a character props/accessories callout sheet.\n"
|
||||
" Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
|
||||
"content. Include every relevant visual fact from the profile: age, species, face, skin, hair, eyes, "
|
||||
"facial hair, marks, build, clothing layers, colors, armor, jewelry, weapons, tools, and signature "
|
||||
"accessories. For each profile-established prop/accessory, state its exact count; omit anything not in "
|
||||
"the profile. Never copy the herbalist/cartographer examples.\n"
|
||||
" Context: state the book's profile-derived genre, setting, era, and the production purpose of the sheet.\n"
|
||||
" Style: retain the reference style principles — detailed hand-painted or painterly game concept art, "
|
||||
"grounded leather/metal/cloth/wood materials, clear readable shape language, and coherent wear — but "
|
||||
"choose the visual medium and palette treatment appropriate to this book's profile.\n"
|
||||
" Composition: choose either (A) aligned full-body front, side, and back views across the top with a "
|
||||
"dialogue/action pose and one clean accessory callout row below, or (B) one full-body three-quarter "
|
||||
"hero figure at left with exactly one isolated callout for each profile-established accessory at right. "
|
||||
"Use clean leader lines, generous spacing, safe margins, and keep every figure and accessory fully visible.\n"
|
||||
" Lighting: use warm-neutral or neutral studio light that reveals material texture, with soft contact "
|
||||
"shadows and no cinematic color cast unless the profile or book context explicitly requires one.\n"
|
||||
" Constraints: same exact gender, face identity anchor, body proportions, silhouette, garment seams, "
|
||||
"colors, wear, accessory scale, attachment points, and prop count in every view; each accessory appears "
|
||||
"once and matches the carried version; no duplicate gear, disassembled fragments, floating objects, extra "
|
||||
"limbs, readable text, logos, real-world emblems, franchise references, or watermark.\n"
|
||||
" Output: one organized, production-ready 3:2 character reference sheet.\n\n"
|
||||
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
|
||||
),
|
||||
}
|
||||
@ -1637,11 +1736,22 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
|
||||
if not api_key:
|
||||
raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)")
|
||||
gmodel = model or "gemini-2.5-flash-image"
|
||||
# Google retired the preview alias; keep older saved settings usable
|
||||
# instead of turning a previously working Google selection into a 404.
|
||||
if gmodel == "gemini-2.5-flash-image-preview":
|
||||
gmodel = "gemini-2.5-flash-image"
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"https://generativelanguage.googleapis.com/v1beta/models/{gmodel}:generateContent",
|
||||
params={"key": api_key},
|
||||
json={"contents": [{"parts": [{"text": prompt}]}]},
|
||||
f"https://generativelanguage.googleapis.com/v1/models/{gmodel}:generateContent",
|
||||
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
|
||||
# Gemini image models do not return image bytes unless image
|
||||
# output is explicitly requested. The old payload only sent
|
||||
# the prompt, so Google successfully returned text (or a
|
||||
# safety response) and the client reported "no image data".
|
||||
json={
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"responseModalities": ["IMAGE"]},
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if not resp.ok:
|
||||
@ -1651,10 +1761,15 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(resp.status_code, f"Google image generation failed: {detail}")
|
||||
parts = (resp.json().get("candidates") or [{}])[0].get("content", {}).get("parts", [])
|
||||
inline = next((p.get("inlineData") for p in parts if p.get("inlineData")), None)
|
||||
payload = resp.json()
|
||||
parts = (payload.get("candidates") or [{}])[0].get("content", {}).get("parts", [])
|
||||
inline = next((p.get("inlineData") or p.get("inline_data") for p in parts if p.get("inlineData") or p.get("inline_data")), None)
|
||||
if not inline:
|
||||
raise HTTPException(502, "Google returned no image data — try again or rephrase the prompt")
|
||||
# Preserve Google's useful finish/safety text in the error;
|
||||
# this makes quota, blocked prompts, and unavailable models
|
||||
# distinguishable from a client parsing bug.
|
||||
finish = (payload.get("candidates") or [{}])[0].get("finishReason") or "unknown response"
|
||||
raise HTTPException(502, f"Google returned no image data ({finish}) — check the selected image model, quota, or prompt")
|
||||
mime = inline.get("mimeType", "image/png")
|
||||
return {"image": f"data:{mime};base64,{inline['data']}"}
|
||||
except HTTPException:
|
||||
|
||||
28
static/dist/main.min.js
vendored
28
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.20.35">
|
||||
<meta name="app-version" content="1.20.36">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.20.35">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.20.36">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.20.35"></script>
|
||||
<script src="/static/loader.js?v=1.20.36"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -24,6 +24,38 @@ const AUDIOBOOK_RECAST_CONTEXT_CHARS = 4200;
|
||||
const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6;
|
||||
const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000;
|
||||
const _audiobook = { running: false, cancel: false };
|
||||
|
||||
// Studio relocates this control out of the audiobook panel. That means a
|
||||
// normal panel rebuild can leave an old copy visible even after the async cast
|
||||
// has finished or failed. Always clear every live copy when the worker state
|
||||
// reaches a terminal state.
|
||||
function _abHideCastingControl() {
|
||||
document.querySelectorAll('#ab-cv-cancel').forEach(btn => {
|
||||
btn.hidden = true;
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<span class="mdi mdi-close"></span> Cancel';
|
||||
});
|
||||
}
|
||||
|
||||
// The casting footer is physically moved into Studio and is also rebuilt by
|
||||
// the live view. A listener attached only to the original panel can therefore
|
||||
// disappear with a rebuilt footer. Keep one delegated listener on document so
|
||||
// Stop Casting always reaches the current button.
|
||||
if (!window._abDelegatedCancelHandler) {
|
||||
window._abDelegatedCancelHandler = true;
|
||||
document.addEventListener('click', event => {
|
||||
const btn = event.target.closest?.('#ab-cv-cancel');
|
||||
if (!btn || btn.hidden) return;
|
||||
if (_audiobook.running) {
|
||||
_audiobook.cancel = true;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Stopping...';
|
||||
if (typeof _audiobook.abort === 'function') _audiobook.abort();
|
||||
} else {
|
||||
_abHideCastingControl();
|
||||
}
|
||||
});
|
||||
}
|
||||
window._audiobook = _audiobook;
|
||||
let _abDraftAutosaveTimer = null;
|
||||
let _abLastServerDraftErrorAt = 0;
|
||||
@ -1087,7 +1119,15 @@ function _abIdentityTokens(name) {
|
||||
// why a fresh cast produced 73 speakers where the human had 62. A line credited
|
||||
// to one of these is better left Unknown: visibly unresolved, and still
|
||||
// recoverable by the repair passes, instead of silently wrong.
|
||||
// Grammatical words the caster mistakes for names because they open the sentence
|
||||
// that introduces a quote: "Hinter ihm raunte Oberst von Blautann:" -> Hinter,
|
||||
// "Keiner sagte mehr etwas." -> Keiner, a dangling "Von" from "von Blautann".
|
||||
// Confirmed by a reader hand-correcting a whole book: every one of these was
|
||||
// deleted as a bogus character.
|
||||
const _AB_NOT_A_CHARACTER = new Set([
|
||||
'hinter','vor','neben','über','unter','zwischen','gegenüber','keiner','keine','niemand','jemand','alle','beide',
|
||||
'einige','viele','andere','anderen','jeder','jede','man','von','zu','bei','nach','aus','mit','ihm','ihr','ihnen',
|
||||
'dort','hier','draußen','drinnen','oben','unten','plötzlich','endlich','schließlich',
|
||||
'inneren','innere','herz','herzen','hand','hände','haenden','kopf','stimme','stimmen','wind','feuer','flamme','flammen',
|
||||
'dunkelheit','stille','schatten','licht','nacht','angst','schmerz','gedanke','gedanken','traum','träume','geliebten',
|
||||
'geliebter','geliebte','blondschopf','antwort','echo','ferne','nähe','naehe','tiefe','wasser','regen','donner','erde',
|
||||
@ -1583,8 +1623,8 @@ function _abOpenRecastSelectPopup(existingChars) {
|
||||
});
|
||||
};
|
||||
|
||||
ov.innerHTML = '<div class="audiobook-box">'
|
||||
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Cast selected character roles</div>'
|
||||
ov.innerHTML = '<div class="audiobook-box ab-recast-dialog">'
|
||||
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Select characters to cast or recast roles</div>'
|
||||
+ '<div class="audiobook-msg">Only re-reads the passages that mention the characters you pick below, including known aliases, plus a little surrounding context. Everyone else\'s sheet stays as-is.</div>'
|
||||
+ '<div class="ab-cv-side-tools">'
|
||||
+ '<input type="text" class="ab-cv-side-search" id="ab-recast-search" placeholder="Search characters..." autocomplete="off">'
|
||||
@ -1626,7 +1666,11 @@ function _abOpenRecastSelectPopup(existingChars) {
|
||||
render();
|
||||
});
|
||||
ov.querySelector('#ab-recast-confirm').addEventListener('click', async () => {
|
||||
const names = [...selected];
|
||||
// The DOM checkboxes are the source of truth at confirmation time. The
|
||||
// Set is only a render/search convenience and could become stale after a
|
||||
// filtered list was rebuilt, which previously allowed a one-character
|
||||
// selection to fall through as a full-roster recast.
|
||||
const names = [...ov.querySelectorAll('.ab-recast-cb:checked')].map(cb => cb.value).filter(Boolean);
|
||||
if (!names.length) { toast('Select at least one character', 'error'); return; }
|
||||
ov.remove();
|
||||
if (typeof window.audiobookRecastSelectedCharacters === 'function') await window.audiobookRecastSelectedCharacters(names);
|
||||
@ -4003,6 +4047,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
// Park the panel in a "done" state with a Review button instead of auto-popping
|
||||
// the preview — so it waits for you if you wandered off to do something else.
|
||||
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
||||
_abHideCastingControl();
|
||||
if (count) count.hidden = true;
|
||||
const castBar = panel.querySelector('.ab-castpanel-bar');
|
||||
if (castBar) castBar.hidden = true;
|
||||
@ -4406,7 +4451,7 @@ ABSOLUTE REGELN:
|
||||
castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
|
||||
{ icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll },
|
||||
{ icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted },
|
||||
{ icon: 'mdi-account-check-outline', label: 'Cast selected character roles', title: existing.length ? 'Generate / refresh the character sheets for selected cast characters' : 'No character sheets found yet for this book', disabled: !existing.length, onClick: runCastSelected },
|
||||
{ icon: 'mdi-account-check-outline', label: 'Select characters to cast or recast roles', title: existing.length ? 'Choose character roles to generate or refresh' : 'No character sheets found yet for this book', disabled: !existing.length, onClick: runCastSelected },
|
||||
{ divider: true },
|
||||
{ icon: 'mdi-refresh', label: 'New recast (discard & rebuild all)', title: 'Discard every generated character sheet and rebuild every profile from scratch', onClick: runCastFresh, danger: true },
|
||||
]));
|
||||
@ -4428,6 +4473,7 @@ ABSOLUTE REGELN:
|
||||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
|
||||
},
|
||||
done(options = {}) {
|
||||
_abHideCastingControl();
|
||||
const stopped = options.stopped || _audiobook.cancel;
|
||||
if (!stopped) { closePanel(); return; }
|
||||
|
||||
@ -4765,6 +4811,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
|
||||
} finally {
|
||||
_audiobook.running = false;
|
||||
_audiobook.abort = null;
|
||||
_abHideCastingControl();
|
||||
}
|
||||
// Everything below only ever ran when nothing above threw — but nothing
|
||||
// here was itself guarded, so any failure in this post-processing (dedup,
|
||||
@ -5352,6 +5399,7 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
|
||||
_abStopDraftAutosave();
|
||||
_audiobook.running = false;
|
||||
_audiobook.abort = null;
|
||||
_abHideCastingControl();
|
||||
}
|
||||
|
||||
if (!allSegments.length) {
|
||||
|
||||
@ -43,7 +43,7 @@ For each character output these fields:
|
||||
- mannerisms: habitual gestures, tics, body language, habits and quirks
|
||||
- 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 Qwen voice-design prompt (15-45 words). Include age impression, gender/androgyny if inferable, pitch, timbre, pace, accent/register, emotional baseline and suitability for audiobook dialogue. Do NOT mention plot spoilers.
|
||||
- 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 '*'.
|
||||
- inventory: 1-3 defining items/props/clothing (array of short strings)
|
||||
- secret: dark secret or fatal flaw
|
||||
@ -76,6 +76,34 @@ function csLlmModel() { return $('reh-llm-model')?.value || ''; }
|
||||
function csLang() { return $('reh-design-lang')?.value || ''; }
|
||||
function csReaderPageHost() { return $('reader-charsheets-panel'); }
|
||||
|
||||
// Establish shared world context before the first profile pass. This keeps
|
||||
// genre/setting/era/language consistent across character sheets, images, and
|
||||
// voice prompts. The server preserves any fields the user already entered.
|
||||
async function csEnsureBookContext(book, text, roster, opts = {}) {
|
||||
if (!book || !text || typeof _getBookProfile !== 'function') return {};
|
||||
let existing = {};
|
||||
try { existing = await _getBookProfile(book); } catch (_) {}
|
||||
const missing = ['genre', 'setting', 'era', 'language'].filter(k => !String(existing?.[k] || '').trim());
|
||||
if (!missing.length && !opts.force) return existing;
|
||||
try {
|
||||
const r = await fetch('/api/book-profile/generate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
book, text, known_characters: Array.isArray(roster) ? roster : [],
|
||||
llm_url: csLlmUrl(), model: csLlmModel(), language: csLang(),
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.text()).slice(0, 300) || ('HTTP ' + r.status));
|
||||
const data = await r.json();
|
||||
if (typeof _bookProfileCache !== 'undefined') _bookProfileCache.set(book, data.profile || {});
|
||||
return data.profile || existing;
|
||||
} catch (e) {
|
||||
console.warn('[book context] automatic generation failed:', e);
|
||||
if (typeof toast === 'function') toast('Book context could not be generated; profile casting will continue', 'info');
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
// Build page-annotated text from the current reader scope ([p.N] at page changes).
|
||||
function csReaderText() {
|
||||
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
||||
@ -289,6 +317,44 @@ function csSheetMatchesRoster(sheet, roster = []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The dialogue-casting roster is the source of truth for the character's
|
||||
// display name. Character-sheet extraction may discover a real name, role, or
|
||||
// epithet and put it in `name`, but that must never replace the name the user
|
||||
// sees in Characters found / the cast. Keep those discoveries in aliases.
|
||||
function csRosterCanonicalName(sheet, roster = []) {
|
||||
const names = csCleanRoster(roster);
|
||||
if (!names.length) return '';
|
||||
const tokens = csNameTokens(sheet);
|
||||
const exact = names.find(n => tokens.has(n.toLowerCase()));
|
||||
return exact || '';
|
||||
}
|
||||
|
||||
function csCanonicalizeRosterNames(sheets, roster = []) {
|
||||
const canonical = new Map();
|
||||
for (const original of sheets || []) {
|
||||
if (!original) continue;
|
||||
const sheet = { ...original };
|
||||
const rosterName = csRosterCanonicalName(sheet, roster);
|
||||
if (rosterName && sheet.name && sheet.name.toLowerCase() !== rosterName.toLowerCase()) {
|
||||
sheet.aliases = csMergeAliases(
|
||||
{ name: rosterName, aliases: sheet.aliases },
|
||||
{ name: sheet.name, aliases: '' },
|
||||
);
|
||||
sheet.name = rosterName;
|
||||
}
|
||||
// Tell the library upsert that this name came from the live cast roster.
|
||||
// It must be allowed to correct an older record whose canonical name was
|
||||
// previously chosen from an alias (for example an LLM-selected real name
|
||||
// replacing the sidebar role name Fremder).
|
||||
if (rosterName) sheet._rosterNameAuthoritative = true;
|
||||
const key = String(sheet.name || '').trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!canonical.has(key)) canonical.set(key, sheet);
|
||||
else csMerge(canonical, [sheet]);
|
||||
}
|
||||
return [...canonical.values()];
|
||||
}
|
||||
|
||||
function csIdentityNeedles(value) {
|
||||
return csSplitIdentityTokens(value, { aliases: true }).filter(v => {
|
||||
const n = String(v || '').trim();
|
||||
@ -441,7 +507,7 @@ function csMerge(map, sheets) {
|
||||
// this run (settled by Identify Characters or an earlier cast pass) as
|
||||
// canonical, with the other name demoted to an alias — rather than letting
|
||||
// the character-sheet step invent a competing identity.
|
||||
function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated) {
|
||||
function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster = []) {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
@ -456,8 +522,18 @@ function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated)
|
||||
// pass), fall back to whichever record is OLDER — the one Identify
|
||||
// Characters / the original cast settled on first.
|
||||
const sEst = establishedKeys.has(key), oEst = establishedKeys.has(dupKey);
|
||||
const rosterNames = csCleanRoster(roster);
|
||||
const sRoster = rosterNames.findIndex(n => n.toLowerCase() === key);
|
||||
const oRoster = rosterNames.findIndex(n => n.toLowerCase() === dupKey);
|
||||
let keepKey = key, dropKey = dupKey;
|
||||
if (oEst && !sEst) { keepKey = dupKey; dropKey = key; }
|
||||
// If both names are present in the current Characters-found roster,
|
||||
// preserve the roster's own order. That makes the displayed sidebar
|
||||
// name authoritative (for example Fremder), while the other name
|
||||
// remains an AKA instead of unexpectedly becoming the profile title.
|
||||
if (sRoster >= 0 && oRoster >= 0 && sRoster !== oRoster) {
|
||||
if (oRoster < sRoster) { keepKey = dupKey; dropKey = key; }
|
||||
} else if (sRoster < 0 && oRoster >= 0) { keepKey = dupKey; dropKey = key; }
|
||||
else if (oEst && !sEst) { keepKey = dupKey; dropKey = key; }
|
||||
else if (sEst === oEst) {
|
||||
const filled = (sh) => CS_DETAIL_FIELDS.filter(f => _csStr(sh[f]).trim()).length;
|
||||
const sFilled = filled(s), oFilled = filled(other);
|
||||
@ -757,8 +833,11 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
|
||||
}
|
||||
} finally { prog.done(); }
|
||||
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
|
||||
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated);
|
||||
const sheets = [...map.values()];
|
||||
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster);
|
||||
// The live casting roster (and its Characters found sidebar) owns the
|
||||
// display name. Any LLM-selected real name/role stays in aliases instead of
|
||||
// renaming the profile.
|
||||
const sheets = csCanonicalizeRosterNames([...map.values()], roster);
|
||||
if (cacheKey) _cs.cache[cacheKey] = sheets;
|
||||
return sheets;
|
||||
} catch (e) {
|
||||
@ -1267,6 +1346,7 @@ function csAlignmentBar(score, arcDirection, arcNote) {
|
||||
function csBuildImagePrompt(s, bookProfile) {
|
||||
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
|
||||
const parts = [];
|
||||
if (s.gender) parts.push(`exactly ${s.gender}`);
|
||||
if (s.race_species) parts.push(s.race_species);
|
||||
if (s.archetype) parts.push(s.archetype);
|
||||
if (s.physical) parts.push(s.physical);
|
||||
@ -1289,6 +1369,9 @@ function csBuildImagePrompt(s, bookProfile) {
|
||||
+ `real-world default. `
|
||||
: '';
|
||||
return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
|
||||
+ `VISUAL IDENTITY LOCK: use the exact gender, age impression, species, face shape, skin tone, hair, eyes, facial hair, `
|
||||
+ `distinguishing marks, build, clothing, colors, armor, jewelry, weapons, tools, and signature props established here. `
|
||||
+ `Keep one identical face identity anchor across the portrait, front/side/back turnaround, expression sheet, and later concept art. `
|
||||
+ settingClause
|
||||
+ `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — `
|
||||
+ `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" `
|
||||
@ -2241,11 +2324,17 @@ async function csForReader(opts = {}) {
|
||||
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
|
||||
const pageHost = csReaderPageHost();
|
||||
if (!fresh && _cs.cache[key]) {
|
||||
await csSaveToLibrary(book, _cs.cache[key]);
|
||||
if (pageHost) await csShow(_cs.cache[key], book, text, book, pageHost);
|
||||
const cached = csCanonicalizeRosterNames(_cs.cache[key], csKnownReaderRoster());
|
||||
_cs.cache[key] = cached;
|
||||
await csSaveToLibrary(book, cached);
|
||||
if (pageHost) await csShow(cached, book, text, book, pageHost);
|
||||
return;
|
||||
}
|
||||
const knownRoster = csKnownReaderRoster();
|
||||
// Generate context after Identify Characters has produced the roster and
|
||||
// before any character-sheet passage is sent. This is intentionally best
|
||||
// effort: a model outage must not prevent the actual cast-profile run.
|
||||
await csEnsureBookContext(book, text, knownRoster, { force: fresh });
|
||||
let seedSheets = [];
|
||||
if (!fresh) try {
|
||||
if (typeof clGetAllByTagOrBook === 'function') {
|
||||
@ -2257,9 +2346,25 @@ async function csForReader(opts = {}) {
|
||||
// the CURRENT cast (knownRoster, from the live audiobook roster) no
|
||||
// longer has them. Keep only saved records that still match a name in
|
||||
// the current cast, so this list stays in sync with what step 2 shows.
|
||||
const rosterSet = new Set(knownRoster.map(n => n.toLowerCase()));
|
||||
const stillCast = rec => !rosterSet.size || rosterSet.has(String(rec?.name || '').trim().toLowerCase());
|
||||
seedSheets = (existing || []).filter(stillCast).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim());
|
||||
const stillCast = rec => !knownRoster.length || csSheetMatchesRoster({
|
||||
name: rec?.name || '',
|
||||
aliases: rec?.sheet?.aliases || '',
|
||||
first_name: rec?.sheet?.first_name || '',
|
||||
last_name: rec?.sheet?.last_name || '',
|
||||
full_name: rec?.sheet?.full_name || '',
|
||||
}, knownRoster);
|
||||
seedSheets = (existing || []).filter(stillCast).map(rec => {
|
||||
const sheet = csSeedSheet(rec);
|
||||
const rosterName = csRosterCanonicalName(sheet, knownRoster);
|
||||
if (rosterName && sheet.name.toLowerCase() !== rosterName.toLowerCase()) {
|
||||
sheet.aliases = csMergeAliases(
|
||||
{ name: rosterName, aliases: sheet.aliases },
|
||||
{ name: sheet.name, aliases: '' },
|
||||
);
|
||||
sheet.name = rosterName;
|
||||
}
|
||||
return sheet;
|
||||
}).filter(s => String(s.name || '').trim());
|
||||
}
|
||||
} catch (_) { seedSheets = []; }
|
||||
// Computed up front (not just after the whole run finishes) so "Lines"
|
||||
@ -2309,6 +2414,7 @@ async function csForReaderSelective(selectedNames) {
|
||||
// that race regardless of which async step caused it.
|
||||
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
|
||||
const pageHost = csReaderPageHost();
|
||||
const knownRoster = csKnownReaderRoster();
|
||||
let records = [];
|
||||
try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; }
|
||||
const picked = [];
|
||||
@ -2329,19 +2435,37 @@ async function csForReaderSelective(selectedNames) {
|
||||
}
|
||||
return false;
|
||||
};
|
||||
(records || []).forEach(rec => {
|
||||
if (!rec || !rec.name || !matchesWanted(rec)) return;
|
||||
const key = String(rec.name || '').trim().toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
picked.push(rec);
|
||||
});
|
||||
// Exact canonical names always win. Alias matching is only a fallback for
|
||||
// old records that have no exact record; otherwise selecting Lysandra could
|
||||
// pick an Alrik profile whose generated aliases happened to contain the
|
||||
// word "Lysandra".
|
||||
(selectedNames || []).forEach(name => {
|
||||
const key = String(name || '').trim().toLowerCase();
|
||||
const canonical = String(name || '').trim();
|
||||
const key = canonical.toLowerCase();
|
||||
if (!key || seen.has(key)) return;
|
||||
const fallback = { name: String(name || '').trim(), sheet: csBlankSheet(String(name || '').trim()) };
|
||||
const exact = (records || []).find(rec => String(rec?.name || '').trim().toLowerCase() === key);
|
||||
const aliasMatch = exact || (records || []).find(rec => {
|
||||
if (!rec || !rec.name) return false;
|
||||
const tokens = csNameTokens({
|
||||
name: rec.name,
|
||||
aliases: rec.sheet?.aliases || '',
|
||||
first_name: rec.sheet?.first_name || '',
|
||||
last_name: rec.sheet?.last_name || '',
|
||||
full_name: rec.sheet?.full_name || '',
|
||||
});
|
||||
return tokens.some(t => {
|
||||
const lower = String(t || '').toLowerCase();
|
||||
return lower === key || lower.includes(key) || key.includes(lower);
|
||||
});
|
||||
});
|
||||
let chosen = aliasMatch || { name: canonical, sheet: csBlankSheet(canonical) };
|
||||
// Keep the selected sidebar name as the generation identity even when we
|
||||
// borrowed profile evidence from a legacy alias-linked record.
|
||||
if (chosen.name !== canonical) {
|
||||
chosen = Object.assign({}, chosen, { name: canonical, sheet: Object.assign({}, chosen.sheet || {}, { name: canonical }) });
|
||||
}
|
||||
seen.add(key);
|
||||
picked.push(fallback);
|
||||
picked.push(chosen);
|
||||
});
|
||||
picked.sort((a, b) => (b?.sheet?.line_count || 0) - (a?.sheet?.line_count || 0) || String(a?.name || '').localeCompare(String(b?.name || '')));
|
||||
if (!picked.length) { toast('No matching cast characters found', 'error'); return; }
|
||||
@ -2349,13 +2473,16 @@ async function csForReaderSelective(selectedNames) {
|
||||
for (const rec of picked) {
|
||||
const targetName = String(rec.name || '').trim();
|
||||
if (!targetName) continue;
|
||||
const rosterName = targetName;
|
||||
const needles = csRecordNeedles(rec);
|
||||
const scanText = csEvidenceWindowText(csReaderParagraphBlocks(), needles, 2, 2) || text;
|
||||
const seedSheet = csSeedSheet(rec);
|
||||
const sheets = await csGenerate(scanText, null, [targetName], { pageHost, seedSheets: [seedSheet] });
|
||||
seedSheet.name = targetName;
|
||||
const sheets = await csGenerate(scanText, null, [rosterName], { pageHost, seedSheets: [seedSheet] });
|
||||
if (!sheets) return;
|
||||
const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase()));
|
||||
if (!filtered.length) continue;
|
||||
filtered.forEach(s => { s.name = targetName; s._rosterNameAuthoritative = true; });
|
||||
if (rec?.sheet?.line_count != null) filtered.forEach(s => { s.line_count = rec.sheet.line_count; });
|
||||
csMerge(finalMap, filtered);
|
||||
}
|
||||
|
||||
@ -206,15 +206,25 @@ function clMergeTags(...parts) {
|
||||
async function clUpsert(book, sheet, knownId) {
|
||||
const name = (sheet.name || '').trim();
|
||||
if (!name) return null;
|
||||
const rosterNameAuthoritative = sheet._rosterNameAuthoritative === true;
|
||||
const bk = (book || '').trim() || 'Unsorted';
|
||||
const aliasPrev = knownId
|
||||
// A live cast roster is an explicit identity, not a guess. Never let an
|
||||
// alias from the generated sheet redirect `Krieger` into an older
|
||||
// `Zerwas` record (or `Fremder` into `Lancorian`). The sidebar name and
|
||||
// book-scoped key are authoritative; aliases remain data on that record.
|
||||
const authoritativeId = rosterNameAuthoritative ? clKey(bk, name) : null;
|
||||
const aliasPrev = authoritativeId
|
||||
? await clGet(authoritativeId).catch(() => null)
|
||||
: knownId
|
||||
? await clGet(knownId).catch(() => null)
|
||||
: (await clGetAll().catch(() => [])).find(r => clSameIdentity(r, bk, sheet));
|
||||
const id = aliasPrev?.id || knownId || clKey(bk, name);
|
||||
const id = authoritativeId || aliasPrev?.id || knownId || clKey(bk, name);
|
||||
const now = new Date();
|
||||
const prev = aliasPrev || await clGet(id).catch(() => null);
|
||||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name };
|
||||
const canonicalName = prev?.name || name;
|
||||
const sanitized = _clSanitize(sheet);
|
||||
delete sanitized._rosterNameAuthoritative;
|
||||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sanitized, name };
|
||||
const canonicalName = rosterNameAuthoritative ? name : (prev?.name || name);
|
||||
merged.name = canonicalName;
|
||||
const tags = clMergeTags(prev?.tags, sheet.tags, bk);
|
||||
const color = clNormalizeColor(prev?.color || prev?.sheet?.color || sheet.color, canonicalName);
|
||||
|
||||
@ -150,7 +150,7 @@ function initElevenLabsBrowser() {
|
||||
<button class="el-play" title="Play preview" data-preview="${prev}">▶ Play</button>
|
||||
</div>
|
||||
</div>
|
||||
${prev ? `<div class="el-vc-footer"><button class="el-clone" title="Import to Clone a Voice" data-preview="${prev}" data-name="${nm}"><span class="mdi mdi-download"></span> Clone</button></div>` : ''}
|
||||
${prev ? `<div class="el-vc-footer"><button class="el-clone" title="Import directly to Voice Library" data-preview="${prev}" data-name="${nm}" data-voice-id="${_elEsc(v.voice_id || '')}" data-language="${_elEsc(v.language || '')}" data-gender="${_elEsc(v.gender || '')}" data-description="${_elEsc(v.description || '')}"><span class="mdi mdi-download"></span> Get voice</button></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@ -170,15 +170,54 @@ function initElevenLabsBrowser() {
|
||||
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
|
||||
}
|
||||
|
||||
function elImport(btn) {
|
||||
async function elImport(btn) {
|
||||
const url = btn.dataset.preview;
|
||||
if (!url) return;
|
||||
if (typeof navTo === 'function') navTo('s-clone');
|
||||
setTimeout(() => {
|
||||
const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn');
|
||||
if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); }
|
||||
if (btn2) btn2.click();
|
||||
}, 120);
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="el-spinner"></span> Importing…';
|
||||
|
||||
const name = btn.dataset.name || 'ElevenLabs voice';
|
||||
const lang = (typeof sourceLanguageCode === 'function'
|
||||
? sourceLanguageCode(btn.dataset.language)
|
||||
: String(btn.dataset.language || 'EN').slice(0, 2).toUpperCase());
|
||||
const gender = typeof sourceGenderCode === 'function'
|
||||
? sourceGenderCode(btn.dataset.gender, name)
|
||||
: '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}`;
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/quick-import-voice', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audio_url: url, voice_id: voiceId, transcript: '' }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
const d = await r.json();
|
||||
if (typeof saveMeta === 'function') {
|
||||
await saveMeta(d.voice_id, {
|
||||
name, tag: 'elevenlabs', group: 'elevenlabs', origin: 'cloned',
|
||||
gender, language: lang,
|
||||
note: (btn.dataset.description || '').slice(0, 180),
|
||||
source: `ElevenLabs shared voice${btn.dataset.voiceId ? ` · ${btn.dataset.voiceId}` : ''}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
btn.innerHTML = '<span class="mdi mdi-check"></span> In library';
|
||||
btn.classList.add('el-imported');
|
||||
if (typeof toast === 'function') toast(`Imported "${name}" to Voice Library`, 'success');
|
||||
if (typeof loadVoiceLibrary === 'function') {
|
||||
_libraryLoadPromise = null;
|
||||
await loadVoiceLibrary();
|
||||
}
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
if (typeof toast === 'function') toast('Import failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
elLoadKey();
|
||||
|
||||
@ -97,6 +97,7 @@ async function libraryRenderCharacters() {
|
||||
['lines', 'Anzahl Zeilen'],
|
||||
['gender', 'Geschlecht'],
|
||||
['voice', 'Stimme zugewiesen'],
|
||||
['rating', 'Stimmenbewertung'],
|
||||
];
|
||||
const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); })
|
||||
? localStorage.getItem('ttsvc_libchars_sort') : 'tier';
|
||||
@ -148,6 +149,11 @@ async function libraryRenderCharacters() {
|
||||
return ga.localeCompare(gb) || (a.name || '').localeCompare(b.name || '');
|
||||
},
|
||||
voice: function (a, b) { return (b.voice ? 1 : 0) - (a.voice ? 1 : 0) || (a.name || '').localeCompare(b.name || ''); },
|
||||
rating: function (a, b) {
|
||||
const idOf = function (r) { return r.voice ? (typeof r.voice === 'object' ? r.voice.id : r.voice) : ''; };
|
||||
const ratingOf = function (r) { return Number((window._voices || []).find(function (v) { return v.id === idOf(r); })?.rating || 0); };
|
||||
return ratingOf(b) - ratingOf(a) || (a.name || '').localeCompare(b.name || '');
|
||||
},
|
||||
age: function (a, b) { return (_charAgeSortVal(a.sheet) - _charAgeSortVal(b.sheet)) || (a.name || '').localeCompare(b.name || ''); },
|
||||
language: function (a, b) { return _charLangLabel(a).localeCompare(_charLangLabel(b)) || (a.name || '').localeCompare(b.name || ''); },
|
||||
align: function (a, b) { return (b.sheet?.moral_alignment_score ?? -1) - (a.sheet?.moral_alignment_score ?? -1) || (a.name || '').localeCompare(b.name || ''); },
|
||||
@ -421,6 +427,41 @@ async function libraryRenderCharacters() {
|
||||
// Wire voice selectors and auto-assign buttons
|
||||
_wireCharCards(prod, byId, chars);
|
||||
|
||||
// Keep the Voice Library's review metadata available where casting
|
||||
// decisions are made. Ratings and notes belong to the assigned voice
|
||||
// (not the character), so edits write through the same saveMeta path as
|
||||
// the Voice Library and immediately stay consistent in both views.
|
||||
prod.querySelectorAll('.lib-chars-tbl-rating-star').forEach(function (star) {
|
||||
star.addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
const voiceId = star.dataset.voiceId;
|
||||
const voice = (window._voices || []).find(function (v) { return v.id === voiceId; });
|
||||
if (!voice || typeof saveMeta !== 'function') return;
|
||||
const value = Number(star.dataset.rating || 0);
|
||||
voice.rating = value === Number(voice.rating || 0) ? 0 : value;
|
||||
prod.querySelectorAll('.lib-chars-tbl-rating-star').forEach(function (btn) {
|
||||
if (btn.dataset.voiceId === voiceId) btn.classList.toggle('is-on', Number(btn.dataset.rating) <= voice.rating);
|
||||
});
|
||||
try { await saveMeta(voiceId, { rating: voice.rating }); }
|
||||
catch (err) { toast('Could not save voice rating: ' + (err.message || err), 'error'); }
|
||||
});
|
||||
});
|
||||
prod.querySelectorAll('.lib-chars-tbl-voice-note').forEach(function (input) {
|
||||
let timer = null;
|
||||
input.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
input.addEventListener('input', function () {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(async function () {
|
||||
const voiceId = input.dataset.voiceId;
|
||||
const voice = (window._voices || []).find(function (v) { return v.id === voiceId; });
|
||||
if (!voice || typeof saveMeta !== 'function') return;
|
||||
voice.note = input.value;
|
||||
try { await saveMeta(voiceId, { note: voice.note }); }
|
||||
catch (err) { toast('Could not save voice note: ' + (err.message || err), 'error'); }
|
||||
}, 600);
|
||||
});
|
||||
});
|
||||
|
||||
productions.appendChild(prod);
|
||||
});
|
||||
container.appendChild(productions);
|
||||
@ -821,7 +862,16 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
const sh = rec.sheet || {};
|
||||
const hue = _charHue(rec.name);
|
||||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||||
const voiceMeta = voiceId ? (window._voices || []).find(function (v) { return v.id === voiceId; }) : null;
|
||||
const voiceMissing = !!voiceId && !_voiceExists(voiceId);
|
||||
const voiceRating = Math.max(0, Math.min(5, Number(voiceMeta?.rating || 0)));
|
||||
const voiceNote = _libStr(voiceMeta?.note);
|
||||
const voicePrompt = _libStr(voiceMeta?.voice_design_prompt || sh.voice_design_prompt);
|
||||
const stars = voiceId
|
||||
? '<div class="lib-chars-tbl-rating" role="group" aria-label="Voice rating">' + [1, 2, 3, 4, 5].map(function (n) {
|
||||
return '<button type="button" class="lib-chars-tbl-rating-star' + (n <= voiceRating ? ' is-on' : '') + '" data-voice-id="' + escHtml(voiceId) + '" data-rating="' + n + '" title="Rate ' + n + ' of 5"><span class="mdi mdi-star"></span></button>';
|
||||
}).join('') + '</div>'
|
||||
: '<span class="lib-chars-tbl-dash">—</span>';
|
||||
const voiceLang = _charLangLabel(rec);
|
||||
const tier = String(sh.tier || '').toLowerCase();
|
||||
const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
|
||||
@ -865,6 +915,11 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
+ (voiceId ? '<button class="lib-char-remove-voice btn-sm" title="Remove this voice — leaves the character unassigned" aria-label="Remove voice"><span class="mdi mdi-close"></span></button>' : '')
|
||||
+ '</div>'
|
||||
+ '</div></td>'
|
||||
+ '<td class="lib-chars-tbl-rating-cell" onclick="event.stopPropagation()">' + stars + '</td>'
|
||||
+ '<td class="lib-chars-tbl-prompt" title="' + escHtml(voicePrompt) + '">' + (voicePrompt ? escHtml(voicePrompt) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||||
+ '<td class="lib-chars-tbl-note-cell" onclick="event.stopPropagation()">'
|
||||
+ (voiceId ? '<input type="text" class="lib-chars-tbl-voice-note" data-voice-id="' + escHtml(voiceId) + '" value="' + escHtml(voiceNote) + '" placeholder="Add note…" title="Voice Library note">' : '<span class="lib-chars-tbl-dash">—</span>')
|
||||
+ '</td>'
|
||||
+ '<td class="lib-chars-tbl-tags"><div class="lib-chars-tbl-tags-wrap">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</div></td>'
|
||||
+ '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
|
||||
+ '</tr>';
|
||||
@ -877,11 +932,12 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
// the same row. Fixed explicit widths sidestep the whole class of bug.
|
||||
+ '<colgroup><col style="width:28px"><col style="width:60px"><col style="width:300px">'
|
||||
+ '<col style="width:88px"><col style="width:80px"><col style="width:72px"><col style="width:100px">'
|
||||
+ '<col style="width:140px"><col style="width:230px"><col style="width:220px"><col style="width:200px">'
|
||||
+ '<col style="width:140px"><col style="width:230px"><col style="width:126px"><col style="width:360px"><col style="width:260px"><col style="width:220px"><col style="width:200px">'
|
||||
+ '<col style="width:36px"></colgroup>'
|
||||
+ '<thead><tr>'
|
||||
+ '<th class="lib-chars-tbl-cb"><input type="checkbox" class="lib-chars-tbl-select-all-cb" title="Select all"></th><th></th>' + th('alpha', 'Name') + th('gender', 'Geschlecht') + th('age', 'Alter', 'Estimated age')
|
||||
+ th('lines', 'Zeilen', 'Anzahl Zeilen') + th('language', 'Sprache') + th('align', 'Gut/Böse', 'Moralische Gesinnung') + th('voice', 'Stimme')
|
||||
+ th('lines', 'Zeilen', 'Anzahl Zeilen') + th('language', 'Sprache') + th('align', 'Gut/Böse', 'Moralische Gesinnung') + th('voice', 'Stimme') + th('rating', 'Rating', 'Rate the assigned voice')
|
||||
+ '<th title="The exact prompt used to create the assigned voice">Voice Design Prompt</th><th>Notes</th>'
|
||||
+ '<th>Tags</th><th>Book / Script</th><th></th>'
|
||||
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||
}
|
||||
@ -1009,6 +1065,7 @@ function _charLineCount(c) {
|
||||
|
||||
async function _charDetailPage(rec, allChars, opts) {
|
||||
opts = opts || {};
|
||||
window._libDetailOpts = opts;
|
||||
// Defaults to the Library grid's own list container/back-navigation, but
|
||||
// any caller showing character cards elsewhere (e.g. the fresh-recast
|
||||
// results grid on the Read Aloud page) can pass its own container + onBack
|
||||
@ -1042,13 +1099,8 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
|
||||
const avatarHtml = '<div class="lcd-avatar-wrap">'
|
||||
+ (rec.image
|
||||
? '<div class="lcd-avatar lcd-avatar-upload" title="Bild hochladen"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||||
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>')
|
||||
+ '<div class="lcd-avatar-actions">'
|
||||
+ '<button class="lcd-avatar-upload-btn" type="button" title="Von Festplatte hochladen"><span class="mdi mdi-upload"></span></button>'
|
||||
+ '<button class="lcd-avatar-online-btn" type="button" title="Im Internet suchen"><span class="mdi mdi-web"></span></button>'
|
||||
+ '<button class="lcd-avatar-gen-btn" type="button" title="Mit KI generieren"><span class="mdi mdi-creation"></span></button>'
|
||||
+ '</div>'
|
||||
? '<div class="lcd-avatar lcd-avatar-upload" title="Profilbild öffnen"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||||
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Profilbild öffnen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>')
|
||||
+ '</div>';
|
||||
|
||||
// Concept art (the auto-generated NPC design-sheet image, distinct from
|
||||
@ -1177,7 +1229,7 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
pg.style.gridColumn = '1 / -1';
|
||||
pg.innerHTML =
|
||||
'<div class="lib-cpg-main">'
|
||||
+ '<button class="lib-cpg-back"><span class="mdi mdi-arrow-left"></span> Alle Charaktere</button>'
|
||||
+ '<button type="button" class="lib-cpg-back"><span class="mdi mdi-arrow-left"></span> Alle Charaktere</button>'
|
||||
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + hue2 + ',58%,25%)">'
|
||||
+ avatarHtml
|
||||
+ '<div class="lcd-header-body">'
|
||||
@ -1196,11 +1248,12 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
+ conceptArtHtml
|
||||
+ '<div class="lcd-voice-top">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||||
+ (voiceId ? '<button type="button" class="lcd-preview-voice" title="Stimme anhören" aria-label="Stimme anhören"><span class="mdi mdi-play"></span></button>' : '')
|
||||
+ '<span class="lcd-voice-label">' + (voiceId ? (!_voiceExists(voiceId) ? '<span class="lcd-voice-missing" title="Stimme wurde aus der Voice Library gelöscht — bitte neu zuweisen"><span class="mdi mdi-alert-circle-outline"></span> ' + escHtml(voiceId) + '</span>' : escHtml(voiceId)) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||||
+ '<button type="button" class="lcd-gen-voice lcd-design-voice-icon" title="Voice Design Prompt bearbeiten und Stimme neu generieren" aria-label="Voice Design Prompt bearbeiten und Stimme neu generieren"><span class="mdi mdi-refresh"></span></button>'
|
||||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||||
+ '<button class="lcd-gen-voice">Design</button>'
|
||||
+ '<button class="lcd-clone-voice">Klonen</button>'
|
||||
+ '</div>'
|
||||
+ alignHtml
|
||||
@ -1258,65 +1311,19 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
|
||||
container.appendChild(pg);
|
||||
|
||||
pg.querySelector('.lib-cpg-back').addEventListener('click', function () { goBack(); });
|
||||
|
||||
const _lcdUploadAvatar = function () {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*';
|
||||
inp.onchange = async function () {
|
||||
const file = inp.files[0]; if (!file) return;
|
||||
const fr = new FileReader();
|
||||
fr.onload = async function (ev) {
|
||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||||
toast('Profilbild gespeichert', 'success');
|
||||
rec.image = ev.target.result;
|
||||
_syncVoicePictureFromChar(rec);
|
||||
const av = pg.querySelector('.lcd-avatar-upload');
|
||||
if (av) av.innerHTML = '<img src="' + ev.target.result + '" alt="' + escHtml(rec.name) + '">';
|
||||
};
|
||||
fr.readAsDataURL(file);
|
||||
};
|
||||
inp.click();
|
||||
};
|
||||
pg.querySelector('.lcd-avatar-upload').addEventListener('click', _lcdUploadAvatar);
|
||||
pg.querySelector('.lcd-avatar-upload-btn')?.addEventListener('click', function (e) { e.stopPropagation(); _lcdUploadAvatar(); });
|
||||
|
||||
// Opens an image search in a new tab for the user to browse and save an
|
||||
// image themselves (dragging it onto the avatar, or downloading and using
|
||||
// the Upload button) — no scraping/auto-fetch, since that'd need to pick a
|
||||
// result and copyright/likeness on a random web image is the user's call.
|
||||
pg.querySelector('.lcd-avatar-online-btn')?.addEventListener('click', function (e) {
|
||||
pg.querySelector('.lib-cpg-back').addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const q = [rec.name, rec.book, sh.archetype, 'character art'].filter(Boolean).join(' ');
|
||||
window.open('https://www.google.com/search?tbm=isch&q=' + encodeURIComponent(q), '_blank', 'noopener');
|
||||
Promise.resolve(goBack()).catch(function (err) {
|
||||
console.error('[character detail] back navigation failed', err);
|
||||
toast('Character list could not be opened', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
pg.querySelector('.lcd-avatar-gen-btn')?.addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
const btn = this;
|
||||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
|
||||
const prompt = _libStr(sh.image_prompt).trim() || (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
|
||||
if (!prompt) { toast('No image prompt to work from — generate the Character Image Prompt below first', 'error'); return; }
|
||||
const orig = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span>';
|
||||
try {
|
||||
const r = await fetch('/api/character-generate-image', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: prompt }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
||||
const d = await r.json();
|
||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.image);
|
||||
rec.image = d.image;
|
||||
_syncVoicePictureFromChar(rec);
|
||||
toast('Profile picture generated', 'success');
|
||||
_charDetailPage(rec, allChars, opts);
|
||||
} catch (err) {
|
||||
toast('Image generation failed: ' + (err.message || err), 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}
|
||||
// Both the initial letter and an existing portrait open the same clear
|
||||
// image workflow; clicking no longer launches an unexplained file picker.
|
||||
pg.querySelector('.lcd-avatar-upload')?.addEventListener('click', function () {
|
||||
_openAvatarLightbox(rec, function () { _charDetailPage(rec, allChars, opts); });
|
||||
});
|
||||
|
||||
pg.querySelector('.lcd-conceptart-gen')?.addEventListener('click', async function (e) {
|
||||
@ -1340,22 +1347,10 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
}
|
||||
});
|
||||
|
||||
pg.querySelector('.lcd-conceptart-img')?.addEventListener('click', function () {
|
||||
document.getElementById('conceptart-lightbox')?.remove();
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'conceptart-lightbox';
|
||||
ov.className = 'audiobook-overlay';
|
||||
ov.innerHTML = '<div class="audiobook-box conceptart-lightbox-box">'
|
||||
+ '<div class="audiobook-title"><span class="mdi mdi-image-frame"></span> ' + escHtml(rec.name) + ' — Konzeptbild'
|
||||
+ '<span style="flex:1"></span>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm" id="calb-close">Close</button>'
|
||||
+ '</div>'
|
||||
+ '<img class="conceptart-lightbox-img" src="' + sh.concept_art_image + '" alt="Concept art — ' + escHtml(rec.name) + '">'
|
||||
+ '</div>';
|
||||
document.body.appendChild(ov);
|
||||
const close = function () { ov.remove(); };
|
||||
ov.querySelector('#calb-close').addEventListener('click', close);
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
pg.querySelector('.lcd-conceptart-img, .lcd-conceptart-empty')?.addEventListener('click', function () {
|
||||
_openConceptArtLightbox(rec, function () {
|
||||
_charDetailPage(rec, allChars, opts);
|
||||
});
|
||||
});
|
||||
|
||||
pg.querySelectorAll('.lib-cpg-sidebar-item').forEach(function (item) {
|
||||
@ -1388,6 +1383,11 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
|
||||
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoiceInline(rec); });
|
||||
pg.querySelector('.lcd-clone-voice')?.addEventListener('click', function () { _charCloneVoice(rec); });
|
||||
pg.querySelector('.lcd-preview-voice')?.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_libPreviewCharVoice(rec, e.currentTarget);
|
||||
});
|
||||
|
||||
// Generation Prompts section: copy buttons + one-call generation of all four
|
||||
// external-tool prompts (Voice Design / Image / SillyTavern / Concept Art)
|
||||
@ -1565,10 +1565,10 @@ function _charDetailModal(rec, allChars) {
|
||||
+ '<div class="lcd-voice-top">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||||
+ '<span class="lcd-voice-label">' + (voiceId ? (!_voiceExists(voiceId) ? '<span class="lcd-voice-missing" title="Stimme wurde aus der Voice Library gelöscht — bitte neu zuweisen"><span class="mdi mdi-alert-circle-outline"></span> ' + escHtml(voiceId) + '</span>' : escHtml(voiceId)) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||||
+ '<button type="button" class="lcd-gen-voice lcd-design-voice-icon" title="Voice Design Prompt bearbeiten und Stimme neu generieren" aria-label="Voice Design Prompt bearbeiten und Stimme neu generieren"><span class="mdi mdi-refresh"></span></button>'
|
||||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||||
+ '<button class="lcd-gen-voice">Generieren</button>'
|
||||
+ '</div>'
|
||||
+ alignHtml
|
||||
+ '<div class="lcd-sections">'
|
||||
@ -1693,12 +1693,16 @@ function _openAvatarLightbox(rec, onSaved) {
|
||||
+ '<div class="alb-preview">'
|
||||
+ (rec.image
|
||||
? '<img id="alb-preview-img" src="' + rec.image + '" alt="' + escHtml(rec.name) + '">'
|
||||
: '<div class="alb-preview-empty"><span class="mdi mdi-account-outline"></span></div>')
|
||||
: '<div class="alb-preview-empty" style="background:hsl(' + _charHue(rec.name) + ',55%,38%)"><span>' + escHtml((rec.name || '?')[0].toUpperCase()) + '</span></div>')
|
||||
+ '</div>'
|
||||
+ '<div class="alb-actions">'
|
||||
+ '<div class="alb-quick-actions">'
|
||||
+ '<button type="button" class="btn-secondary" id="alb-upload-btn"><span class="mdi mdi-upload"></span> Upload local image</button>'
|
||||
+ '<button type="button" class="btn-primary" id="alb-gen-btn"><span class="mdi mdi-creation"></span> Regenerate</button>'
|
||||
+ '<button type="button" class="btn-secondary" id="alb-online-focus-btn"><span class="mdi mdi-web"></span> Add online image</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="alb-section">'
|
||||
+ '<div class="alb-section-label"><span class="mdi mdi-upload"></span> Von Festplatte hochladen</div>'
|
||||
+ '<input type="file" id="alb-file-input" accept="image/*">'
|
||||
+ '<input type="file" id="alb-file-input" accept="image/*" hidden>'
|
||||
+ '</div>'
|
||||
+ '<div class="alb-section">'
|
||||
+ '<div class="alb-section-label"><span class="mdi mdi-link-variant"></span> Bild-URL</div>'
|
||||
@ -1719,7 +1723,6 @@ function _openAvatarLightbox(rec, onSaved) {
|
||||
+ '<option value="pollinations">Pollinations.ai (free)</option>'
|
||||
+ '<option value="comfyui">Local ComfyUI</option>'
|
||||
+ '</select>'
|
||||
+ '<button type="button" class="btn-primary btn-sm" id="alb-gen-btn"><span class="mdi mdi-creation"></span> Generieren</button>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
@ -1729,6 +1732,11 @@ function _openAvatarLightbox(rec, onSaved) {
|
||||
document.body.appendChild(ov);
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) ov.remove(); });
|
||||
ov.querySelector('#alb-close').addEventListener('click', function () { ov.remove(); });
|
||||
ov.querySelector('#alb-upload-btn').addEventListener('click', function () { ov.querySelector('#alb-file-input').click(); });
|
||||
ov.querySelector('#alb-online-focus-btn').addEventListener('click', function () {
|
||||
ov.querySelector('#alb-url-input').focus();
|
||||
ov.querySelector('#alb-url-input').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
const setPreview = function (src) {
|
||||
ov.querySelector('.alb-preview').innerHTML = '<img id="alb-preview-img" src="' + src + '" alt="' + escHtml(rec.name) + '">';
|
||||
@ -2165,6 +2173,56 @@ function _hashPick(str, pool) {
|
||||
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||
return pool[h % pool.length];
|
||||
}
|
||||
|
||||
// A species or profession is only useful to a voice-design model when it is
|
||||
// translated into something a listener can actually hear. Sending it labels
|
||||
// such as "orc" or "blacksmith" alone led to the model producing the same
|
||||
// generic human voice for most of the cast. These are intentionally vocal
|
||||
// directions, not lore: they preserve intelligibility for an audiobook while
|
||||
// giving each kind of character a clearly different sound world.
|
||||
function _voiceAudibleSpeciesCue(value) {
|
||||
const v = String(value || '').toLowerCase();
|
||||
if (/\b(ork|orc|orcs)\b/.test(v)) return 'Orc: chest-led, gravel-edged resonance; clipped consonants, forceful breath, fully intelligible.';
|
||||
if (/\b(vampir|vampire)\b/.test(v)) return 'Vampire: cool velvety timbre, precise diction, restrained predatory calm.';
|
||||
if (/\b(elf|elv|elfe)\b/.test(v)) return 'Elf: clear airy resonance, precise musical diction, adult and grounded.';
|
||||
if (/\b(d[aä]mon|demon|teufel|devil)\b/.test(v)) return 'Demon: dark resonant timbre with a subtle otherworldly undertone; fully intelligible.';
|
||||
if (/\b(zwerg|dwarf)\b/.test(v)) return 'Dwarf: compact earthy resonance, sturdy cadence, deliberate articulation.';
|
||||
if (/\b(fee|fae|geist|spirit|untot|undead)\b/.test(v)) return 'Otherworldly: unusual natural resonance, measured breath, emotionally believable.';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _voiceAudibleRoleCue(sheet) {
|
||||
const v = [sheet.profession, sheet.title, sheet.archetype, sheet.social_class, sheet.capabilities].filter(Boolean).join(' ').toLowerCase();
|
||||
if (/krieger|soldat|ritter|wache|kämpfer|warrior|soldier|knight|guard|general|anführer|commander/.test(v)) return 'Warrior/commander: grounded projection, disciplined breath, concise command cadence.';
|
||||
if (/schmied|blacksmith|handwerk|artisan|craft/.test(v)) return 'Craftsperson: practical work-worn texture, measured pace, plain-spoken certainty.';
|
||||
if (/magier|zauber|hexe|hexer|wizard|mage|sorcer|druid/.test(v)) return 'Magic-user: measured phrasing, precise diction, contained intensity.';
|
||||
if (/priester|inquis|mönch|monk|priest|cleric|prophet/.test(v)) return 'Religious authority: formal diction, calm conviction, weighted pauses.';
|
||||
if (/könig|königin|baron|graf|adel|noble|lord|lady|herrscher|herrscherin/.test(v)) return 'High-status authority: polished diction, composed pace, effortless command.';
|
||||
if (/dieb|spion|assassin|schurke|rogue|thief/.test(v)) return 'Covert operator: quiet confidence, economical phrasing, alert timing.';
|
||||
if (/gelehrt|scholar|lehrer|teacher|arzt|heiler|healer/.test(v)) return 'Learned professional: articulate, patient pace, observant and assured.';
|
||||
return '';
|
||||
}
|
||||
|
||||
function _voiceIdentityPrompt(rec, sourcePrompt, langName) {
|
||||
const sh = rec.sheet || {};
|
||||
const rawGender = String(sh.gender || _genderFromGenericName(rec.name) || '').toLowerCase();
|
||||
const gender = rawGender.startsWith('f') ? 'female' : rawGender.startsWith('m') ? 'male' : rawGender.startsWith('non') ? 'androgynous/nonbinary' : '';
|
||||
const hardGender = gender
|
||||
? 'Adult ' + gender + ' voice, clearly ' + (gender === 'female' ? 'feminine and never masculine-coded' : gender === 'male' ? 'masculine and never feminine-coded' : 'androgynous') + '.'
|
||||
: '';
|
||||
const age = String(sh.age_estimate || '').trim();
|
||||
const ageCue = age ? 'Age: ' + age.slice(0, 36) + '.' : '';
|
||||
const lang = String(langName || '').trim();
|
||||
const languageCue = lang ? (lang.toLowerCase() === 'english' ? 'Neutral international English.' : 'Native ' + lang + ' delivery, never American-accented.') : '';
|
||||
const species = _voiceAudibleSpeciesCue([sh.race_species, sh.archetype, sh.profession, sh.aliases].filter(Boolean).join(' '));
|
||||
const role = _voiceAudibleRoleCue(sh);
|
||||
const distinct = 'Distinct natural audiobook voice; no imitation.';
|
||||
const prefix = [hardGender, ageCue, languageCue, species, role, distinct].filter(Boolean).join(' ');
|
||||
const base = String(sourcePrompt || '').replace(/\s+/g, ' ').trim();
|
||||
const room = Math.max(0, 480 - prefix.length - (prefix && base ? 1 : 0));
|
||||
return (prefix + (base ? ' ' + base.slice(0, room) : '')).slice(0, 480);
|
||||
}
|
||||
|
||||
function _buildVoicePrompt(rec, profile, langName) {
|
||||
const sh = rec.sheet || {};
|
||||
const g = String(sh.gender || '').toLowerCase();
|
||||
@ -2365,7 +2423,7 @@ async function _charDesignVoice(rec) {
|
||||
_selectLoose(document.getElementById('design-gender'), sh.gender);
|
||||
_selectLoose(document.getElementById('design-language'), lang);
|
||||
const instruct = document.getElementById('design-instruct');
|
||||
if (instruct) instruct.value = savedPrompt || _buildVoicePrompt(rec, null, lang);
|
||||
if (instruct) instruct.value = _voiceIdentityPrompt(rec, savedPrompt || _buildVoicePrompt(rec, null, lang), lang);
|
||||
const nm = document.getElementById('design-preset-name');
|
||||
if (nm) nm.value = rec.name;
|
||||
}, 140);
|
||||
@ -2384,7 +2442,7 @@ function _charDesignVoiceInline(rec) {
|
||||
const sh = rec.sheet || {};
|
||||
const lang = _charLang(rec);
|
||||
const savedPrompt = _libStr(sh.voice_design_prompt).trim();
|
||||
const instruct = savedPrompt || _buildVoicePrompt(rec, null, lang);
|
||||
const instruct = _voiceIdentityPrompt(rec, savedPrompt || _buildVoicePrompt(rec, null, lang), lang);
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'audiobook-overlay';
|
||||
ov.innerHTML = '<div class="audiobook-box" style="max-width:560px;">'
|
||||
@ -2410,9 +2468,22 @@ function _charDesignVoiceInline(rec) {
|
||||
try {
|
||||
await _charAutoDesignVoice(rec, true, text);
|
||||
_schedulePendingTtsRestart();
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
rec.sheet.voice_design_prompt = text;
|
||||
close();
|
||||
toast('New voice designed for ' + rec.name, 'success');
|
||||
if (typeof libraryRenderCharacters === 'function') libraryRenderCharacters();
|
||||
// Preserve the open profile. Re-rendering the entire library here can
|
||||
// replace the detail page while the async save is finishing, leaving a
|
||||
// blank-looking profile until the user navigates away and back.
|
||||
const detailPage = document.querySelector('.lib-char-page');
|
||||
const detailOpts = window._libDetailOpts;
|
||||
if (detailPage && detailOpts && typeof _charDetailPage === 'function') {
|
||||
const all = typeof clGetAll === 'function' ? await clGetAll().catch(function () { return []; }) : [];
|
||||
const updated = all.find(function (r) { return r.id === rec.id; }) || rec;
|
||||
_charDetailPage(updated, all.filter(function (r) { return r.book === updated.book; }), detailOpts);
|
||||
} else if (typeof libraryRenderCharacters === 'function') {
|
||||
await libraryRenderCharacters();
|
||||
}
|
||||
if (typeof _libRefreshDetailModal === 'function') _libRefreshDetailModal(rec);
|
||||
} catch (err) {
|
||||
toast('Voice design failed: ' + (err.message || err), 'error');
|
||||
@ -2599,8 +2670,9 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
|
||||
}
|
||||
const sh = rec.sheet || {};
|
||||
const langName = (await _resolveBookLang(rec)) || 'English';
|
||||
const instruct = instructOverride || _buildVoicePrompt(rec, await _getBookProfile(rec.book), langName);
|
||||
const instruct = _voiceIdentityPrompt(rec, instructOverride || _libStr(sh.voice_design_prompt).trim() || _buildVoicePrompt(rec, await _getBookProfile(rec.book), langName), langName);
|
||||
if (!instruct.trim()) throw new Error('No character description to design a voice from yet');
|
||||
if (instructOverride) sh.voice_design_prompt = instruct;
|
||||
const langCode = (typeof DESIGN_LANG_CODE !== 'undefined' && DESIGN_LANG_CODE[langName]) || 'EN';
|
||||
// A generic/minor entry the casting pass extracted from a plain noun in
|
||||
// the text ("die Frau", "der Mann", "ein Bote") rather than a real named
|
||||
@ -2815,9 +2887,105 @@ async function _charAutoGenerateConceptArt(rec, provider) {
|
||||
return d.image;
|
||||
}
|
||||
|
||||
// Concept art uses the same editable image workflow as profile portraits,
|
||||
// but stores its result in sheet.concept_art_image so the portrait is never
|
||||
// replaced accidentally.
|
||||
function _openConceptArtLightbox(rec, onSaved) {
|
||||
document.getElementById('conceptart-lightbox')?.remove();
|
||||
const sh = rec.sheet || {};
|
||||
const prompt = _libStr(sh.concept_art_prompt).trim();
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'conceptart-lightbox';
|
||||
ov.className = 'audiobook-overlay';
|
||||
ov.innerHTML = '<div class="audiobook-box conceptart-lightbox-box conceptart-editor-box">'
|
||||
+ '<div class="audiobook-title"><span class="mdi mdi-image-frame"></span> ' + escHtml(rec.name) + ' — Konzeptbild'
|
||||
+ '<span style="flex:1"></span>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm" id="calb-close">Close</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="conceptart-editor-preview">'
|
||||
+ (sh.concept_art_image
|
||||
? '<img class="conceptart-lightbox-img" src="' + sh.concept_art_image + '" alt="Concept art — ' + escHtml(rec.name) + '">'
|
||||
: '<div class="conceptart-editor-empty"><span class="mdi mdi-image-frame"></span><span>No concept art yet</span></div>')
|
||||
+ '</div>'
|
||||
+ '<div class="conceptart-quick-actions">'
|
||||
+ '<button type="button" class="btn-secondary" id="calb-upload"><span class="mdi mdi-upload"></span> Upload local image</button>'
|
||||
+ '<button type="button" class="btn-primary" id="calb-regenerate"><span class="mdi mdi-creation"></span> Regenerate</button>'
|
||||
+ '<button type="button" class="btn-secondary" id="calb-online"><span class="mdi mdi-web"></span> Add online image</button>'
|
||||
+ '</div>'
|
||||
+ '<input type="file" id="calb-file" accept="image/*" hidden>'
|
||||
+ '<div class="conceptart-editor-tools">'
|
||||
+ '<div class="alb-section-label"><span class="mdi mdi-link-variant"></span> Image URL</div>'
|
||||
+ '<div class="alb-url-row"><input type="text" id="calb-url" placeholder="https://…"><button type="button" class="btn-secondary btn-sm" id="calb-url-btn">Download</button></div>'
|
||||
+ '<div class="alb-section-label"><span class="mdi mdi-script-text-outline"></span> Concept art prompt</div>'
|
||||
+ '<textarea id="calb-prompt" rows="5" spellcheck="false" placeholder="Generate the concept-art prompt first…">' + escHtml(prompt) + '</textarea>'
|
||||
+ '<div class="alb-gen-row"><select id="calb-provider"><option value="">(Active Provider)</option><option value="openai">OpenAI</option><option value="google">Google</option><option value="openrouter">OpenRouter</option><option value="pollinations">Pollinations.ai (free)</option><option value="comfyui">Local ComfyUI</option></select></div>'
|
||||
+ '</div>'
|
||||
+ '<div id="calb-status" class="llm-active-status"></div>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(ov);
|
||||
const close = function () { ov.remove(); };
|
||||
const status = function (msg, cls) {
|
||||
const el = ov.querySelector('#calb-status');
|
||||
el.textContent = msg || '';
|
||||
el.className = 'llm-active-status' + (cls ? ' ' + cls : '');
|
||||
};
|
||||
const setPreview = function (src) {
|
||||
ov.querySelector('.conceptart-editor-preview').innerHTML = '<img class="conceptart-lightbox-img" src="' + src + '" alt="Concept art — ' + escHtml(rec.name) + '">';
|
||||
};
|
||||
const commit = async function (dataUri) {
|
||||
const current = typeof clGet === 'function' ? await clGet(rec.id).catch(function () { return null; }) : null;
|
||||
const target = current || rec;
|
||||
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: dataUri });
|
||||
target.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(target);
|
||||
rec.sheet = target.sheet;
|
||||
setPreview(dataUri);
|
||||
toast('Concept art saved', 'success');
|
||||
if (typeof onSaved === 'function') onSaved();
|
||||
};
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
ov.querySelector('#calb-close').addEventListener('click', close);
|
||||
ov.querySelector('#calb-upload').addEventListener('click', function () { ov.querySelector('#calb-file').click(); });
|
||||
ov.querySelector('#calb-online').addEventListener('click', function () {
|
||||
ov.querySelector('#calb-url').focus();
|
||||
ov.querySelector('#calb-url').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
ov.querySelector('#calb-file').addEventListener('change', function () {
|
||||
const file = this.files[0]; if (!file) return;
|
||||
const fr = new FileReader();
|
||||
fr.onload = function (e) { commit(e.target.result).catch(function (err) { toast('Upload failed: ' + err.message, 'error'); }); };
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
ov.querySelector('#calb-url-btn').addEventListener('click', async function () {
|
||||
const url = ov.querySelector('#calb-url').value.trim();
|
||||
if (!url) { toast('Enter an image URL', 'error'); return; }
|
||||
const btn = this; btn.disabled = true; status('Downloading…');
|
||||
try {
|
||||
const r = await fetch('/api/character-image-from-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url }) });
|
||||
const d = await r.json(); if (!r.ok) throw new Error(d.detail || r.statusText);
|
||||
await commit(d.image); status('✓ Downloaded', 'ok');
|
||||
} catch (e) { status('Failed', 'err'); toast('Download failed: ' + e.message, 'error'); }
|
||||
finally { btn.disabled = false; }
|
||||
});
|
||||
ov.querySelector('#calb-regenerate').addEventListener('click', async function () {
|
||||
const nextPrompt = ov.querySelector('#calb-prompt').value.trim();
|
||||
if (!nextPrompt) { toast('Generate or enter a concept-art prompt first', 'error'); return; }
|
||||
const btn = this; const orig = btn.innerHTML; btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…'; status('Generating…');
|
||||
try {
|
||||
sh.concept_art_prompt = nextPrompt;
|
||||
const provider = ov.querySelector('#calb-provider').value;
|
||||
const img = await _charAutoGenerateConceptArt(rec, provider);
|
||||
await commit(img); status('✓ Generated', 'ok');
|
||||
} catch (e) { status('Failed', 'err'); toast('Generation failed: ' + e.message, 'error'); }
|
||||
finally { btn.disabled = false; btn.innerHTML = orig; }
|
||||
});
|
||||
}
|
||||
|
||||
window._charSearchOnline = _charSearchOnline;
|
||||
window._charDesignVoice = _charDesignVoice;
|
||||
window._charAutoDesignVoice = _charAutoDesignVoice;
|
||||
window._charAutoGenerateImage = _charAutoGenerateImage;
|
||||
window._charAutoGenerateConceptArt = _charAutoGenerateConceptArt;
|
||||
window._openConceptArtLightbox = _openConceptArtLightbox;
|
||||
window.libraryRenderCharacters = libraryRenderCharacters;
|
||||
|
||||
@ -336,21 +336,62 @@ document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(functio
|
||||
// Rehearser" still showing) sitting back at the bottom. Watch for it instead
|
||||
// of relocating once.
|
||||
let _stuCastFootObserver = null;
|
||||
let _stuCastFootHealthTimer = null;
|
||||
|
||||
// The Studio controls must never depend on the borrowed audiobook footer.
|
||||
// audiobookCastView rebuilds that footer during several async transitions,
|
||||
// which previously left this toolbar empty because the plain Studio buttons
|
||||
// had already been hidden. The only time they may be hidden is while a cast
|
||||
// job is actually running; in that state the borrowed footer exposes Cancel.
|
||||
function _stuCastIsRunning() {
|
||||
return typeof _audiobook !== 'undefined' && !!_audiobook.running;
|
||||
}
|
||||
|
||||
function _stuSyncCastFallbackControls(tabs, innerTabs, hasActionFooter) {
|
||||
const running = _stuCastIsRunning();
|
||||
tabs.hidden = false;
|
||||
tabs.style.display = 'flex';
|
||||
innerTabs.forEach(function (tab) {
|
||||
// The real three-menu action bar replaces these two simple navigation
|
||||
// buttons only after its completed-cast actions exist. Until then they
|
||||
// remain the reliable fallback, rather than leaving an empty toolbar.
|
||||
const hideFallback = running || hasActionFooter;
|
||||
tab.hidden = hideFallback;
|
||||
tab.style.display = hideFallback ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
function _stuRelocateCastFoot() {
|
||||
_stuTryRelocateCastFoot();
|
||||
if (_stuCastFootObserver) return;
|
||||
const slot = document.getElementById('stu-cast-slot');
|
||||
if (!slot) return;
|
||||
// The audiobook panel is rebuilt asynchronously and can briefly be moved
|
||||
// through its native reader container before Studio's borrow completes.
|
||||
// Observe the document, not only the slot, so a freshly-created footer is
|
||||
// never stranded outside Studio's visible action strip.
|
||||
_stuCastFootObserver = new MutationObserver(function () { _stuTryRelocateCastFoot(); });
|
||||
_stuCastFootObserver.observe(slot, { childList: true, subtree: true });
|
||||
_stuCastFootObserver.observe(document.body, { childList: true, subtree: true });
|
||||
[0, 100, 300, 800, 1500].forEach(ms => setTimeout(_stuTryRelocateCastFoot, ms));
|
||||
// Async restore/recast paths can replace the footer without producing a
|
||||
// useful childList mutation in the Studio slot. Keep a lightweight health
|
||||
// check while Studio is active so the toolbar can never remain blank.
|
||||
_stuCastFootHealthTimer = setInterval(function () {
|
||||
if (_stuIsActive && _stuActive === 2) _stuTryRelocateCastFoot();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function _stuTryRelocateCastFoot() {
|
||||
const slot = document.getElementById('stu-cast-slot');
|
||||
const tabs = document.getElementById('stu-cast-inner-tabs');
|
||||
if (!tabs) return;
|
||||
const freshFoot = slot ? slot.querySelector('#ab-cv-foot') : null;
|
||||
const alreadyRelocated = tabs.querySelector('#ab-cv-foot');
|
||||
const freshFoot = (slot && slot.querySelector('#ab-cv-foot')) || document.querySelector('#ab-cv-foot');
|
||||
const innerTabs = Array.from(tabs.querySelectorAll(':scope > .stu-inner-tab'));
|
||||
// Do not take the footer while audiobookCastView is still building it.
|
||||
// Its initial Cancel/placeholder buttons are replaced asynchronously by
|
||||
// the completed-cast Identify / Cast Characters / Cast menus. Relocating
|
||||
// that early steals the node from view.complete(), so its submenu buttons
|
||||
// can never be installed and Studio ends up with only the fallback tabs.
|
||||
const footerHasActions = freshFoot && freshFoot.querySelector('#ab-cv-menu-identify, #ab-cv-menu-cast, #ab-cv-menu-viewcast');
|
||||
_stuSyncCastFallbackControls(tabs, innerTabs, !!footerHasActions);
|
||||
// Transient states (e.g. "Checking saved cast before starting a new
|
||||
// one…") rebuild the panel WITHOUT a footer at all — confirmed live: the
|
||||
// plain tabs stayed hidden from an earlier relocation with nothing having
|
||||
@ -358,17 +399,28 @@ function _stuTryRelocateCastFoot() {
|
||||
// showing the plain tabs any time there's genuinely no footer to relocate
|
||||
// (neither a fresh one nor an already-relocated one), rather than only
|
||||
// ever hiding them and never re-checking.
|
||||
if (!freshFoot && !alreadyRelocated) {
|
||||
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; });
|
||||
// The footer is intentionally empty during some async rebuild phases.
|
||||
// Never hide the usable Studio buttons while that happens.
|
||||
if (!freshFoot || !footerHasActions) {
|
||||
return;
|
||||
}
|
||||
// A previous relocation can leave the footer in the toolbar but hidden
|
||||
// after audiobookCastView rebuilds its own panel. Always restore its
|
||||
// visibility when it is already in Studio's toolbar.
|
||||
if (freshFoot.parentElement === tabs) {
|
||||
freshFoot.hidden = false;
|
||||
freshFoot.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
if (!freshFoot || freshFoot.parentElement === tabs) return;
|
||||
tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); });
|
||||
tabs.hidden = false;
|
||||
tabs.style.display = 'flex';
|
||||
freshFoot.style.borderTop = 'none';
|
||||
freshFoot.style.padding = '0';
|
||||
freshFoot.style.justifyContent = 'flex-start';
|
||||
freshFoot.hidden = false;
|
||||
freshFoot.style.display = 'flex';
|
||||
tabs.appendChild(freshFoot);
|
||||
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = true; });
|
||||
const openReh = freshFoot.querySelector('#ab-cv-open-reh');
|
||||
if (openReh) openReh.hidden = true;
|
||||
}
|
||||
@ -380,6 +432,7 @@ function _stuTryRelocateCastFoot() {
|
||||
// panel itself goes home, so the footer rides back inside it intact.
|
||||
function _stuRestoreCastFoot() {
|
||||
if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; }
|
||||
if (_stuCastFootHealthTimer) { clearInterval(_stuCastFootHealthTimer); _stuCastFootHealthTimer = null; }
|
||||
const tabs = document.getElementById('stu-cast-inner-tabs');
|
||||
const panel = document.getElementById('reader-audiobook-panel');
|
||||
const foot = tabs ? tabs.querySelector('#ab-cv-foot') : null;
|
||||
|
||||
@ -609,7 +609,7 @@ audio { width: 100%; }
|
||||
|
||||
/* Table view — dense alternative to the card grid for scanning a large cast */
|
||||
.lib-chars-tbl-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; background:var(--surface); position:relative; }
|
||||
.lib-chars-tbl { width:100%; min-width:1662px; table-layout:fixed; border-collapse:collapse; font-size:12.9px; white-space:nowrap; }
|
||||
.lib-chars-tbl { width:100%; min-width:2410px; table-layout:fixed; border-collapse:collapse; font-size:12.9px; white-space:nowrap; }
|
||||
.lib-chars-tbl td { overflow: hidden; text-overflow: ellipsis; }
|
||||
.lib-chars-tbl thead th {
|
||||
text-align:left; padding:8px 8px; font-size:10.5px; font-weight:800; text-transform:uppercase;
|
||||
@ -686,6 +686,18 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
matching its own header entirely). Flex only the inner wrapper instead. */
|
||||
.lib-chars-tbl-tags { white-space:normal; max-width:260px; }
|
||||
.lib-chars-tbl-tags-wrap { display:flex; flex-wrap:wrap; gap:3px; }
|
||||
.lib-chars-tbl-rating { display:inline-flex; gap:1px; }
|
||||
.lib-chars-tbl-rating-star {
|
||||
border:0; background:transparent; padding:1px; color:#d1d5db; cursor:pointer; line-height:1;
|
||||
}
|
||||
.lib-chars-tbl-rating-star:hover, .lib-chars-tbl-rating-star.is-on { color:#f59e0b; }
|
||||
.lib-chars-tbl-prompt { color:var(--subtext); font-size:11.5px; font-style:italic; }
|
||||
.lib-chars-tbl-note-cell { padding:5px 8px !important; }
|
||||
.lib-chars-tbl-voice-note {
|
||||
width:100%; min-width:0; border:1px solid transparent; background:transparent; color:var(--text);
|
||||
padding:4px 5px; border-radius:5px; font:inherit;
|
||||
}
|
||||
.lib-chars-tbl-voice-note:hover, .lib-chars-tbl-voice-note:focus { border-color:var(--border); background:var(--panel); outline:none; }
|
||||
.lib-chars-tbl-dash { opacity:.4; }
|
||||
.lib-chars-tbl-check { display:inline-flex; }
|
||||
.lib-chars-tbl-check.is-yes { color:#4caf50; }
|
||||
@ -970,7 +982,7 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
.lib-char-card-banner { aspect-ratio: auto; height: auto; min-height: 120px; }
|
||||
.lib-char-avatar { width:82px; height:82px; font-size:30px; }
|
||||
.lib-char-name { font-size:18px; }
|
||||
.lib-chars-tbl { min-width: 1340px; }
|
||||
.lib-chars-tbl { min-width: 2410px; }
|
||||
}
|
||||
|
||||
/* ── Character detail modal (D&D / LARP card) ───────────────────────────────── */
|
||||
@ -1066,8 +1078,17 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
}
|
||||
.lcd-conceptart-empty .mdi { font-size: 30px; }
|
||||
.lcd-conceptart-empty.is-noprompt { opacity: .6; }
|
||||
.conceptart-lightbox-box { width: min(900px, 96vw); max-height: 94vh; overflow-y: auto; }
|
||||
.lcd-conceptart-empty { cursor:pointer; }
|
||||
.lcd-conceptart-empty:hover { border-color:var(--accent); }
|
||||
.conceptart-lightbox-box { width: min(980px, 96vw); max-height: 94vh; overflow-y: auto; }
|
||||
.conceptart-lightbox-img { width: 100%; height: auto; border-radius: 10px; display: block; }
|
||||
.conceptart-editor-preview { display:flex; justify-content:center; min-height:120px; margin-top:18px; }
|
||||
.conceptart-editor-preview .conceptart-lightbox-img { max-width:min(900px, 90vw); max-height:62vh; object-fit:contain; width:auto; }
|
||||
.conceptart-editor-empty { width:min(700px, 88vw); min-height:240px; border:2px dashed var(--border); border-radius:10px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; color:var(--subtext); }
|
||||
.conceptart-editor-empty .mdi { font-size:38px; }
|
||||
.conceptart-quick-actions { display:flex; justify-content:center; flex-wrap:wrap; gap:10px; margin:18px 0; }
|
||||
.conceptart-editor-tools { display:flex; flex-direction:column; gap:9px; padding:0 10px; }
|
||||
.conceptart-editor-tools textarea { width:100%; resize:vertical; padding:10px; font:inherit; min-height:120px; }
|
||||
.lcd-header-btns { display:flex; gap:8px; flex-shrink:0; align-self:flex-start; }
|
||||
.lcd-header-btns button {
|
||||
background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.32); color:#fff;
|
||||
@ -1200,7 +1221,7 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
background:var(--surface); display:flex; align-items:center; gap:12px; flex-wrap:wrap;
|
||||
}
|
||||
.lcd-voice-top .lcd-section-label { margin-bottom:0; flex-shrink:0; }
|
||||
.lcd-voice-top .lcd-voice-label { font-size:14px; flex:1; min-width:120px; }
|
||||
.lcd-voice-top .lcd-voice-label { font-size:14px; flex:0 1 auto; min-width:0; }
|
||||
.lcd-voice-top button {
|
||||
font-size:13px; padding:6px 16px; border-radius:7px; cursor:pointer;
|
||||
border:1px solid var(--border); background:var(--panel); color:var(--text);
|
||||
@ -1210,6 +1231,17 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
.lcd-voice-top .lcd-auto-voice:hover { border-color:var(--green,#22c55e); color:var(--green,#22c55e); }
|
||||
.lcd-voice-top .lcd-online-voice:hover { border-color:#0ea5e9; color:#0ea5e9; }
|
||||
.lcd-voice-top .lcd-gen-voice:hover { border-color:#a855f7; color:#a855f7; }
|
||||
.lcd-voice-top .lcd-preview-voice { display:inline-flex; align-items:center; justify-content:center; width:30px; height:30px; padding:0; border-radius:50%; }
|
||||
.lcd-voice-top .lcd-preview-voice { background:#2563eb; border-color:#2563eb; color:#fff; }
|
||||
.lcd-voice-top .lcd-preview-voice:hover { background:#1d4ed8; border-color:#1d4ed8; color:#fff; }
|
||||
.lcd-voice-top .lcd-preview-voice.playing { background:#1d4ed8; border-color:#1d4ed8; color:#fff; }
|
||||
.lcd-voice-top .lcd-preview-voice.loading { opacity:.65; cursor:wait; }
|
||||
.lcd-voice-top .lcd-design-voice-icon {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
width:30px; height:30px; padding:0; border-radius:50%;
|
||||
color:#7c3aed;
|
||||
}
|
||||
.lcd-voice-top .lcd-design-voice-icon:hover { background:#f3e8ff; border-color:#a855f7; color:#7c3aed; }
|
||||
/* Sources section */
|
||||
.lcd-sources {
|
||||
padding:20px 28px; background:var(--panel); border-top:1px solid var(--border);
|
||||
@ -6450,6 +6482,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
/* Audiobook casting overlay */
|
||||
.audiobook-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.audiobook-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 22px 24px; width: min(420px, 90vw); box-shadow: 0 12px 40px rgba(0,0,0,.25); }
|
||||
.audiobook-box.conceptart-lightbox-box { width: min(1120px, calc(100vw - 40px)); max-width: calc(100vw - 40px); }
|
||||
.audiobook-title { font-weight: 800; font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
||||
.audiobook-title .mdi { color: var(--accent); font-size: 20px; }
|
||||
.audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; }
|
||||
@ -6457,17 +6490,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.audiobook-box label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 600; color: var(--subtext); }
|
||||
.audiobook-box label input[type="text"] { font: inherit; font-weight: 400; color: var(--text); padding: 7px 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
|
||||
.audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.conceptart-editor-box .conceptart-editor-preview { width: 100%; }
|
||||
.conceptart-editor-box .conceptart-editor-preview .conceptart-lightbox-img { max-width: 100%; width: auto; }
|
||||
|
||||
/* Avatar lightbox — click any character avatar to open */
|
||||
.avatar-lightbox-box { width: min(1180px, 96vw); max-height: 92vh; overflow-y: auto; }
|
||||
.alb-body { display: flex; gap: 32px; margin-top: 20px; flex-wrap: wrap; }
|
||||
.alb-preview { flex: 0 0 440px; }
|
||||
.alb-preview img { width: 440px; height: 440px; object-fit: cover; border-radius: 14px; border: 1px solid var(--border); }
|
||||
.alb-body { display: flex; flex-direction: column; gap: 20px; margin-top: 20px; align-items: center; }
|
||||
.alb-preview { width: min(560px, 86vw); }
|
||||
.alb-preview img { width: min(560px, 86vw); height: min(560px, 86vw); min-width: 500px; min-height: 500px; object-fit: cover; border-radius: 14px; border: 1px solid var(--border); }
|
||||
.alb-preview-empty {
|
||||
width: 440px; height: 440px; border-radius: 14px; border: 1px dashed var(--border);
|
||||
width: min(560px, 86vw); height: min(560px, 86vw); min-width: 500px; min-height: 500px; border-radius: 14px; border: 1px dashed var(--border);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 120px; color: var(--subtext); opacity: .5;
|
||||
}
|
||||
.alb-actions { flex: 1; min-width: 340px; display: flex; flex-direction: column; gap: 22px; }
|
||||
.alb-actions { width: min(760px, 92vw); display: flex; flex-direction: column; gap: 22px; }
|
||||
.alb-quick-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
|
||||
.alb-section-label { font-size: 14px; font-weight: 700; color: var(--subtext); display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
||||
.alb-section input[type="file"] { font-size: 13.5px; width: 100%; }
|
||||
.alb-url-row { display: flex; gap: 8px; }
|
||||
@ -6477,11 +6513,15 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.alb-gen-row select { flex: 1; min-width: 0; font-size: 13.5px; padding: 8px 10px; }
|
||||
.alb-gen-row .btn-primary { font-size: 13.5px; padding: 9px 16px; }
|
||||
@media (max-width: 820px) {
|
||||
.alb-preview, .alb-preview img, .alb-preview-empty { flex-basis: auto; width: min(440px, 80vw); height: min(440px, 80vw); }
|
||||
.alb-preview, .alb-preview img, .alb-preview-empty { min-width: 0; width: min(500px, 86vw); height: min(500px, 86vw); }
|
||||
.alb-actions { min-width: 0; width: 100%; }
|
||||
}
|
||||
#alb-status { margin-top: 10px; }
|
||||
.ab-recast-select-list { max-height: 320px; overflow-y: auto; margin: 10px 0; text-align: left; border: 1px solid var(--border); border-radius: 6px; padding: 4px; }
|
||||
.audiobook-box.ab-recast-dialog { width: min(760px, calc(100vw - 40px)); max-width: calc(100vw - 40px); max-height: 90vh; }
|
||||
.ab-recast-select-list label.ab-char-item { display:flex; flex-direction:row; align-items:center; width:100%; box-sizing:border-box; }
|
||||
.ab-recast-select-list .ab-char-name { min-width:0; flex:1; text-align:left; }
|
||||
.ab-recast-select-list .ab-char-count { margin-left:auto; flex:0 0 auto; }
|
||||
.ab-recast-select-list .ab-char-item + .ab-char-item { margin-top: 2px; }
|
||||
.ab-recast-select-list .ab-recast-cb { flex-shrink: 0; width: 16px; height: 16px; cursor: pointer; }
|
||||
.ab-recast-select-list .ab-cv-empty { padding: 16px; text-align: center; color: var(--subtext); font-size: 13px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user