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
|
## [1.20.8] — 2026-08-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -685,7 +685,7 @@ def _charsheets_prepare(data: dict) -> dict:
|
|||||||
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
|
"- mannerisms: habitual gestures, tics, body language, habits and quirks\n"
|
||||||
"- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.\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_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. ")
|
+ ("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"
|
+ "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"
|
"- 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)
|
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")
|
@router.post("/api/character-sheets/stream")
|
||||||
async def character_sheets_stream(request: Request):
|
async def character_sheets_stream(request: Request):
|
||||||
"""Same job as /api/character-sheets, but streams the LLM's raw output
|
"""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 = (
|
preamble = (
|
||||||
"You are a prompt engineer who turns a fiction character's profile into ready-to-paste prompts for "
|
"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 "
|
"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"
|
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
|
# 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
|
# 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": (
|
"image_prompt": (
|
||||||
preamble
|
preamble
|
||||||
|
+ visual_identity_rule
|
||||||
+ "Produce exactly this field:\n"
|
+ "Produce exactly this field:\n"
|
||||||
"- image_prompt: a detailed English image-generation prompt for a full CHARACTER REFERENCE SHEET "
|
"- 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. "
|
"(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 "
|
"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 "
|
"(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 "
|
"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 "
|
"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 "
|
"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"
|
"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),
|
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
|
||||||
"concept_art_prompt": (
|
"concept_art_prompt": (
|
||||||
preamble
|
preamble
|
||||||
|
+ visual_identity_rule
|
||||||
+ "Produce exactly this field:\n"
|
+ "Produce exactly this field:\n"
|
||||||
"- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC "
|
"- 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, "
|
"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 "
|
"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 "
|
"(not a list). Use the following two reference layouts as the structural model, but replace EVERY "
|
||||||
"precisely rather than writing free-form:\n"
|
"subject fact with the current character profile:\n"
|
||||||
" Task: name the sheet type, e.g. 'Generate a character/NPC design sheet.'\n"
|
" Task: choose the best sheet purpose for the profile — a three-view character turnaround with "
|
||||||
" Subject: 'an original adult [role/archetype from the profile]' plus its 3-6 most visually "
|
"accessory callouts, or a character props/accessories callout sheet.\n"
|
||||||
"distinctive, ALREADY-ESTABLISHED traits (skin/hair, signature garment layers, and — only if the "
|
" Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
|
||||||
"profile actually gives this character a prop, weapon, or tool — its exact count, e.g. 'exactly one "
|
"content. Include every relevant visual fact from the profile: age, species, face, skin, hair, eyes, "
|
||||||
"quiver' or 'exactly two throwing knives'; omit props entirely if the profile has none).\n"
|
"facial hair, marks, build, clothing layers, colors, armor, jewelry, weapons, tools, and signature "
|
||||||
" Context: one clause on what the sheet is for and the story's genre/setting/era — infer this from "
|
"accessories. For each profile-established prop/accessory, state its exact count; omit anything not in "
|
||||||
"the book/profile the same way you would for a portrait (a fantasy Admiral is NOT a real-world 20th-"
|
"the profile. Never copy the herbalist/cartographer examples.\n"
|
||||||
"century Admiral) and state it explicitly, since an image model defaults to generic modern imagery "
|
" Context: state the book's profile-derived genre, setting, era, and the production purpose of the sheet.\n"
|
||||||
"otherwise.\n"
|
" Style: retain the reference style principles — detailed hand-painted or painterly game concept art, "
|
||||||
" Style: a concept-art style matching that genre/setting (painterly game concept art / detailed "
|
"grounded leather/metal/cloth/wood materials, clear readable shape language, and coherent wear — but "
|
||||||
"semi-realistic concept art / hand-painted concept art — pick what fits), grounded materials, clear "
|
"choose the visual medium and palette treatment appropriate to this book's profile.\n"
|
||||||
"shape language.\n"
|
" Composition: choose either (A) aligned full-body front, side, and back views across the top with a "
|
||||||
" Composition: full-body front, side, and back views across the top; below them, ONE clean row of "
|
"dialogue/action pose and one clean accessory callout row below, or (B) one full-body three-quarter "
|
||||||
"isolated callouts for this character's established props/costume components ONLY (skip this row "
|
"hero figure at left with exactly one isolated callout for each profile-established accessory at right. "
|
||||||
"entirely if the profile establishes no distinct props/costume pieces worth separating out) — state "
|
"Use clean leader lines, generous spacing, safe margins, and keep every figure and accessory fully visible.\n"
|
||||||
"the exact number of callouts and name each one.\n"
|
" Lighting: use warm-neutral or neutral studio light that reveals material texture, with soft contact "
|
||||||
" Lighting: neutral studio/museum-style light, no cinematic color cast.\n"
|
"shadows and no cinematic color cast unless the profile or book context explicitly requires one.\n"
|
||||||
" Constraints: face/silhouette/garment/prop consistency across every view; the exact prop count "
|
" Constraints: same exact gender, face identity anchor, body proportions, silhouette, garment seams, "
|
||||||
"restated; no duplicate gear, no extra limbs, no readable text/logos/watermark, no real-world/"
|
"colors, wear, accessory scale, attachment points, and prop count in every view; each accessory appears "
|
||||||
"franchise references, this is an ORIGINAL character not based on any copyrighted one.\n"
|
"once and matches the carried version; no duplicate gear, disassembled fragments, floating objects, extra "
|
||||||
" Output: one production-ready 3:2 reference sheet.\n\n"
|
"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'
|
'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:
|
if not api_key:
|
||||||
raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)")
|
raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)")
|
||||||
gmodel = model or "gemini-2.5-flash-image"
|
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:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"https://generativelanguage.googleapis.com/v1beta/models/{gmodel}:generateContent",
|
f"https://generativelanguage.googleapis.com/v1/models/{gmodel}:generateContent",
|
||||||
params={"key": api_key},
|
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
|
||||||
json={"contents": [{"parts": [{"text": prompt}]}]},
|
# 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,
|
timeout=120,
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
@ -1651,10 +1761,15 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
raise HTTPException(resp.status_code, f"Google image generation failed: {detail}")
|
raise HTTPException(resp.status_code, f"Google image generation failed: {detail}")
|
||||||
parts = (resp.json().get("candidates") or [{}])[0].get("content", {}).get("parts", [])
|
payload = resp.json()
|
||||||
inline = next((p.get("inlineData") for p in parts if p.get("inlineData")), None)
|
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:
|
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")
|
mime = inline.get("mimeType", "image/png")
|
||||||
return {"image": f"data:{mime};base64,{inline['data']}"}
|
return {"image": f"data:{mime};base64,{inline['data']}"}
|
||||||
except HTTPException:
|
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="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.20.35">
|
<meta name="app-version" content="1.20.36">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.20.35">
|
<link rel="stylesheet" href="/static/style.css?v=1.20.36">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -378,7 +378,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.20.35"></script>
|
<script src="/static/loader.js?v=1.20.36"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -24,6 +24,38 @@ const AUDIOBOOK_RECAST_CONTEXT_CHARS = 4200;
|
|||||||
const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6;
|
const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6;
|
||||||
const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000;
|
const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000;
|
||||||
const _audiobook = { running: false, cancel: false };
|
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;
|
window._audiobook = _audiobook;
|
||||||
let _abDraftAutosaveTimer = null;
|
let _abDraftAutosaveTimer = null;
|
||||||
let _abLastServerDraftErrorAt = 0;
|
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
|
// 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
|
// to one of these is better left Unknown: visibly unresolved, and still
|
||||||
// recoverable by the repair passes, instead of silently wrong.
|
// 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([
|
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',
|
'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',
|
'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',
|
'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">'
|
ov.innerHTML = '<div class="audiobook-box ab-recast-dialog">'
|
||||||
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Cast selected character roles</div>'
|
+ '<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="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">'
|
+ '<div class="ab-cv-side-tools">'
|
||||||
+ '<input type="text" class="ab-cv-side-search" id="ab-recast-search" placeholder="Search characters..." autocomplete="off">'
|
+ '<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();
|
render();
|
||||||
});
|
});
|
||||||
ov.querySelector('#ab-recast-confirm').addEventListener('click', async () => {
|
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; }
|
if (!names.length) { toast('Select at least one character', 'error'); return; }
|
||||||
ov.remove();
|
ov.remove();
|
||||||
if (typeof window.audiobookRecastSelectedCharacters === 'function') await window.audiobookRecastSelectedCharacters(names);
|
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
|
// 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.
|
// the preview — so it waits for you if you wandered off to do something else.
|
||||||
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
||||||
|
_abHideCastingControl();
|
||||||
if (count) count.hidden = true;
|
if (count) count.hidden = true;
|
||||||
const castBar = panel.querySelector('.ab-castpanel-bar');
|
const castBar = panel.querySelector('.ab-castpanel-bar');
|
||||||
if (castBar) castBar.hidden = true;
|
if (castBar) castBar.hidden = true;
|
||||||
@ -4406,7 +4451,7 @@ ABSOLUTE REGELN:
|
|||||||
castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
|
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-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-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 },
|
{ 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 },
|
{ 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);
|
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
|
||||||
},
|
},
|
||||||
done(options = {}) {
|
done(options = {}) {
|
||||||
|
_abHideCastingControl();
|
||||||
const stopped = options.stopped || _audiobook.cancel;
|
const stopped = options.stopped || _audiobook.cancel;
|
||||||
if (!stopped) { closePanel(); return; }
|
if (!stopped) { closePanel(); return; }
|
||||||
|
|
||||||
@ -4765,6 +4811,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {})
|
|||||||
} finally {
|
} finally {
|
||||||
_audiobook.running = false;
|
_audiobook.running = false;
|
||||||
_audiobook.abort = null;
|
_audiobook.abort = null;
|
||||||
|
_abHideCastingControl();
|
||||||
}
|
}
|
||||||
// Everything below only ever ran when nothing above threw — but nothing
|
// Everything below only ever ran when nothing above threw — but nothing
|
||||||
// here was itself guarded, so any failure in this post-processing (dedup,
|
// here was itself guarded, so any failure in this post-processing (dedup,
|
||||||
@ -5352,6 +5399,7 @@ async function audiobookCast(overrideUrl, overrideModel, resume) {
|
|||||||
_abStopDraftAutosave();
|
_abStopDraftAutosave();
|
||||||
_audiobook.running = false;
|
_audiobook.running = false;
|
||||||
_audiobook.abort = null;
|
_audiobook.abort = null;
|
||||||
|
_abHideCastingControl();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allSegments.length) {
|
if (!allSegments.length) {
|
||||||
|
|||||||
@ -43,7 +43,7 @@ For each character output these fields:
|
|||||||
- mannerisms: habitual gestures, tics, body language, habits and quirks
|
- mannerisms: habitual gestures, tics, body language, habits and quirks
|
||||||
- communication_style: how they communicate socially — blunt, formal, warm, guarded, sarcastic, etc.
|
- 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_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 '*'.
|
- 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)
|
- inventory: 1-3 defining items/props/clothing (array of short strings)
|
||||||
- secret: dark secret or fatal flaw
|
- 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 csLang() { return $('reh-design-lang')?.value || ''; }
|
||||||
function csReaderPageHost() { return $('reader-charsheets-panel'); }
|
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).
|
// Build page-annotated text from the current reader scope ([p.N] at page changes).
|
||||||
function csReaderText() {
|
function csReaderText() {
|
||||||
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
||||||
@ -289,6 +317,44 @@ function csSheetMatchesRoster(sheet, roster = []) {
|
|||||||
return false;
|
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) {
|
function csIdentityNeedles(value) {
|
||||||
return csSplitIdentityTokens(value, { aliases: true }).filter(v => {
|
return csSplitIdentityTokens(value, { aliases: true }).filter(v => {
|
||||||
const n = String(v || '').trim();
|
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
|
// this run (settled by Identify Characters or an earlier cast pass) as
|
||||||
// canonical, with the other name demoted to an alias — rather than letting
|
// canonical, with the other name demoted to an alias — rather than letting
|
||||||
// the character-sheet step invent a competing identity.
|
// the character-sheet step invent a competing identity.
|
||||||
function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated) {
|
function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster = []) {
|
||||||
let changed = true;
|
let changed = true;
|
||||||
while (changed) {
|
while (changed) {
|
||||||
changed = false;
|
changed = false;
|
||||||
@ -456,8 +522,18 @@ function csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated)
|
|||||||
// pass), fall back to whichever record is OLDER — the one Identify
|
// pass), fall back to whichever record is OLDER — the one Identify
|
||||||
// Characters / the original cast settled on first.
|
// Characters / the original cast settled on first.
|
||||||
const sEst = establishedKeys.has(key), oEst = establishedKeys.has(dupKey);
|
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;
|
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) {
|
else if (sEst === oEst) {
|
||||||
const filled = (sh) => CS_DETAIL_FIELDS.filter(f => _csStr(sh[f]).trim()).length;
|
const filled = (sh) => CS_DETAIL_FIELDS.filter(f => _csStr(sh[f]).trim()).length;
|
||||||
const sFilled = filled(s), oFilled = filled(other);
|
const sFilled = filled(s), oFilled = filled(other);
|
||||||
@ -757,8 +833,11 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
|
|||||||
}
|
}
|
||||||
} finally { prog.done(); }
|
} finally { prog.done(); }
|
||||||
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
|
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
|
||||||
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated);
|
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster);
|
||||||
const sheets = [...map.values()];
|
// 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;
|
if (cacheKey) _cs.cache[cacheKey] = sheets;
|
||||||
return sheets;
|
return sheets;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -1267,6 +1346,7 @@ function csAlignmentBar(score, arcDirection, arcNote) {
|
|||||||
function csBuildImagePrompt(s, bookProfile) {
|
function csBuildImagePrompt(s, bookProfile) {
|
||||||
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
|
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
if (s.gender) parts.push(`exactly ${s.gender}`);
|
||||||
if (s.race_species) parts.push(s.race_species);
|
if (s.race_species) parts.push(s.race_species);
|
||||||
if (s.archetype) parts.push(s.archetype);
|
if (s.archetype) parts.push(s.archetype);
|
||||||
if (s.physical) parts.push(s.physical);
|
if (s.physical) parts.push(s.physical);
|
||||||
@ -1289,6 +1369,9 @@ function csBuildImagePrompt(s, bookProfile) {
|
|||||||
+ `real-world default. `
|
+ `real-world default. `
|
||||||
: '';
|
: '';
|
||||||
return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
|
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
|
+ settingClause
|
||||||
+ `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — `
|
+ `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" `
|
+ `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);
|
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
|
||||||
const pageHost = csReaderPageHost();
|
const pageHost = csReaderPageHost();
|
||||||
if (!fresh && _cs.cache[key]) {
|
if (!fresh && _cs.cache[key]) {
|
||||||
await csSaveToLibrary(book, _cs.cache[key]);
|
const cached = csCanonicalizeRosterNames(_cs.cache[key], csKnownReaderRoster());
|
||||||
if (pageHost) await csShow(_cs.cache[key], book, text, book, pageHost);
|
_cs.cache[key] = cached;
|
||||||
|
await csSaveToLibrary(book, cached);
|
||||||
|
if (pageHost) await csShow(cached, book, text, book, pageHost);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const knownRoster = csKnownReaderRoster();
|
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 = [];
|
let seedSheets = [];
|
||||||
if (!fresh) try {
|
if (!fresh) try {
|
||||||
if (typeof clGetAllByTagOrBook === 'function') {
|
if (typeof clGetAllByTagOrBook === 'function') {
|
||||||
@ -2257,9 +2346,25 @@ async function csForReader(opts = {}) {
|
|||||||
// the CURRENT cast (knownRoster, from the live audiobook roster) no
|
// the CURRENT cast (knownRoster, from the live audiobook roster) no
|
||||||
// longer has them. Keep only saved records that still match a name in
|
// 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.
|
// 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 => !knownRoster.length || csSheetMatchesRoster({
|
||||||
const stillCast = rec => !rosterSet.size || rosterSet.has(String(rec?.name || '').trim().toLowerCase());
|
name: rec?.name || '',
|
||||||
seedSheets = (existing || []).filter(stillCast).map(rec => csSeedSheet(rec)).filter(s => String(s.name || '').trim());
|
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 = []; }
|
} catch (_) { seedSheets = []; }
|
||||||
// Computed up front (not just after the whole run finishes) so "Lines"
|
// 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.
|
// that race regardless of which async step caused it.
|
||||||
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
|
if (typeof showReaderView === 'function') setTimeout(() => showReaderView('chars'), 120);
|
||||||
const pageHost = csReaderPageHost();
|
const pageHost = csReaderPageHost();
|
||||||
|
const knownRoster = csKnownReaderRoster();
|
||||||
let records = [];
|
let records = [];
|
||||||
try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; }
|
try { records = (typeof clGetAllByTagOrBook === 'function') ? await clGetAllByTagOrBook(book) : []; } catch (_) { records = []; }
|
||||||
const picked = [];
|
const picked = [];
|
||||||
@ -2329,19 +2435,37 @@ async function csForReaderSelective(selectedNames) {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
(records || []).forEach(rec => {
|
// Exact canonical names always win. Alias matching is only a fallback for
|
||||||
if (!rec || !rec.name || !matchesWanted(rec)) return;
|
// old records that have no exact record; otherwise selecting Lysandra could
|
||||||
const key = String(rec.name || '').trim().toLowerCase();
|
// pick an Alrik profile whose generated aliases happened to contain the
|
||||||
if (seen.has(key)) return;
|
// word "Lysandra".
|
||||||
seen.add(key);
|
|
||||||
picked.push(rec);
|
|
||||||
});
|
|
||||||
(selectedNames || []).forEach(name => {
|
(selectedNames || []).forEach(name => {
|
||||||
const key = String(name || '').trim().toLowerCase();
|
const canonical = String(name || '').trim();
|
||||||
|
const key = canonical.toLowerCase();
|
||||||
if (!key || seen.has(key)) return;
|
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);
|
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 || '')));
|
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; }
|
if (!picked.length) { toast('No matching cast characters found', 'error'); return; }
|
||||||
@ -2349,13 +2473,16 @@ async function csForReaderSelective(selectedNames) {
|
|||||||
for (const rec of picked) {
|
for (const rec of picked) {
|
||||||
const targetName = String(rec.name || '').trim();
|
const targetName = String(rec.name || '').trim();
|
||||||
if (!targetName) continue;
|
if (!targetName) continue;
|
||||||
|
const rosterName = targetName;
|
||||||
const needles = csRecordNeedles(rec);
|
const needles = csRecordNeedles(rec);
|
||||||
const scanText = csEvidenceWindowText(csReaderParagraphBlocks(), needles, 2, 2) || text;
|
const scanText = csEvidenceWindowText(csReaderParagraphBlocks(), needles, 2, 2) || text;
|
||||||
const seedSheet = csSeedSheet(rec);
|
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;
|
if (!sheets) return;
|
||||||
const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase()));
|
const filtered = sheets.filter(s => wanted.has(String(s.name || '').trim().toLowerCase()));
|
||||||
if (!filtered.length) continue;
|
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; });
|
if (rec?.sheet?.line_count != null) filtered.forEach(s => { s.line_count = rec.sheet.line_count; });
|
||||||
csMerge(finalMap, filtered);
|
csMerge(finalMap, filtered);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -206,15 +206,25 @@ function clMergeTags(...parts) {
|
|||||||
async function clUpsert(book, sheet, knownId) {
|
async function clUpsert(book, sheet, knownId) {
|
||||||
const name = (sheet.name || '').trim();
|
const name = (sheet.name || '').trim();
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
const rosterNameAuthoritative = sheet._rosterNameAuthoritative === true;
|
||||||
const bk = (book || '').trim() || 'Unsorted';
|
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 clGet(knownId).catch(() => null)
|
||||||
: (await clGetAll().catch(() => [])).find(r => clSameIdentity(r, bk, sheet));
|
: (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 now = new Date();
|
||||||
const prev = aliasPrev || await clGet(id).catch(() => null);
|
const prev = aliasPrev || await clGet(id).catch(() => null);
|
||||||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name };
|
const sanitized = _clSanitize(sheet);
|
||||||
const canonicalName = prev?.name || name;
|
delete sanitized._rosterNameAuthoritative;
|
||||||
|
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sanitized, name };
|
||||||
|
const canonicalName = rosterNameAuthoritative ? name : (prev?.name || name);
|
||||||
merged.name = canonicalName;
|
merged.name = canonicalName;
|
||||||
const tags = clMergeTags(prev?.tags, sheet.tags, bk);
|
const tags = clMergeTags(prev?.tags, sheet.tags, bk);
|
||||||
const color = clNormalizeColor(prev?.color || prev?.sheet?.color || sheet.color, canonicalName);
|
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>
|
<button class="el-play" title="Play preview" data-preview="${prev}">▶ Play</button>
|
||||||
</div>
|
</div>
|
||||||
</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>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,15 +170,54 @@ function initElevenLabsBrowser() {
|
|||||||
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
|
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function elImport(btn) {
|
async function elImport(btn) {
|
||||||
const url = btn.dataset.preview;
|
const url = btn.dataset.preview;
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (typeof navTo === 'function') navTo('s-clone');
|
const original = btn.innerHTML;
|
||||||
setTimeout(() => {
|
btn.disabled = true;
|
||||||
const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn');
|
btn.innerHTML = '<span class="el-spinner"></span> Importing…';
|
||||||
if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); }
|
|
||||||
if (btn2) btn2.click();
|
const name = btn.dataset.name || 'ElevenLabs voice';
|
||||||
}, 120);
|
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();
|
elLoadKey();
|
||||||
|
|||||||
@ -97,6 +97,7 @@ async function libraryRenderCharacters() {
|
|||||||
['lines', 'Anzahl Zeilen'],
|
['lines', 'Anzahl Zeilen'],
|
||||||
['gender', 'Geschlecht'],
|
['gender', 'Geschlecht'],
|
||||||
['voice', 'Stimme zugewiesen'],
|
['voice', 'Stimme zugewiesen'],
|
||||||
|
['rating', 'Stimmenbewertung'],
|
||||||
];
|
];
|
||||||
const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); })
|
const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); })
|
||||||
? localStorage.getItem('ttsvc_libchars_sort') : 'tier';
|
? localStorage.getItem('ttsvc_libchars_sort') : 'tier';
|
||||||
@ -148,6 +149,11 @@ async function libraryRenderCharacters() {
|
|||||||
return ga.localeCompare(gb) || (a.name || '').localeCompare(b.name || '');
|
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 || ''); },
|
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 || ''); },
|
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 || ''); },
|
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 || ''); },
|
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
|
// Wire voice selectors and auto-assign buttons
|
||||||
_wireCharCards(prod, byId, chars);
|
_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);
|
productions.appendChild(prod);
|
||||||
});
|
});
|
||||||
container.appendChild(productions);
|
container.appendChild(productions);
|
||||||
@ -821,7 +862,16 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
|||||||
const sh = rec.sheet || {};
|
const sh = rec.sheet || {};
|
||||||
const hue = _charHue(rec.name);
|
const hue = _charHue(rec.name);
|
||||||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
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 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 voiceLang = _charLangLabel(rec);
|
||||||
const tier = String(sh.tier || '').toLowerCase();
|
const tier = String(sh.tier || '').toLowerCase();
|
||||||
const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
|
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>' : '')
|
+ (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>'
|
||||||
+ '</div></td>'
|
+ '</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 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>'
|
+ '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
@ -877,11 +932,12 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
|||||||
// the same row. Fixed explicit widths sidestep the whole class of bug.
|
// 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">'
|
+ '<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: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>'
|
+ '<col style="width:36px"></colgroup>'
|
||||||
+ '<thead><tr>'
|
+ '<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 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>'
|
+ '<th>Tags</th><th>Book / Script</th><th></th>'
|
||||||
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||||
}
|
}
|
||||||
@ -1009,6 +1065,7 @@ function _charLineCount(c) {
|
|||||||
|
|
||||||
async function _charDetailPage(rec, allChars, opts) {
|
async function _charDetailPage(rec, allChars, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
|
window._libDetailOpts = opts;
|
||||||
// Defaults to the Library grid's own list container/back-navigation, but
|
// Defaults to the Library grid's own list container/back-navigation, but
|
||||||
// any caller showing character cards elsewhere (e.g. the fresh-recast
|
// any caller showing character cards elsewhere (e.g. the fresh-recast
|
||||||
// results grid on the Read Aloud page) can pass its own container + onBack
|
// 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">'
|
const avatarHtml = '<div class="lcd-avatar-wrap">'
|
||||||
+ (rec.image
|
+ (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" 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="Bild hochladen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>')
|
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Profilbild öffnen">' + 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>';
|
+ '</div>';
|
||||||
|
|
||||||
// Concept art (the auto-generated NPC design-sheet image, distinct from
|
// 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.style.gridColumn = '1 / -1';
|
||||||
pg.innerHTML =
|
pg.innerHTML =
|
||||||
'<div class="lib-cpg-main">'
|
'<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%)">'
|
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + hue2 + ',58%,25%)">'
|
||||||
+ avatarHtml
|
+ avatarHtml
|
||||||
+ '<div class="lcd-header-body">'
|
+ '<div class="lcd-header-body">'
|
||||||
@ -1196,11 +1248,12 @@ async function _charDetailPage(rec, allChars, opts) {
|
|||||||
+ conceptArtHtml
|
+ conceptArtHtml
|
||||||
+ '<div class="lcd-voice-top">'
|
+ '<div class="lcd-voice-top">'
|
||||||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
+ '<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>'
|
+ '<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-pick-voice">Auswählen</button>'
|
||||||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||||||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||||||
+ '<button class="lcd-gen-voice">Design</button>'
|
|
||||||
+ '<button class="lcd-clone-voice">Klonen</button>'
|
+ '<button class="lcd-clone-voice">Klonen</button>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ alignHtml
|
+ alignHtml
|
||||||
@ -1258,65 +1311,19 @@ async function _charDetailPage(rec, allChars, opts) {
|
|||||||
|
|
||||||
container.appendChild(pg);
|
container.appendChild(pg);
|
||||||
|
|
||||||
pg.querySelector('.lib-cpg-back').addEventListener('click', function () { goBack(); });
|
pg.querySelector('.lib-cpg-back').addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
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) {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const q = [rec.name, rec.book, sh.archetype, 'character art'].filter(Boolean).join(' ');
|
Promise.resolve(goBack()).catch(function (err) {
|
||||||
window.open('https://www.google.com/search?tbm=isch&q=' + encodeURIComponent(q), '_blank', 'noopener');
|
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) {
|
// Both the initial letter and an existing portrait open the same clear
|
||||||
e.stopPropagation();
|
// image workflow; clicking no longer launches an unexplained file picker.
|
||||||
const btn = this;
|
pg.querySelector('.lcd-avatar-upload')?.addEventListener('click', function () {
|
||||||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
|
_openAvatarLightbox(rec, function () { _charDetailPage(rec, allChars, opts); });
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
pg.querySelector('.lcd-conceptart-gen')?.addEventListener('click', async function (e) {
|
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 () {
|
pg.querySelector('.lcd-conceptart-img, .lcd-conceptart-empty')?.addEventListener('click', function () {
|
||||||
document.getElementById('conceptart-lightbox')?.remove();
|
_openConceptArtLightbox(rec, function () {
|
||||||
const ov = document.createElement('div');
|
_charDetailPage(rec, allChars, opts);
|
||||||
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.querySelectorAll('.lib-cpg-sidebar-item').forEach(function (item) {
|
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-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
|
||||||
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoiceInline(rec); });
|
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoiceInline(rec); });
|
||||||
pg.querySelector('.lcd-clone-voice')?.addEventListener('click', function () { _charCloneVoice(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
|
// Generation Prompts section: copy buttons + one-call generation of all four
|
||||||
// external-tool prompts (Voice Design / Image / SillyTavern / Concept Art)
|
// 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-voice-top">'
|
||||||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
+ '<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>'
|
+ '<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-pick-voice">Auswählen</button>'
|
||||||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||||||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||||||
+ '<button class="lcd-gen-voice">Generieren</button>'
|
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ alignHtml
|
+ alignHtml
|
||||||
+ '<div class="lcd-sections">'
|
+ '<div class="lcd-sections">'
|
||||||
@ -1693,12 +1693,16 @@ function _openAvatarLightbox(rec, onSaved) {
|
|||||||
+ '<div class="alb-preview">'
|
+ '<div class="alb-preview">'
|
||||||
+ (rec.image
|
+ (rec.image
|
||||||
? '<img id="alb-preview-img" src="' + rec.image + '" alt="' + escHtml(rec.name) + '">'
|
? '<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>'
|
||||||
+ '<div class="alb-actions">'
|
+ '<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">'
|
||||||
+ '<div class="alb-section-label"><span class="mdi mdi-upload"></span> Von Festplatte hochladen</div>'
|
+ '<input type="file" id="alb-file-input" accept="image/*" hidden>'
|
||||||
+ '<input type="file" id="alb-file-input" accept="image/*">'
|
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '<div class="alb-section">'
|
+ '<div class="alb-section">'
|
||||||
+ '<div class="alb-section-label"><span class="mdi mdi-link-variant"></span> Bild-URL</div>'
|
+ '<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="pollinations">Pollinations.ai (free)</option>'
|
||||||
+ '<option value="comfyui">Local ComfyUI</option>'
|
+ '<option value="comfyui">Local ComfyUI</option>'
|
||||||
+ '</select>'
|
+ '</select>'
|
||||||
+ '<button type="button" class="btn-primary btn-sm" id="alb-gen-btn"><span class="mdi mdi-creation"></span> Generieren</button>'
|
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@ -1729,6 +1732,11 @@ function _openAvatarLightbox(rec, onSaved) {
|
|||||||
document.body.appendChild(ov);
|
document.body.appendChild(ov);
|
||||||
ov.addEventListener('click', function (e) { if (e.target === ov) ov.remove(); });
|
ov.addEventListener('click', function (e) { if (e.target === ov) ov.remove(); });
|
||||||
ov.querySelector('#alb-close').addEventListener('click', function () { 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) {
|
const setPreview = function (src) {
|
||||||
ov.querySelector('.alb-preview').innerHTML = '<img id="alb-preview-img" src="' + src + '" alt="' + escHtml(rec.name) + '">';
|
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;
|
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
||||||
return pool[h % pool.length];
|
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) {
|
function _buildVoicePrompt(rec, profile, langName) {
|
||||||
const sh = rec.sheet || {};
|
const sh = rec.sheet || {};
|
||||||
const g = String(sh.gender || '').toLowerCase();
|
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-gender'), sh.gender);
|
||||||
_selectLoose(document.getElementById('design-language'), lang);
|
_selectLoose(document.getElementById('design-language'), lang);
|
||||||
const instruct = document.getElementById('design-instruct');
|
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');
|
const nm = document.getElementById('design-preset-name');
|
||||||
if (nm) nm.value = rec.name;
|
if (nm) nm.value = rec.name;
|
||||||
}, 140);
|
}, 140);
|
||||||
@ -2384,7 +2442,7 @@ function _charDesignVoiceInline(rec) {
|
|||||||
const sh = rec.sheet || {};
|
const sh = rec.sheet || {};
|
||||||
const lang = _charLang(rec);
|
const lang = _charLang(rec);
|
||||||
const savedPrompt = _libStr(sh.voice_design_prompt).trim();
|
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');
|
const ov = document.createElement('div');
|
||||||
ov.className = 'audiobook-overlay';
|
ov.className = 'audiobook-overlay';
|
||||||
ov.innerHTML = '<div class="audiobook-box" style="max-width:560px;">'
|
ov.innerHTML = '<div class="audiobook-box" style="max-width:560px;">'
|
||||||
@ -2410,9 +2468,22 @@ function _charDesignVoiceInline(rec) {
|
|||||||
try {
|
try {
|
||||||
await _charAutoDesignVoice(rec, true, text);
|
await _charAutoDesignVoice(rec, true, text);
|
||||||
_schedulePendingTtsRestart();
|
_schedulePendingTtsRestart();
|
||||||
|
if (!rec.sheet) rec.sheet = {};
|
||||||
|
rec.sheet.voice_design_prompt = text;
|
||||||
close();
|
close();
|
||||||
toast('New voice designed for ' + rec.name, 'success');
|
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);
|
if (typeof _libRefreshDetailModal === 'function') _libRefreshDetailModal(rec);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast('Voice design failed: ' + (err.message || err), 'error');
|
toast('Voice design failed: ' + (err.message || err), 'error');
|
||||||
@ -2599,8 +2670,9 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
|
|||||||
}
|
}
|
||||||
const sh = rec.sheet || {};
|
const sh = rec.sheet || {};
|
||||||
const langName = (await _resolveBookLang(rec)) || 'English';
|
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 (!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';
|
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
|
// 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
|
// 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;
|
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._charSearchOnline = _charSearchOnline;
|
||||||
window._charDesignVoice = _charDesignVoice;
|
window._charDesignVoice = _charDesignVoice;
|
||||||
window._charAutoDesignVoice = _charAutoDesignVoice;
|
window._charAutoDesignVoice = _charAutoDesignVoice;
|
||||||
window._charAutoGenerateImage = _charAutoGenerateImage;
|
window._charAutoGenerateImage = _charAutoGenerateImage;
|
||||||
window._charAutoGenerateConceptArt = _charAutoGenerateConceptArt;
|
window._charAutoGenerateConceptArt = _charAutoGenerateConceptArt;
|
||||||
|
window._openConceptArtLightbox = _openConceptArtLightbox;
|
||||||
window.libraryRenderCharacters = libraryRenderCharacters;
|
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
|
// Rehearser" still showing) sitting back at the bottom. Watch for it instead
|
||||||
// of relocating once.
|
// of relocating once.
|
||||||
let _stuCastFootObserver = null;
|
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() {
|
function _stuRelocateCastFoot() {
|
||||||
_stuTryRelocateCastFoot();
|
_stuTryRelocateCastFoot();
|
||||||
if (_stuCastFootObserver) return;
|
if (_stuCastFootObserver) return;
|
||||||
const slot = document.getElementById('stu-cast-slot');
|
// The audiobook panel is rebuilt asynchronously and can briefly be moved
|
||||||
if (!slot) return;
|
// 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 = 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() {
|
function _stuTryRelocateCastFoot() {
|
||||||
const slot = document.getElementById('stu-cast-slot');
|
const slot = document.getElementById('stu-cast-slot');
|
||||||
const tabs = document.getElementById('stu-cast-inner-tabs');
|
const tabs = document.getElementById('stu-cast-inner-tabs');
|
||||||
if (!tabs) return;
|
if (!tabs) return;
|
||||||
const freshFoot = slot ? slot.querySelector('#ab-cv-foot') : null;
|
const freshFoot = (slot && slot.querySelector('#ab-cv-foot')) || document.querySelector('#ab-cv-foot');
|
||||||
const alreadyRelocated = tabs.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
|
// Transient states (e.g. "Checking saved cast before starting a new
|
||||||
// one…") rebuild the panel WITHOUT a footer at all — confirmed live: the
|
// one…") rebuild the panel WITHOUT a footer at all — confirmed live: the
|
||||||
// plain tabs stayed hidden from an earlier relocation with nothing having
|
// 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
|
// 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
|
// (neither a fresh one nor an already-relocated one), rather than only
|
||||||
// ever hiding them and never re-checking.
|
// ever hiding them and never re-checking.
|
||||||
if (!freshFoot && !alreadyRelocated) {
|
// The footer is intentionally empty during some async rebuild phases.
|
||||||
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; });
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
if (!freshFoot || freshFoot.parentElement === tabs) return;
|
|
||||||
tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); });
|
tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); });
|
||||||
|
tabs.hidden = false;
|
||||||
|
tabs.style.display = 'flex';
|
||||||
freshFoot.style.borderTop = 'none';
|
freshFoot.style.borderTop = 'none';
|
||||||
freshFoot.style.padding = '0';
|
freshFoot.style.padding = '0';
|
||||||
freshFoot.style.justifyContent = 'flex-start';
|
freshFoot.style.justifyContent = 'flex-start';
|
||||||
|
freshFoot.hidden = false;
|
||||||
|
freshFoot.style.display = 'flex';
|
||||||
tabs.appendChild(freshFoot);
|
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');
|
const openReh = freshFoot.querySelector('#ab-cv-open-reh');
|
||||||
if (openReh) openReh.hidden = true;
|
if (openReh) openReh.hidden = true;
|
||||||
}
|
}
|
||||||
@ -380,6 +432,7 @@ function _stuTryRelocateCastFoot() {
|
|||||||
// panel itself goes home, so the footer rides back inside it intact.
|
// panel itself goes home, so the footer rides back inside it intact.
|
||||||
function _stuRestoreCastFoot() {
|
function _stuRestoreCastFoot() {
|
||||||
if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; }
|
if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; }
|
||||||
|
if (_stuCastFootHealthTimer) { clearInterval(_stuCastFootHealthTimer); _stuCastFootHealthTimer = null; }
|
||||||
const tabs = document.getElementById('stu-cast-inner-tabs');
|
const tabs = document.getElementById('stu-cast-inner-tabs');
|
||||||
const panel = document.getElementById('reader-audiobook-panel');
|
const panel = document.getElementById('reader-audiobook-panel');
|
||||||
const foot = tabs ? tabs.querySelector('#ab-cv-foot') : null;
|
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 */
|
/* 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-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 td { overflow: hidden; text-overflow: ellipsis; }
|
||||||
.lib-chars-tbl thead th {
|
.lib-chars-tbl thead th {
|
||||||
text-align:left; padding:8px 8px; font-size:10.5px; font-weight:800; text-transform:uppercase;
|
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. */
|
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 { white-space:normal; max-width:260px; }
|
||||||
.lib-chars-tbl-tags-wrap { display:flex; flex-wrap:wrap; gap:3px; }
|
.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-dash { opacity:.4; }
|
||||||
.lib-chars-tbl-check { display:inline-flex; }
|
.lib-chars-tbl-check { display:inline-flex; }
|
||||||
.lib-chars-tbl-check.is-yes { color:#4caf50; }
|
.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-card-banner { aspect-ratio: auto; height: auto; min-height: 120px; }
|
||||||
.lib-char-avatar { width:82px; height:82px; font-size:30px; }
|
.lib-char-avatar { width:82px; height:82px; font-size:30px; }
|
||||||
.lib-char-name { font-size:18px; }
|
.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) ───────────────────────────────── */
|
/* ── 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 .mdi { font-size: 30px; }
|
||||||
.lcd-conceptart-empty.is-noprompt { opacity: .6; }
|
.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-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 { display:flex; gap:8px; flex-shrink:0; align-self:flex-start; }
|
||||||
.lcd-header-btns button {
|
.lcd-header-btns button {
|
||||||
background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.32); color:#fff;
|
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;
|
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-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 {
|
.lcd-voice-top button {
|
||||||
font-size:13px; padding:6px 16px; border-radius:7px; cursor:pointer;
|
font-size:13px; padding:6px 16px; border-radius:7px; cursor:pointer;
|
||||||
border:1px solid var(--border); background:var(--panel); color:var(--text);
|
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-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-online-voice:hover { border-color:#0ea5e9; color:#0ea5e9; }
|
||||||
.lcd-voice-top .lcd-gen-voice:hover { border-color:#a855f7; color:#a855f7; }
|
.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 */
|
/* Sources section */
|
||||||
.lcd-sources {
|
.lcd-sources {
|
||||||
padding:20px 28px; background:var(--panel); border-top:1px solid var(--border);
|
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 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-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 { 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 { font-weight: 800; font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
||||||
.audiobook-title .mdi { color: var(--accent); font-size: 20px; }
|
.audiobook-title .mdi { color: var(--accent); font-size: 20px; }
|
||||||
.audiobook-msg { color: var(--subtext); font-size: 13px; margin: 10px 0 12px; }
|
.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 { 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-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; }
|
.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 — click any character avatar to open */
|
||||||
.avatar-lightbox-box { width: min(1180px, 96vw); max-height: 92vh; overflow-y: auto; }
|
.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-body { display: flex; flex-direction: column; gap: 20px; margin-top: 20px; align-items: center; }
|
||||||
.alb-preview { flex: 0 0 440px; }
|
.alb-preview { width: min(560px, 86vw); }
|
||||||
.alb-preview img { width: 440px; height: 440px; object-fit: cover; border-radius: 14px; border: 1px solid var(--border); }
|
.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 {
|
.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;
|
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-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-section input[type="file"] { font-size: 13.5px; width: 100%; }
|
||||||
.alb-url-row { display: flex; gap: 8px; }
|
.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 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; }
|
.alb-gen-row .btn-primary { font-size: 13.5px; padding: 9px 16px; }
|
||||||
@media (max-width: 820px) {
|
@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-actions { min-width: 0; width: 100%; }
|
||||||
}
|
}
|
||||||
#alb-status { margin-top: 10px; }
|
#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; }
|
.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-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-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; }
|
.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