Release v1.20.37

This commit is contained in:
mARTin-B78 2026-08-19 16:04:30 +02:00
parent 62274a17fc
commit 189ca51e47
23 changed files with 727 additions and 219 deletions

View File

@ -5,6 +5,21 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.20.37] — 2026-08-19
### Added
- **Character profile completeness is visible in the cast table.** The table now shows how many of the 32 profile fields are filled, with visual sparse/partial/complete states and sorting by completeness.
- **ComfyUI character artwork workflows are available locally.** Profile portraits and 3:2 character concept sheets can be generated through the configured ComfyUI provider, including bulk generation from the cast table.
### Changed
- **Voice-design prompts now enforce audible identity constraints.** Generated prompts preserve the book language and native accent, respect gender and apparent age, and require more distinct vocal fingerprints between characters.
- **Voice ratings remain review feedback rather than casting filters.** One- to three-star voices stay eligible while their prompts and profiles are improved.
### Fixed
- **Assigned voice ratings could appear to vanish in Characters / Cast.** The table now waits for the shared voice library metadata before rendering ratings.
- **Character-table columns could shift out of alignment.** The Book / Script row cell now matches its header, keeping the profile-completion and export columns in the correct positions.
- **WhisperX Blackwell status naming and health checks were inconsistent.** The engine and status bar now identify the service consistently, and health checks no longer send unnecessary transcription requests.
## [1.20.36] — 2026-08-15
### Added

View File

@ -31,6 +31,7 @@ COPY CHANGELOG.md .
COPY VERSION .
COPY core/ core/
COPY routes/ routes/
COPY workflows/ workflows/
COPY static/ static/
# ── Vendor Tesseract.js (client-side OCR for image-based PDF chapter headings) ─

View File

@ -1 +1 @@
1.20.36
1.20.37

View File

@ -67,17 +67,27 @@ def _is_sound_asset_file(path: Path) -> bool:
_AUDIO_EXTS_SET = frozenset(_AUDIO_EXTS)
def _is_trashed_voice_file(path: Path) -> bool:
"""Keep recoverable voice packages out of normal library discovery."""
return any(part.lower() == "trash_voices" for part in path.parts)
def _voice_audio_files(root: Path):
# Single rglob pass instead of one per extension (6× faster directory scan)
for p in root.rglob("*"):
if p.suffix.lower() in _AUDIO_EXTS_SET and not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
if (
p.suffix.lower() in _AUDIO_EXTS_SET
and not _is_internal_voice_file(p)
and not _is_sound_asset_file(p)
and not _is_trashed_voice_file(p)
):
yield p
def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None:
for ext in _AUDIO_EXTS:
for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")):
if not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
if not _is_internal_voice_file(p) and not _is_sound_asset_file(p) and not _is_trashed_voice_file(p):
return p
return None
@ -322,7 +332,11 @@ def _voice_health(
def _voice_entry(p: Path) -> dict:
meta = _load_meta(p)
parts = p.stem.split("_", 2)
lang = parts[0].upper() if parts else ""
# A saved user choice always wins over the filename. Provider-prefixed
# imports such as EL_EN_F_* use EL as an import/source marker, not a
# language, so deriving the display language solely from the ID made a
# manually selected DE revert after every refresh.
lang = str(meta.get("lang") or (parts[0] if parts else "")).strip().upper()
try:
duration = round(_duration(p), 2)
except Exception:
@ -401,7 +415,31 @@ def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str
from core.tts_helpers import _tts_benchmark_request
has_ref, _transcript = _read_reference_text(audio)
meta = _load_meta(audio)
is_designed = meta.get("origin") == "designed" or not has_ref
# An imported WAV without a transcript is still a clone voice. Treating
# every such file as a prompt-designed voice sent it to the unrelated
# Voice Design service (8021), producing a misleading 404. Qwen clone
# synthesis requires the exact reference transcript, so surface that
# prerequisite directly instead.
if not has_ref:
error = "Reference transcript required before this saved WAV voice can be benchmarked. Add or generate its exact transcript first."
return {
"ok": False,
"realtime_ok": False,
"elapsed_sec": None,
"audio_sec": None,
"rtf": None,
"speed": None,
"ttfa_ms": None,
"bytes": 0,
"clipped": False,
"benchmarked_at": datetime.now(timezone.utc).isoformat(),
"text": sentences[0][1] if len(sentences) == 1 else "short / medium / long",
"runs": [{"ok": False, "label": label, "text": text, "error": error} for label, text in sentences],
"summary": {},
"advice": ["Generate an exact reference transcript with STT, then retry the benchmark."],
"error": error,
}
is_designed = meta.get("origin") == "designed"
runs: list[dict] = []
for label, text in sentences:
try:

View File

@ -24,6 +24,7 @@ services:
- ./server.py:/app/server.py:ro
- ./core:/app/core:ro
- ./routes:/app/routes:ro
- ./workflows:/app/workflows:ro
- ./VERSION:/app/VERSION:ro
- ./CHANGELOG.md:/app/CHANGELOG.md:ro

View File

@ -57,7 +57,11 @@ async def characters_image(char_id: str):
raw = base64.b64decode(m.group(2))
except Exception:
raise HTTPException(404, "Invalid image data")
return Response(content=raw, media_type=m.group(1))
return Response(
content=raw,
media_type=m.group(1),
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
)
@router.get("/api/characters/{char_id:path}")

View File

@ -407,7 +407,14 @@ async def analyze_characters(request: Request):
"- description: 2–3 sentences covering pitch (high/mid/low), pace, "
"timbre, accent/dialect, emotional default, and any distinctive speech trait "
"that fits the character's personality and role.\n"
"Characters with few lines: infer from their role name and context.\n"
"Treat gender and age as hard casting constraints, not suggestions. Do not "
"default every character to an adult neutral American voice. Preserve the "
"script's language and specify a native accent/dialect when supported by "
"the text or setting; do not invent a US accent. Give every character a "
"different audible fingerprint (pitch register, rhythm, resonance, texture, "
"or articulation) from the other characters in this response.\n"
"Characters with few lines: infer cautiously from their role name and context, "
"and mark uncertain age/gender as N/unknown rather than guessing confidently.\n"
"Respond with STRICT JSON only — no markdown, no explanation:\n"
'{"characters":[{"name":"NAME","gender":"M|F|N","language":"LANG",'
'"age":"30s","description":"voice description"}]}\n'
@ -493,7 +500,7 @@ async def match_characters_voices(request: Request):
script = script[:5000]
def _vline(v: dict) -> str:
meta = [str(v.get(k)) for k in ("gender", "language") if v.get(k)]
meta = [str(v.get(k)) for k in ("gender", "age", "language") if v.get(k)]
if v.get("tags"):
meta.append("tags:" + str(v["tags"]))
desc = str(v.get("description") or v.get("name") or "")[:120]
@ -509,7 +516,9 @@ async def match_characters_voices(request: Request):
"RULE 1 — GENDER FIRST: the voice's gender MUST match the character's gender whenever the "
"character's gender is clear from the script; only pick a different gender if no same-gender "
"voice exists in the catalogue. "
"RULE 2 — then match apparent age, language, personality, and the voice's description/tags. "
"RULE 2 — then match apparent age, language/accent, personality, and the voice's description/tags. "
"RULE 3 — maximize cast diversity: do not reuse a voice when another suitable candidate exists; "
"avoid voices with the same gender, age, accent, and timbre as an already assigned character. "
"You MUST choose a voice_id that appears verbatim in the catalogue — never invent one. "
"Reuse a voice for two characters only if no better distinct option exists. "
"Respond with STRICT JSON only, no markdown:\n"
@ -564,7 +573,19 @@ async def match_characters_voices(request: Request):
except Exception:
continue
# Keep only assignments that reference a real catalogue voice
clean = [a for a in assignments if isinstance(a, dict) and str(a.get("voice_id")) in valid_ids]
clean = []
used_ids = set()
for a in assignments:
if not isinstance(a, dict) or str(a.get("voice_id")) not in valid_ids:
continue
# A voice may be reused only as a last resort. Most books have enough
# suitable voices, and reusing the same one is the main source of the
# user's "all 3-star voices sound alike" complaint.
vid = str(a.get("voice_id"))
if vid in used_ids and len(used_ids) < len(valid_ids):
continue
used_ids.add(vid)
clean.append(a)
return {"assignments": clean}
@ -1317,28 +1338,19 @@ async def character_generate_prompts(request: Request):
preamble
+ visual_identity_rule
+ "Produce exactly this field:\n"
"- image_prompt: a detailed English image-generation prompt for a full CHARACTER REFERENCE SHEET "
"(a single composite image, like a game/animation production turnaround), not just one portrait. "
"FIRST, from the book title and profile, work out the story's genre, setting, and era (e.g. "
"'medieval European-inspired high fantasy', 'grimdark low fantasy', 'space opera sci-fi', "
"'contemporary urban fantasy') — an image model has no idea what a book title implies and will "
"default to generic modern/real-world imagery unless told explicitly, which is exactly wrong for "
"an occupation like 'Admiral' or 'General' in a fantasy world (it will draw a 20th-century military "
"uniform instead of that world's actual equivalent). State that genre/setting/era explicitly, as "
"its own descriptor near the START of the prompt, and make clear the world has no real-world 20th "
"or 21st century technology, uniforms, or clothing unless the story is actually confirmed "
"contemporary/near-future — every other visual choice (armor, dress, rank insignia, weapons) must "
"fit THAT world, not the real one. Then ask the image model for: (1) a full-body front-view "
"illustration as the anchor, (2) a turnaround panel with side and back views, (3) a small "
"expression sheet with 3-5 headshots showing this character's typical emotional range "
"(calm/determined/etc. — pick expressions that fit their personality), (4) a color palette swatch "
"panel for hair/eyes/outfit, (5) callouts for their signature props, tools, or clothing details "
"with short labels. Include the explicit gender and every established visual trait: age impression, species, "
"face shape, build, hair, eyes, skin, facial hair, distinguishing marks, clothing, armor, colors, "
"jewelry, weapons, tools, and signature props when present. "
"appropriate to the established setting. Specify a clean production-design/concept-art layout "
"with a plain neutral background, and explicitly note this is an ORIGINAL character, not based on "
"any copyrighted character. One dense paragraph, comma-separated descriptors are fine.\n\n"
"- image_prompt: an English image-generation prompt for a photorealistic audiobook character portrait, "
"written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
"(not a list). You MUST use the following STRICT stylistic constraints for the clauses, but replace the Subject facts with the current character profile:\n"
" Task: Generate a Character portrait.\n"
" Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
"content. Include every relevant visual fact from the profile.\n"
" Context: a believable live-action character in the book's established original world and era.\n"
" Style: photorealistic cinematic character photography, as a premium live-action fantasy-film portrait; natural skin texture and pores, anatomically plausible face and hands, real cloth, leather and metal materials, and no illustration, painting, anime, game render, or plastic skin.\n"
" Composition: a simple square headshot/bust on a subdued neutral studio background, facing forward with a subtle in-character expression.\n"
" Lighting: flattering neutral photographic key light with soft rim separation, realistic shadow falloff, and no stylized color cast.\n"
" Constraints: preserve the exact gender, age impression, species traits, face, hairstyle, garment layers and signature details established in the profile; no existing game references, logos, readable labels, floating fragments, or watermark.\n"
" Output: one production-ready square live-action character portrait.\n\n"
'Respond with STRICT JSON only: {"image_prompt":""}/no-think'
),
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
@ -1347,32 +1359,17 @@ async def character_generate_prompts(request: Request):
+ visual_identity_rule
+ "Produce exactly this field:\n"
"- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC "
"reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
"reference sheet, written as labelled clauses in this EXACT order — Subject, Context, Style, "
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
"(not a list). Use the following two reference layouts as the structural model, but replace EVERY "
"subject fact with the current character profile:\n"
" Task: choose the best sheet purpose for the profile — a three-view character turnaround with "
"accessory callouts, or a character props/accessories callout sheet.\n"
" Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
"content. Include every relevant visual fact from the profile: age, species, face, skin, hair, eyes, "
"facial hair, marks, build, clothing layers, colors, armor, jewelry, weapons, tools, and signature "
"accessories. For each profile-established prop/accessory, state its exact count; omit anything not in "
"the profile. Never copy the herbalist/cartographer examples.\n"
" Context: state the book's profile-derived genre, setting, era, and the production purpose of the sheet.\n"
" Style: retain the reference style principles — detailed hand-painted or painterly game concept art, "
"grounded leather/metal/cloth/wood materials, clear readable shape language, and coherent wear — but "
"choose the visual medium and palette treatment appropriate to this book's profile.\n"
" Composition: choose either (A) aligned full-body front, side, and back views across the top with a "
"dialogue/action pose and one clean accessory callout row below, or (B) one full-body three-quarter "
"hero figure at left with exactly one isolated callout for each profile-established accessory at right. "
"Use clean leader lines, generous spacing, safe margins, and keep every figure and accessory fully visible.\n"
" Lighting: use warm-neutral or neutral studio light that reveals material texture, with soft contact "
"shadows and no cinematic color cast unless the profile or book context explicitly requires one.\n"
" Constraints: same exact gender, face identity anchor, body proportions, silhouette, garment seams, "
"colors, wear, accessory scale, attachment points, and prop count in every view; each accessory appears "
"once and matches the carried version; no duplicate gear, disassembled fragments, floating objects, extra "
"limbs, readable text, logos, real-world emblems, franchise references, or watermark.\n"
" Output: one organized, production-ready 3:2 character reference sheet.\n\n"
"(not a list). You MUST use the following STRICT stylistic constraints for the clauses, but replace the Subject and Prop facts with the current character profile:\n"
" Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
"content. Include every relevant visual fact from the profile. For each prop/accessory, state its exact count.\n"
" Context: a costume and prop handoff reference for the book's established original world and era.\n"
" Style: refined traditional pencil-and-ink concept sketch with restrained watercolor washes, clear silhouette and costume construction, grounded materials, visible linework, and believable proportions; intentionally illustrative, not photorealistic and not cartoonish.\n"
" Composition: full-body front, side, and back views across the top; below them, one dialogue pose and a single clean callout row containing exactly [list exactly the props/accessories from the profile] on a muted parchment landscape board.\n"
" Lighting: neutral studio reference lighting with gentle shading that keeps every construction detail readable.\n"
" Constraints: consistent face, silhouette, garment layers, [prop specifics], and palette in every view; no duplicate gear, no existing game references, logos, readable labels, floating fragments, extra limbs, or watermark.\n"
" Output: one production-ready 3:2 NPC reference sheet.\n\n"
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
),
}
@ -1547,6 +1544,47 @@ async def audiobook_consistency_check(request: Request):
return {"outliers": clean}
def _bundled_comfyui_workflow(image_kind: str) -> tuple[str, str, str, str]:
"""Return the validated local Flux workflows shipped with Voice Creator.
A custom API-format graph saved in Settings still takes precedence. The
bundled workflows mean that selecting Local ComfyUI works immediately on
the supported local installation instead of leaving every bulk run to
fail because the workflow textarea was empty.
"""
name = "comfyui-character-concept-sheet.json" if image_kind == "concept_art" else "comfyui-character-portrait.json"
path = Path(__file__).resolve().parent.parent / "workflows" / name
try:
return path.read_text(encoding="utf-8"), "2", "text", "7"
except OSError as exc:
raise HTTPException(500, f"Bundled ComfyUI workflow is unavailable: {exc}")
def _apply_character_image_rendering_style(prompt: str, image_kind: str) -> str:
"""Keep asset style deterministic, including for prompts saved before v1.20.36.
A profile and a concept sheet serve different purposes. The LLM provides
character-specific content, while this final rendering directive makes the
visual contract explicit to ComfyUI and prevents an old "painterly" prompt
from silently turning every newly regenerated portrait into game art.
"""
if image_kind == "concept_art":
return (
f"{prompt}\n\n"
"FINAL RENDERING DIRECTIVE: make this a refined hand-drawn production design sheet: "
"pencil and ink linework, restrained watercolor washes, readable costume construction and props, "
"subtle paper texture. It must be illustrative but not cartoonish; do not render it as a photograph, "
"3D game art, anime, glossy digital painting, or a collage."
)
return (
f"{prompt}\n\n"
"FINAL RENDERING DIRECTIVE: render this as a photorealistic live-action cinematic casting portrait, "
"with natural human skin texture and pores, realistic hair, physically plausible anatomy, authentic "
"fabric/leather/metal, and photographic lens lighting. Do not make an illustration, painting, anime, "
"cartoon, game render, CGI character, or plastic-skinned doll."
)
def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
prompt_field: str, output_node_id: str, prompt: str) -> str:
"""Submits a pre-exported ComfyUI API-format workflow with the given
@ -1560,8 +1598,6 @@ def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
"""
if not comfyui_url:
raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)")
if not workflow_json:
raise HTTPException(400, "No ComfyUI workflow configured — paste an API-format workflow export in Settings > Engines > Image Generation")
if not prompt_node_id or not output_node_id:
raise HTTPException(400, "ComfyUI prompt/output node IDs not set (Settings > Engines > Image Generation)")
try:
@ -1702,10 +1738,12 @@ async def character_generate_image(request: Request):
# as long as one image generation took, which for a bulk "auto-generate
# images" run across a whole cast could be tens of minutes of site-wide
# freeze with no error, just silence. Runs in a worker thread instead.
return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings)
image_kind = "concept_art" if str(data.get("image_kind") or "").strip().lower() in {"concept", "concept_art", "sheet", "character_sheet"} else "portrait"
prompt = _apply_character_image_rendering_style(prompt, image_kind)
return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind)
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict) -> dict:
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict:
if provider == "openai":
api_key = (keys.get("openai_image") or "").strip()
if not api_key:
@ -1735,25 +1773,40 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
api_key = (keys.get("google_image") or "").strip()
if not api_key:
raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)")
gmodel = model or "gemini-2.5-flash-image"
# Google retired the preview alias; keep older saved settings usable
# instead of turning a previously working Google selection into a 404.
if gmodel == "gemini-2.5-flash-image-preview":
gmodel = "gemini-2.5-flash-image"
gmodel = model or "gemini-3.1-flash-image"
# Google retired the preview alias and moved image models to Interactions API.
if gmodel in ["gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"]:
gmodel = "gemini-3.1-flash-image"
try:
api_url = "https://generativelanguage.googleapis.com/v1beta/interactions"
payload = {
"model": gmodel,
"input": [{"type": "user_input", "content": [{"type": "text", "text": prompt}]}],
"response_modalities": ["image"]
}
resp = requests.post(
f"https://generativelanguage.googleapis.com/v1/models/{gmodel}:generateContent",
api_url,
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
# Gemini image models do not return image bytes unless image
# output is explicitly requested. The old payload only sent
# the prompt, so Google successfully returned text (or a
# safety response) and the client reported "no image data".
json={
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"responseModalities": ["IMAGE"]},
},
json=payload,
timeout=120,
)
# Fallback to the older generateContent endpoint if the model is not supported by Interactions API
if resp.status_code == 404:
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gmodel}:generateContent"
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {"responseModalities": ["IMAGE"]},
}
resp = requests.post(
api_url,
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
json=payload,
timeout=120,
)
if not resp.ok:
detail = resp.text[:400]
try:
@ -1761,15 +1814,29 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
except Exception:
pass
raise HTTPException(resp.status_code, f"Google image generation failed: {detail}")
payload = resp.json()
parts = (payload.get("candidates") or [{}])[0].get("content", {}).get("parts", [])
inline = next((p.get("inlineData") or p.get("inline_data") for p in parts if p.get("inlineData") or p.get("inline_data")), None)
payload_json = resp.json()
def _find_inline_data(obj):
if isinstance(obj, dict):
if "inlineData" in obj: return obj["inlineData"]
if "inline_data" in obj: return obj["inline_data"]
for k, v in obj.items():
res = _find_inline_data(v)
if res: return res
elif isinstance(obj, list):
for item in obj:
res = _find_inline_data(item)
if res: return res
return None
inline = _find_inline_data(payload_json)
if not inline:
# 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")
raise HTTPException(502, f"Google returned no image data — check the selected image model, quota, or prompt.")
mime = inline.get("mimeType", "image/png")
return {"image": f"data:{mime};base64,{inline['data']}"}
except HTTPException:
@ -1786,7 +1853,9 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
api_key = (keys.get("openrouter") or "").strip()
if not api_key:
raise HTTPException(400, "OpenRouter API key not set (Settings > Engines > Language Models > OpenRouter)")
omodel = model or "google/gemini-2.5-flash-image-preview:free"
omodel = model or "google/gemini-3.1-flash-image"
if omodel in ["google/gemini-2.5-flash-image-preview:free", "google/gemini-2.5-flash-image"]:
omodel = "google/gemini-3.1-flash-image"
try:
resp = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
@ -1824,12 +1893,26 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
raise HTTPException(502, f"OpenRouter image generation failed: {e}")
if provider == "comfyui":
workflow_json = _settings.get("comfyui_workflow") or ""
prompt_node_id = (_settings.get("comfyui_prompt_node_id") or "").strip()
prompt_field = (_settings.get("comfyui_prompt_field") or "text").strip()
output_node_id = (_settings.get("comfyui_output_node_id") or "").strip()
# Advanced users may still paste their own API workflow. Otherwise
# use the paired local Flux workflows validated against ComfyUI.
# A legacy/custom workflow is normally a square portrait graph. Do
# not stretch that graph into a character sheet: concept art needs the
# dedicated 3:2 composition workflow to keep the turnaround layout
# readable. Portraits still honour the user's advanced override.
if image_kind == "concept_art":
workflow_json = ""
if not workflow_json:
workflow_json, prompt_node_id, prompt_field, output_node_id = _bundled_comfyui_workflow(image_kind)
image = _comfyui_run(
(_settings.get("comfyui_url") or "").strip(),
_settings.get("comfyui_workflow") or "",
(_settings.get("comfyui_prompt_node_id") or "").strip(),
(_settings.get("comfyui_prompt_field") or "text").strip(),
(_settings.get("comfyui_output_node_id") or "").strip(),
workflow_json,
prompt_node_id,
prompt_field,
output_node_id,
prompt,
)
return {"image": image}
@ -1882,7 +1965,7 @@ def _attribution_prepare(data: dict) -> dict:
raise HTTPException(400, "No text provided")
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
prompt_override = data.get("audiobook_prompt")
if not isinstance(prompt_override, str):
prompt_override = data.get("prompt")
@ -2828,7 +2911,7 @@ async def conversation_llm_models(url: str = "", api_key: str = ""):
try:
base = _validate_http_url(base, allow_private=True)
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
# Fallback for gateways like LiteLLM that might prefer /models over /v1/models
if r.status_code != 200 and base.endswith("/v1"):
fallback_base = base[:-3]

View File

@ -39,7 +39,7 @@ _LOCAL_CONTAINER_DEFS: list[dict] = [
{"name": "nvidia-zeroshot-nim", "label": "NVIDIA Zeroshot NIM", "role": "tts", "port": 8093, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Zeroshot clone endpoint with audio_prompt."},
{"name": "nvidia-flow-nim", "label": "NVIDIA Flow NIM", "role": "tts", "port": 8094, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Flow clone endpoint with audio_prompt and transcript."},
{"name": "voicebox", "label": "Voicebox", "role": "llm", "port": 17493, "stack": "voicebox", "image": "local", "repo": "", "description": "Companion voice app/control plane. Not used as a normal TTS engine unless wrapped as an API."},
{"name": "whisperx-blackwell", "label": "WhisperX Blackwell", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service. Use as custom STT if its API is OpenAI-compatible or /transcribe."},
{"name": "stt-whisperx-blackwell", "label": "WhisperX · faster-whisper (Blackwell GPU)", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service using faster-whisper on the Blackwell GPU. Use as custom STT if its API is OpenAI-compatible or /transcribe."},
{"name": "xtts-dgx-spark", "label": "XTTS v2", "role": "tts", "port": 8005, "stack": "xtts_v2", "image": "local", "repo": "https://github.com/daswer123/xtts-api-server", "description": "XTTS v2 OpenAI-compatible speaker endpoint."},
]

View File

@ -5,6 +5,7 @@ import asyncio
import re
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
@ -39,6 +40,68 @@ from core.voice_index import (
router = APIRouter()
_voice_prepare_jobs: dict[str, dict] = {}
async def _prepare_voice_reference(audio: Path, settings: dict) -> dict:
"""Transcribe, publish, warm, and benchmark one saved WAV voice."""
meta = _load_meta(audio)
meta["import_preparation"] = "transcribing"
meta.pop("import_preparation_error", None)
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
try:
from routes.stt import _transcribe_audio
transcript, backend = await asyncio.to_thread(_transcribe_audio, audio, settings, "configured")
transcript = transcript.strip()
if not transcript:
raise RuntimeError("STT returned an empty transcript")
audio.with_suffix(".reference.txt").write_text(transcript, encoding="utf-8")
from routes.tts import sync_voice_clone_reference
meta = _load_meta(audio)
await asyncio.to_thread(sync_voice_clone_reference, audio, transcript, str(meta.get("lang") or ""), settings)
meta = _load_meta(audio)
meta["reference_transcript_status"] = "ready"
meta["reference_transcript_backend"] = backend
meta["import_preparation"] = "benchmarking"
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
except Exception as exc:
meta = _load_meta(audio)
meta["reference_transcript_status"] = "pending"
meta["import_preparation"] = "pending_stt"
meta["import_preparation_error"] = str(exc)[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
return {"ok": False, "stage": "transcription", "error": str(exc)}
benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, list(_BENCHMARK_SENTENCES))
meta = _load_meta(audio)
meta["benchmark"] = benchmark
meta["import_preparation"] = "ready" if benchmark.get("ok") else "pending_tts"
if benchmark.get("ok"):
meta.pop("import_preparation_error", None)
else:
meta["import_preparation_error"] = str(benchmark.get("error") or "Benchmark failed")[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
return {"ok": bool(benchmark.get("ok")), "stage": "benchmark", "error": benchmark.get("error", "")}
async def _run_prepare_missing_job(job_id: str, voices: list[Path], settings: dict) -> None:
job = _voice_prepare_jobs[job_id]
job["state"] = "running"
for index, audio in enumerate(voices, start=1):
job.update({"current": audio.stem, "completed": index - 1})
result = await _prepare_voice_reference(audio, settings)
job["completed"] = index
if result.get("ok"):
job["ready"] += 1
else:
job["failed"] += 1
job["errors"].append({"voice_id": audio.stem, "stage": result.get("stage"), "detail": result.get("error", "")})
job.update({"state": "complete", "current": "", "finished_at": datetime.now().isoformat()})
# ── Book/production profile (genre, setting, era, language) ──────────────────
# A one-time-per-book note the user fills in ("German, fantasy like Lord of
@ -381,10 +444,11 @@ async def update_voice_meta(request: Request):
meta = _load_meta(wav)
meta["enabled"] = enabled
for field in ("note", "rating", "flag", "gender", "loudness", "persona", "origin", "group",
"name", "tag", "avatar", "voice_design_prompt"):
for field in ("note", "rating", "flag", "gender", "age", "lang", "language", "loudness", "persona", "origin", "group",
"name", "tag", "source", "avatar", "voice_design_prompt"):
if field in data:
meta[field] = data[field]
# Older clients sent `language`; keep metadata canonical as `lang`.
meta["lang" if field == "language" else field] = data[field]
if "transcript" in data:
meta["needs_tts_restart"] = True
_save_meta(wav, meta)
@ -525,6 +589,44 @@ async def benchmark_voices(request: Request):
}
@router.post("/api/voices/prepare-missing-references")
async def prepare_missing_references(request: Request):
"""Queue STT transcript + Qwen warm-up/benchmark for active voices missing text."""
data = await request.json()
requested_ids = {str(v).strip() for v in (data.get("voice_ids") or []) if str(v).strip()}
settings = _load_settings()
active_dir = _active_voices_dir(settings)
candidates = []
for audio in sorted(_voice_audio_files(active_dir), key=lambda p: p.stem.lower()):
if requested_ids and audio.stem not in requested_ids:
continue
has_ref, text = _read_reference_text(audio)
if not has_ref or not text.strip():
candidates.append(audio)
job_id = uuid.uuid4().hex
_voice_prepare_jobs[job_id] = {
"id": job_id,
"state": "queued",
"total": len(candidates),
"completed": 0,
"ready": 0,
"failed": 0,
"current": "",
"errors": [],
"started_at": datetime.now().isoformat(),
}
asyncio.create_task(_run_prepare_missing_job(job_id, candidates, settings))
return {"job_id": job_id, "queued": len(candidates)}
@router.get("/api/voices/prepare-missing-references/{job_id}")
async def get_prepare_missing_references_job(job_id: str):
job = _voice_prepare_jobs.get(job_id)
if not job:
raise HTTPException(404, "Preparation job not found")
return job
@router.post("/api/voices/normalize-active")
async def normalize_active_voices():
settings = _load_settings()
@ -694,26 +796,35 @@ async def rename_voice(request: Request):
# ── Voice delete ──────────────────────────────────────────────────────────────
def _voice_trash_batch_dir(scan_dir: Path, label: str) -> Path:
"""Create a non-discoverable, timestamped recycle-bin batch for voice packages."""
safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", label).strip("-") or "voices"
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return scan_dir / "trash_voices" / f"{stamp}-{safe_label}-{uuid.uuid4().hex[:8]}"
def _trash_voice_package(audio: Path, batch_dir: Path) -> Path:
"""Move a complete voice package together so it can be restored intact later."""
return _move_voice_package(audio, batch_dir / audio.stem)
@router.post("/api/voices/delete-group")
async def delete_voice_group(request: Request):
"""Delete every voice whose meta `group` matches the given name. Used to bulk-remove
a rehearser play's auto-designed voices."""
"""Move every voice in a group to the recoverable voice trash."""
data = await request.json()
group = str(data.get("group") or "").strip()
if not group:
raise HTTPException(400, "No group specified")
settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
deleted_ids = []
trashed_ids = []
batch_dir = _voice_trash_batch_dir(scan_dir, f"group-{group}")
for p in _voice_audio_files(scan_dir):
meta = _load_meta(p)
if str(meta.get("group") or "").strip() == group:
for f in _voice_package_paths(p):
if f.exists():
f.unlink()
deleted_ids.append(p.stem)
_trash_voice_package(p, batch_dir)
trashed_ids.append(p.stem)
remove_voice_from_index(p.stem)
return {"deleted": deleted_ids, "count": len(deleted_ids)}
return {"trashed": trashed_ids, "count": len(trashed_ids), "trash_batch": str(batch_dir)}
@router.delete("/api/voice/{voice_id}")
@ -725,13 +836,10 @@ async def delete_voice(voice_id: str):
wav = _find_voice_audio(voice_id, scan_dir)
if wav is None:
raise HTTPException(404, f"Voice '{voice_id}' not found")
deleted = []
for f in _voice_package_paths(wav):
if f.exists():
f.unlink()
deleted.append(f.name)
batch_dir = _voice_trash_batch_dir(scan_dir, f"voice-{voice_id}")
moved = _trash_voice_package(wav, batch_dir)
remove_voice_from_index(voice_id)
return {"deleted": deleted}
return {"trashed": [p.name for p in _voice_package_paths(moved)], "trash_batch": str(batch_dir)}
# ── Voice picture upload / serve ──────────────────────────────────────────────

View File

@ -16,18 +16,82 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from core.config import _load_settings
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES
from core.registry import _registry_put, TEMP_DIR
from core.validation import _validate_http_url
from core.audio import _to_wav_24k, _duration
from core.voice import (
_AUDIO_EXTS, _active_voices_dir,
_remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta,
_benchmark_voice,
)
from core.voice_index import upsert_voice_in_index
router = APIRouter()
async def _prepare_imported_voice(audio: Path, settings: dict) -> None:
"""Make a downloaded reference usable for Qwen clone playback.
Public catalogue previews usually arrive without a transcript. Qwen clone
needs that exact text before it can build a speaker embedding, so imports
transcribe first and only then warm/benchmark the voice. Any unavailable
local service is captured in metadata rather than making the import fail.
"""
meta = _load_meta(audio)
meta["import_preparation"] = "transcribing"
meta.pop("import_preparation_error", None)
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
try:
from routes.stt import _transcribe_audio
transcript, backend = await asyncio.to_thread(_transcribe_audio, audio, settings, "configured")
transcript = transcript.strip()
if not transcript:
raise RuntimeError("STT returned an empty transcript")
audio.with_suffix(".reference.txt").write_text(transcript, encoding="utf-8")
from routes.tts import sync_voice_clone_reference
meta = _load_meta(audio)
await asyncio.to_thread(
sync_voice_clone_reference, audio, transcript, str(meta.get("lang") or ""), settings
)
meta = _load_meta(audio)
meta["reference_transcript_status"] = "ready"
meta["reference_transcript_backend"] = backend
meta["import_preparation"] = "benchmarking"
meta.pop("import_preparation_error", None)
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
except Exception as exc:
meta = _load_meta(audio)
meta["reference_transcript_status"] = "pending"
meta["import_preparation"] = "pending_stt"
meta["import_preparation_error"] = str(exc)[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
return
try:
# Benchmark synthesis also creates/warms Qwen's cached speaker
# embedding, so no separate duplicate warm-up request is required.
benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, list(_BENCHMARK_SENTENCES))
meta = _load_meta(audio)
meta["benchmark"] = benchmark
meta["import_preparation"] = "ready" if benchmark.get("ok") else "pending_tts"
if benchmark.get("ok"):
meta.pop("import_preparation_error", None)
else:
meta["import_preparation_error"] = str(benchmark.get("error") or "Benchmark failed")[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
except Exception as exc:
meta = _load_meta(audio)
meta["import_preparation"] = "pending_tts"
meta["import_preparation_error"] = str(exc)[:500]
_save_meta(audio, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
# ── Voice source helpers ──────────────────────────────────────────────────────
_VOICE_SOURCE_HEADERS = {
@ -506,6 +570,9 @@ async def quick_import_voice(request: Request):
if not voice_id:
raise HTTPException(400, "voice_id is required")
transcript = str(data.get("transcript") or "").strip()
language = str(data.get("lang") or data.get("language") or "").strip().upper()[:12]
gender = str(data.get("gender") or "").strip().upper()[:1]
auto_prepare = bool(data.get("auto_prepare", False))
suffix = Path(urlparse(audio_url).path).suffix.lower()
if suffix not in _AUDIO_EXTS:
@ -548,11 +615,28 @@ async def quick_import_voice(request: Request):
if transcript:
(out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8")
meta = _load_meta(wav_dest)
meta["enabled"] = True
meta["loudness"] = loudness
meta.update({
"enabled": True,
"loudness": loudness,
"lang": language or str(meta.get("lang") or "").upper(),
"gender": gender if gender in {"F", "M", "N"} else meta.get("gender", ""),
})
for field in ("name", "tag", "group", "origin", "note", "source"):
if data.get(field) is not None:
meta[field] = str(data.get(field) or "").strip()[:500]
if auto_prepare and not transcript:
meta["reference_transcript_status"] = "pending"
meta["import_preparation"] = "queued"
_save_meta(wav_dest, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
return {"voice_id": final_id, "loudness": loudness}
if auto_prepare and not transcript:
asyncio.create_task(_prepare_imported_voice(wav_dest, settings))
return {
"voice_id": final_id,
"loudness": loudness,
"language": language,
"preparation": "queued" if auto_prepare and not transcript else "ready",
}
@router.get("/api/download-yt")

View File

@ -127,7 +127,13 @@ def _stt_backend_label(backend: str, url: str) -> str:
"groq_whisper": "Groq Whisper (cloud · free)",
}
port = _backend_port_label(url)
label = labels.get(backend, backend)
# The WhisperX Blackwell service is configured through the generic
# faster_whisper backend, so identify it by its published port in the
# status bar instead of making the engine card and status disagree.
if backend == "faster_whisper" and port in {"8003", "8081"}:
label = "WhisperX · faster-whisper (Blackwell GPU)"
else:
label = labels.get(backend, backend)
return f"{port} {label}" if port else label
@ -152,15 +158,20 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
base = _validate_http_url(url, allow_private=True).rstrip("/")
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
# health at the service root, while its transcription route is under
# `/v1/audio/...`; without splitting the prefix we probe `/v1/health` and
# `/v1/v1/models`, incorrectly marking a healthy WhisperX service down.
api_base = base[:-3].rstrip("/") if base.endswith("/v1") else base
models: list[str] = []
ok = False
try:
r = requests.get(f"{base}/health", timeout=2)
r = requests.get(f"{api_base}/health", timeout=2)
ok = r.status_code == 200
except Exception:
pass
try:
r = requests.get(f"{base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
r = requests.get(f"{api_base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
if r.status_code == 200:
ok = True
payload = r.json()
@ -174,42 +185,12 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
pass
if not ok:
return False, models
try:
wav = _make_minimal_wav()
endpoint_found = False
timed_out = False
for path in ("/v1/audio/transcriptions", "/transcribe"):
try:
r = requests.post(
f"{base}{path}",
files={"file": ("probe.wav", wav, "audio/wav")},
data={"model": "whisper-1", "response_format": "text"},
timeout=8,
)
if r.status_code in {404, 405}:
continue
if r.status_code == 500:
try:
detail = r.json().get("detail", "")
audio_quality_words = ("too short", "no speech", "audio", "empty",
"duration", "length", "silence")
ok = bool(detail) and any(w in detail.lower() for w in audio_quality_words)
except Exception:
ok = False
else:
ok = True
endpoint_found = True
break
except requests.exceptions.ConnectionError:
ok = False
endpoint_found = True
break
except Exception:
timed_out = True
if not endpoint_found and not timed_out:
ok = False
except Exception:
pass
# Do not send a real audio transcription as a health probe. WhisperX's
# batch server uses one worker and a probe consumes the GPU for several
# seconds; while it runs, even /health blocks and the UI reports the
# otherwise healthy service as unreachable. The service health endpoint is
# the authoritative liveness check; the real transcription path is tested
# when the user actually transcribes audio.
return ok, models
@ -236,12 +217,20 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
stt_key = _stt_backend_api_key(settings, backend)
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
model = _stt_backend_model(backend)
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"]
# Accept both `http://host:port` and OpenAI-style `http://host:port/v1`.
# WhisperX's native fallback remains rooted at `/transcribe`.
if stt_url.endswith("/v1"):
paths = ["/audio/transcriptions"] if not backend.startswith("custom:") else ["/audio/transcriptions", "/../transcribe"]
else:
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"]
resp = None
for path in paths:
with src.open("rb") as f:
endpoint = f"{stt_url}{path}"
if path == "/../transcribe":
endpoint = f"{stt_url[:-3].rstrip('/')}/transcribe"
resp = requests.post(
f"{stt_url}{path}",
endpoint,
files={"file": ("audio.wav", f, "audio/wav")},
data={"model": model, "response_format": "text"},
headers=hdrs,

View File

@ -231,6 +231,35 @@ def _seed_finder_backend_ref_audio(audio: Path, settings: dict) -> str:
return f"/voices/{audio.name}"
def sync_voice_clone_reference(audio: Path, transcript: str, language: str, settings: dict) -> None:
"""Publish a saved reference transcript to Qwen's hot-reloaded voices.json."""
transcript = str(transcript or "").strip()
if not transcript:
raise ValueError("reference transcript is required")
voices_path = _seed_finder_voices_json_path(settings)
if not voices_path.exists():
raise RuntimeError(f"TTS voices.json not found at {voices_path}")
language_name = {
"DE": "German", "EN": "English", "FR": "French", "ES": "Spanish",
"IT": "Italian", "PT": "Portuguese", "NL": "Dutch", "PL": "Polish",
"ZH": "Chinese", "JA": "Japanese", "KO": "Korean",
}.get(str(language or "").upper(), "Auto")
with _SEED_FINDER_VOICE_LOCK:
voices = json.loads(voices_path.read_text(encoding="utf-8"))
if not isinstance(voices, dict):
raise RuntimeError("voices.json is not an object")
cfg = dict(voices.get(audio.stem) or {})
cfg["ref_audio"] = _seed_finder_backend_ref_audio(audio, settings)
cfg["ref_text"] = transcript
cfg["language"] = language_name
# The old embedding belongs to a voice without this transcript.
# Removing it forces one correct warm-up on the next synthesis.
cfg.pop("speaker_embeddings", None)
voices[audio.stem] = cfg
voices_path.write_text(json.dumps(voices, indent=2, ensure_ascii=False), encoding="utf-8")
time.sleep(0.35)
def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
cfg = dict(voices.get(voice) or {})
if cfg.get("ref_audio"):

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.20.36">
<meta name="app-version" content="1.20.37">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.20.36">
<link rel="stylesheet" href="/static/style.css?v=1.20.37">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -457,12 +457,16 @@ function csMergeAliases(existing, incoming) {
return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', ');
}
function csMerge(map, sheets) {
function csMerge(map, sheets, opts = {}) {
const changed = [];
for (const s of sheets) {
const name = (s.name || '').trim(); if (!name) continue;
s.aliases = csMergeAliases({ name, aliases: '' }, s);
const key = csFindMergeKey(map, s);
// A live casting roster is authoritative: two different names shown in
// Characters found must never be folded together merely because the LLM
// calls one an alias of the other. Alias-aware merging remains useful
// while discovering characters without a roster, but is unsafe here.
const key = opts.strictRoster ? name.toLowerCase() : csFindMergeKey(map, s);
if (!map.has(key)) {
const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] };
CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); });
@ -769,7 +773,13 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
try { data = JSON.parse(raw); }
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
}
const incomingSheets = targetMode ? (data.sheets || []).filter(s => csSheetMatchesRoster(s, roster)) : (data.sheets || []);
// In roster mode the API must return the exact speaker name it was
// asked to define. Matching through aliases here let a response
// for e.g. "Zerwas" update the separately-cast "Alrik" profile.
// Keep alias matching solely for free-form discovery mode.
const incomingSheets = targetMode
? (data.sheets || []).filter(s => roster.some(n => n.toLowerCase() === String(s?.name || '').trim().toLowerCase()))
: (data.sheets || []);
// Confirmed live: a passage featuring two characters together can
// make the model write one character's real name into ANOTHER
// character's first_name/last_name field (e.g. a "Darrag" sheet
@ -790,7 +800,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
}
});
});
const changed = csMerge(map, incomingSheets);
const changed = csMerge(map, incomingSheets, { strictRoster: targetMode });
if (changed.length) prog.focus(changed[0].name);
if (!targetMode) {
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); });
@ -833,7 +843,10 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
}
} finally { prog.done(); }
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster);
// Do not consolidate alias-linked entries during a cast pass. The
// Characters-found sidebar owns the profile names and line counts; aliases
// describe a profile but never replace or remove another roster entry.
if (!targetMode) csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster);
// The live casting roster (and its Characters found sidebar) owns the
// display name. Any LLM-selected real name/role stays in aliases instead of
// renaming the profile.
@ -1341,8 +1354,8 @@ function csAlignmentBar(score, arcDirection, arcNote) {
// ── Image & Voice prompt generators ──────────────────────────────────────────
// Fallback only — the LLM-generated image_prompt (routes/conversation.py
// character_generate_prompts) is preferred and asks for the same turnaround-
// sheet format; this covers the case where that hasn't been generated yet.
// character_generate_prompts) is preferred. This is deliberately a
// photorealistic *profile portrait*; concept sheets use concept_art_prompt.
function csBuildImagePrompt(s, bookProfile) {
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
const parts = [];
@ -1368,20 +1381,19 @@ function csBuildImagePrompt(s, bookProfile) {
+ `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or `
+ `real-world default. `
: '';
return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
return `Create a photorealistic live-action square character portrait 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. `
+ `Keep one identical face identity anchor for future concept art and profile images. `
+ settingClause
+ `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — `
+ `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" `
+ `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual `
+ `choice should fit the world implied by the description, not the real one, unless the description itself is `
+ `explicitly modern/contemporary. `
+ `Include a full-body front view as the anchor, a turnaround panel (side and back views), an expression sheet `
+ `with 3-5 headshots matching their personality, a color palette swatch for hair/eyes/outfit, and labeled callouts `
+ `for signature props or clothing details. Clean production concept-art layout, plain neutral background, `
+ `original character not based on any copyrighted character.`;
+ `Show a head-and-shoulders portrait facing camera, with a natural in-character expression, on a subdued neutral studio background. `
+ `Photorealistic cinematic fantasy-film photography: natural skin texture and pores, believable anatomy, authentic cloth/leather/metal, physically plausible lighting and shadows. `
+ `Not a drawing, painting, anime, game render, plastic skin, collage, text, logo, watermark, or copyrighted character.`;
}
function csBuildVoicePrompt(s) {

View File

@ -191,24 +191,21 @@ function initElevenLabsBrowser() {
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: '' }),
body: JSON.stringify({
audio_url: url, voice_id: voiceId, transcript: '', auto_prepare: true,
lang, gender, name, tag: 'elevenlabs', group: 'elevenlabs', origin: 'cloned',
note: (btn.dataset.description || '').slice(0, 180),
source: `ElevenLabs shared voice${btn.dataset.voiceId ? ` · ${btn.dataset.voiceId}` : ''}`,
}),
});
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 toast === 'function') toast(`Imported "${name}" — transcript and benchmark are preparing in the background`, 'success');
if (typeof loadVoiceLibrary === 'function') {
_libraryLoadPromise = null;
await loadVoiceLibrary();

View File

@ -8,6 +8,13 @@ async function libraryRenderCharacters() {
const container = document.getElementById('lib-chars-list');
if (!container) return;
// This page can open before the shared voice library has finished loading.
// Ratings live in window._voices, so rendering during that gap makes every
// assigned voice look unrated even when its .meta.json still has a rating.
if ((!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); }
}
// Fetch BEFORE touching the DOM: a transient failure here (e.g. the
// server briefly unresponsive while a TTS container restart is in
// flight) must never blank out an already-populated list — confirmed
@ -98,6 +105,7 @@ async function libraryRenderCharacters() {
['gender', 'Geschlecht'],
['voice', 'Stimme zugewiesen'],
['rating', 'Stimmenbewertung'],
['profile', 'Profil vollständig'],
];
const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); })
? localStorage.getItem('ttsvc_libchars_sort') : 'tier';
@ -154,6 +162,10 @@ async function libraryRenderCharacters() {
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 || '');
},
profile: function (a, b) {
const ca = _charProfileCompleteness(a.sheet), cb = _charProfileCompleteness(b.sheet);
return cb.filled - ca.filled || (a.name || '').localeCompare(b.name || '');
},
age: function (a, b) { return (_charAgeSortVal(a.sheet) - _charAgeSortVal(b.sheet)) || (a.name || '').localeCompare(b.name || ''); },
language: function (a, b) { return _charLangLabel(a).localeCompare(_charLangLabel(b)) || (a.name || '').localeCompare(b.name || ''); },
align: function (a, b) { return (b.sheet?.moral_alignment_score ?? -1) - (a.sheet?.moral_alignment_score ?? -1) || (a.name || '').localeCompare(b.name || ''); },
@ -205,6 +217,7 @@ async function libraryRenderCharacters() {
+ '<option value="comfyui">Local ComfyUI</option>'
+ '</select>'
+ '<button class="btn-secondary btn-sm lib-chars-bulk-image-btn" data-book="' + escHtml(book) + '" title="Generate a profile picture for every checked character, from their Character Image Prompt" disabled><span class="mdi mdi-image-outline"></span> Auto-generate images (<span class="lib-chars-bulk-count-image">0</span>)</button>'
+ '<button class="btn-secondary btn-sm lib-chars-bulk-concept-btn" data-book="' + escHtml(book) + '" title="Generate a 3:2 character reference sheet for every checked character, from their Concept Art Prompt" disabled><span class="mdi mdi-image-multiple-outline"></span> Auto-generate concept art (<span class="lib-chars-bulk-count-concept">0</span>)</button>'
+ '<button class="btn-secondary btn-sm lib-chars-bulk-delete-btn" data-book="' + escHtml(book) + '" title="Delete every checked character from the library — use this to clear out stale/corrupted entries before a fresh recast" disabled><span class="mdi mdi-trash-can-outline"></span> Delete selected (<span class="lib-chars-bulk-count-delete">0</span>)</button>'
+ '<button class="btn-secondary btn-sm lib-chars-fix-lang-btn" data-book="' + escHtml(book) + '" title="Find every character whose current voice does not match this book\'s language, and design a properly-matching one — no selection needed, scans the whole production"><span class="mdi mdi-earth-arrow-right"></span> Fix wrong-language voices</button>'
+ '</div></div>'
@ -277,6 +290,8 @@ async function libraryRenderCharacters() {
const designCount = prod.querySelector('.lib-chars-bulk-count-design');
const imageBtn = prod.querySelector('.lib-chars-bulk-image-btn');
const imageCount = prod.querySelector('.lib-chars-bulk-count-image');
const conceptBtn = prod.querySelector('.lib-chars-bulk-concept-btn');
const conceptCount = prod.querySelector('.lib-chars-bulk-count-concept');
const imageProviderSel = prod.querySelector('.lib-chars-image-provider');
if (imageProviderSel) imageProviderSel.value = (typeof _appSettings !== 'undefined' && _appSettings.image_gen_provider) || '';
const deleteBtn = prod.querySelector('.lib-chars-bulk-delete-btn');
@ -297,6 +312,8 @@ async function libraryRenderCharacters() {
designBtn.disabled = n === 0;
imageCount.textContent = n;
imageBtn.disabled = n === 0;
conceptCount.textContent = n;
conceptBtn.disabled = n === 0;
deleteCount.textContent = n;
deleteBtn.disabled = n === 0;
syncTblSelectAllCb();
@ -380,6 +397,12 @@ async function libraryRenderCharacters() {
const provider = imageProviderSel ? imageProviderSel.value : '';
runBulk(imageBtn, ids, 'Generating images', function (rec) { return _charAutoGenerateImage(rec, provider); });
});
conceptBtn.addEventListener('click', function () {
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
if (!ids.length) return;
const provider = imageProviderSel ? imageProviderSel.value : '';
runBulk(conceptBtn, ids, 'Generating concept art', function (rec) { return _charAutoGenerateConceptArt(rec, provider); });
});
const fixLangBtn = prod.querySelector('.lib-chars-fix-lang-btn');
fixLangBtn?.addEventListener('click', function () {
// No selection needed — scans every character in this production for
@ -449,6 +472,11 @@ async function libraryRenderCharacters() {
prod.querySelectorAll('.lib-chars-tbl-voice-note').forEach(function (input) {
let timer = null;
input.addEventListener('click', function (e) { e.stopPropagation(); });
// The notes cell has an inline click handler for row navigation, which
// the accessibility enhancer also exposes as a keyboard button. Stop
// key events here so the document-level button shortcut cannot cancel
// normal text editing (notably Space and Enter in a textarea).
input.addEventListener('keydown', function (e) { e.stopPropagation(); });
input.addEventListener('input', function () {
clearTimeout(timer);
timer = setTimeout(async function () {
@ -848,6 +876,24 @@ function _charCardHtml(rec, allChars) {
+ '</div></div>';
}
// These are the 32 substantive profile fields shown in the character sheet.
// Identity/name and generated assets are intentionally excluded.
const _CHAR_PROFILE_COMPLETENESS_FIELDS = [
'gender', 'age_estimate', 'race_species', 'languages', 'nationality_background',
'social_class', 'profession', 'title', 'archetype', 'physical', 'clothing',
'alignment', 'arc_direction', 'arc_note', 'attribute_high', 'attribute_low',
'skills', 'capabilities', 'backstory', 'relationships', 'motivation', 'fears',
'mannerisms', 'communication_style', 'voice_pattern', 'secret', 'conflict_style',
'win_condition', 'reputation', 'religious_beliefs', 'notes', 'inventory',
];
function _charProfileCompleteness(sheet) {
const sh = sheet || {};
const filled = _CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function (key) {
return _libStr(sh[key]).trim().length > 0;
}).length;
return { filled: filled, total: _CHAR_PROFILE_COMPLETENESS_FIELDS.length };
}
// Compact alternative to the card grid for scanning a large cast at once —
// one row per character with the fields that matter for casting decisions.
// Rows carry the "lib-char-card" class too so they're picked up by the same
@ -865,7 +911,11 @@ function _charsTableHtml(chars, sortMode, sortDir) {
const voiceMeta = voiceId ? (window._voices || []).find(function (v) { return v.id === voiceId; }) : null;
const voiceMissing = !!voiceId && !_voiceExists(voiceId);
const voiceRating = Math.max(0, Math.min(5, Number(voiceMeta?.rating || 0)));
const voiceNote = _libStr(voiceMeta?.note);
// Designed voices used to copy a clipped Voice Design Prompt into the
// general note field. The prompt now has its own column; keep Notes for
// user-authored comments only, and don't display that legacy duplicate.
const rawVoiceNote = _libStr(voiceMeta?.note);
const voiceNote = /^voice design\s*:/i.test(rawVoiceNote) ? '' : rawVoiceNote;
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) {
@ -892,6 +942,8 @@ function _charsTableHtml(chars, sortMode, sortDir) {
const archetype = _libStr(sh.archetype);
const titleLabel = _libStr(sh.title);
const tableMeta = [aliasLabel ? 'aka ' + aliasLabel : '', occupation ? 'Occupation: ' + occupation : '', titleLabel ? 'Title: ' + titleLabel : '', archetype ? 'Archetype: ' + archetype : ''].filter(Boolean).join(' · ');
const profile = _charProfileCompleteness(sh);
const profileClass = profile.filled <= 8 ? 'is-sparse' : profile.filled <= 20 ? 'is-partial' : 'is-strong';
return '<tr class="lib-char-card lib-chars-tbl-row" data-char-id="' + escHtml(rec.id) + '">'
+ '<td class="lib-chars-tbl-cb" onclick="event.stopPropagation()"><input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '"></td>'
+ '<td class="lib-chars-tbl-avatar-cell"><div class="lib-char-avatar lib-chars-tbl-avatar" data-char-id="' + escHtml(rec.id) + '" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + avatarInner + '</div></td>'
@ -918,9 +970,11 @@ function _charsTableHtml(chars, sortMode, sortDir) {
+ '<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>')
+ (voiceId ? '<textarea class="lib-chars-tbl-voice-note" data-voice-id="' + escHtml(voiceId) + '" rows="2" placeholder="Add note…" title="Voice Library note">' + escHtml(voiceNote) + '</textarea>' : '<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-book-cell">' + (bookLabel ? escHtml(bookLabel) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td class="lib-chars-tbl-profile"><span class="lib-chars-profile-count ' + profileClass + '" title="' + profile.filled + ' von ' + profile.total + ' Profilfeldern ausgefüllt">' + profile.filled + ' / ' + profile.total + '</span></td>'
+ '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
+ '</tr>';
}).join('');
@ -932,13 +986,13 @@ function _charsTableHtml(chars, sortMode, sortDir) {
// the same row. Fixed explicit widths sidestep the whole class of bug.
+ '<colgroup><col style="width:28px"><col style="width:60px"><col style="width:300px">'
+ '<col style="width:88px"><col style="width:80px"><col style="width:72px"><col style="width:100px">'
+ '<col style="width:140px"><col style="width:230px"><col style="width:126px"><col style="width:360px"><col style="width:260px"><col style="width:220px"><col style="width:200px">'
+ '<col style="width:140px"><col style="width:230px"><col style="width:126px"><col style="width:440px"><col style="width:260px"><col style="width:220px"><col style="width:200px"><col style="width:90px">'
+ '<col style="width:36px"></colgroup>'
+ '<thead><tr>'
+ '<th class="lib-chars-tbl-cb"><input type="checkbox" class="lib-chars-tbl-select-all-cb" title="Select all"></th><th></th>' + th('alpha', 'Name') + th('gender', 'Geschlecht') + th('age', 'Alter', 'Estimated age')
+ th('lines', 'Zeilen', 'Anzahl Zeilen') + th('language', 'Sprache') + th('align', 'Gut/Böse', 'Moralische Gesinnung') + th('voice', 'Stimme') + th('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 class="lib-chars-tbl-sortable" data-sort-key="profile" title="Filled profile fields">Profil</th><th></th>'
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
@ -2794,11 +2848,9 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
group: rec.book || undefined,
tag: rec.book || undefined,
transcript: sampleText,
note: 'Voice Design: ' + instruct.slice(0, 240),
// For a designed voice the instruct IS the voice's identity — the TTS
// engine reproduces it from this text alone. `note` is a short display
// summary and is deliberately clipped, so it cannot be the source of
// truth; store the prompt in full here.
// For a designed voice the instruct IS the voice's identity. The
// dedicated Voice Design Prompt column shows it in full; leave note
// untouched for the user's own review comments.
voice_design_prompt: instruct,
}).catch(function () {});
}
@ -2833,7 +2885,7 @@ async function _charAutoGenerateImage(rec, provider) {
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
if (!prompt) throw new Error('No image prompt to work from yet');
const body = { prompt: prompt };
const body = { prompt: prompt, image_kind: 'portrait' };
if (provider) body.provider = provider;
const r = await fetch('/api/character-generate-image', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -2865,7 +2917,7 @@ async function _charAutoGenerateConceptArt(rec, provider) {
const sh = rec.sheet || {};
const prompt = _libStr(sh.concept_art_prompt).trim();
if (!prompt) throw new Error('No concept art prompt to work from yet — generate the prompt first');
const body = { prompt: prompt };
const body = { prompt: prompt, image_kind: 'concept_art' };
if (provider) body.provider = provider;
const r = await fetch('/api/character-generate-image', {
method: 'POST', headers: { 'Content-Type': 'application/json' },

View File

@ -2187,8 +2187,14 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
if (!rehState.cast[sp]) { done++; continue; }
const info = byName[sp.toUpperCase().trim()] || {};
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
const desc = info.description ||
`A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`;
const hardGender = gender === 'M' ? 'male' : gender === 'F' ? 'female' : 'gender-neutral';
const hardAge = info.age || 'age unspecified';
const hardLanguage = info.language || language || 'the script language';
const desc = [
`HARD CASTING CONSTRAINTS: ${hardGender} voice, apparent age ${hardAge}, spoken in ${hardLanguage}.`,
info.description || `Character named ${sp}; natural expressive audiobook voice.`,
`Make this voice audibly distinct from the other cast members: use a memorable vocal fingerprint, not a generic narrator voice.`,
].join(' ');
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
@ -2223,6 +2229,7 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
if (typeof saveMeta === 'function') {
await saveMeta(saved.voice_id, {
gender,
age: info.age || '',
name: charName,
avatar: _pickVoiceAvatar(gender, desc, sp),
origin: 'designed',
@ -2310,7 +2317,7 @@ $('reh-matchlib-btn')?.addEventListener('click', async () => {
if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }
const candidates = lib.map(v => ({
id: v.id, gender: v.gender || '', language: v.lang || '',
id: v.id, gender: v.gender || '', age: v.age || '', language: v.lang || '',
tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
}));
const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp);

View File

@ -3320,7 +3320,7 @@ function makeVoiceRow(v) {
_voices = _voices.filter(x => x.id !== v.id);
wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0';
setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300);
toast(`Deleted: ${v.id}`, 'success');
toast(`Moved to Voice Trash: ${v.id}`, 'success');
} catch(e) {
toast('Delete failed: ' + e.message, 'error');
deleteGoBtn.disabled = false;
@ -3351,7 +3351,7 @@ function renderVoiceGroupsBar() {
names.map(g =>
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
${escHtml(g)} <span class="vg-count">${groups[g]}</span>
<button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">&times;</button>
<button class="vg-del" data-group="${escHtml(g)}" title="Move all ${groups[g]} voices in this group to Voice Trash">&times;</button>
</span>`
).join('') +
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
@ -3377,7 +3377,7 @@ function renderVoiceGroupsBar() {
async function deleteVoiceGroup(group) {
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return;
if (!confirm(`Move all ${count} voice${count!==1?'s':''} in group "${group}" to Voice Trash? You can restore the complete packages from the trash folder later.`)) return;
try {
const r = await fetch('/api/voices/delete-group', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -3386,7 +3386,7 @@ async function deleteVoiceGroup(group) {
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success');
toast(`Moved ${d.count} voice${d.count!==1?'s':''} from "${group}" to Voice Trash`, 'success');
await loadVoiceLibrary();
} catch(e) { toast('Delete failed: ' + e.message, 'error'); }
}
@ -3644,13 +3644,13 @@ function _showBulkDeleteConfirm(ids) {
const ov = document.createElement('div');
ov.className = 'vl-bdc-overlay';
ov.innerHTML = `
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices">
<div class="vl-bdc-title"><span class="mdi mdi-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div>
<p class="vl-bdc-sub">This permanently removes the selected voice${plural} from your library. This cannot be undone.</p>
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm move voices to trash">
<div class="vl-bdc-title"><span class="mdi mdi-delete-outline"></span> Move ${ids.length} voice${plural} to Voice Trash?</div>
<p class="vl-bdc-sub">This removes the selected voice${plural} from the library while preserving each complete package in the Voice Trash folder.</p>
<div class="vl-bdc-list">${names}${more}</div>
<div class="vl-bdc-actions">
<button type="button" class="vl-bdc-cancel">Cancel</button>
<button type="button" class="vl-bdc-go">Delete ${ids.length} voice${plural}</button>
<button type="button" class="vl-bdc-go">Move ${ids.length} voice${plural} to Trash</button>
</div>
</div>`;
@ -3670,9 +3670,9 @@ function _showBulkDeleteConfirm(ids) {
const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (r.ok) done++; else errors++;
} catch (_) { errors++; }
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Deleting ${n}/${ids.length}…`; });
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Moving ${n}/${ids.length}…`; });
close();
toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
toast(`Moved ${done} voice${done !== 1 ? 's' : ''} to Voice Trash${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
_bulkSelected.clear();
await loadVoiceLibrary();
});

View File

@ -677,7 +677,7 @@
<div class="card" id="comfyui-config-panel" hidden style="margin-top:16px">
<div class="s-page-head">
<h3><span class="mdi mdi-graph-outline"></span> Local ComfyUI Workflow</h3>
<p>Runs a workflow on your own GPU instead of a paid API. Export it from ComfyUI's own UI first &mdash; Workflow menu &rarr; Export (API Format) &mdash; then paste the result below. The same workflow is used for both single portraits and character concept sheets; only the prompt text sent to it differs.</p>
<p>Runs on your own GPU, with no paid API. Voice Creator includes validated high-quality local Flux workflows: a square profile portrait and a 3:2 concept-art sheet. Leave the advanced workflow fields blank to use them automatically. An advanced workflow below overrides profile portraits; concept-art sheets always retain their dedicated 3:2 workflow.</p>
</div>
<div style="display:flex; flex-direction:column; gap:14px">
<div class="s-field">
@ -686,9 +686,9 @@
<span class="s-hint">Base URL of your running ComfyUI instance (container port 8188 by default).</span>
</div>
<div class="s-field">
<label>Workflow JSON (API format)</label>
<label>Advanced workflow JSON (API format, optional override)</label>
<textarea id="comfyui-workflow" rows="6" spellcheck="false" placeholder='{"3": {"class_type": "KSampler", "inputs": {...}}, "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", ...}}, ...}' style="font-family:monospace; font-size:12px"></textarea>
<span class="s-hint">Paste the raw JSON exported from ComfyUI's "Export (API Format)" action — not a regular saved workflow (that format has "nodes"/"links" keys and won't work here).</span>
<span class="s-hint">Leave blank for Voice Creator's validated Flux Dev portrait and concept-sheet workflows. Paste raw JSON exported from ComfyUI's "Export (API Format)" only when you deliberately want to replace both.</span>
</div>
<div style="display:flex; gap:14px; flex-wrap:wrap">
<div class="s-field" style="flex:1; min-width:160px">

View File

@ -608,7 +608,11 @@ audio { width: 100%; }
.lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); }
/* 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 {
width:100%; max-width:100%; min-width:0; overflow-x:auto; overflow-y:hidden;
border:1px solid var(--border); border-radius:12px; background:var(--surface);
position:relative; scrollbar-gutter:stable;
}
.lib-chars-tbl { width:100%; min-width:2410px; table-layout:fixed; border-collapse:collapse; font-size:12.9px; white-space:nowrap; }
.lib-chars-tbl td { overflow: hidden; text-overflow: ellipsis; }
.lib-chars-tbl thead th {
@ -686,18 +690,34 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
matching its own header entirely). Flex only the inner wrapper instead. */
.lib-chars-tbl-tags { white-space:normal; max-width:260px; }
.lib-chars-tbl-tags-wrap { display:flex; flex-wrap:wrap; gap:3px; }
.lib-chars-tbl-profile { text-align:center; }
.lib-chars-profile-count {
display:inline-block; min-width:54px; padding:3px 6px; border-radius:999px;
font-size:11.5px; font-weight:800; font-variant-numeric:tabular-nums;
border:1px solid var(--border); background:var(--panel); color:var(--subtext);
}
.lib-chars-profile-count.is-sparse { color:#b42318; background:rgba(239,68,68,.10); border-color:rgba(239,68,68,.35); }
.lib-chars-profile-count.is-partial { color:#a15c00; background:rgba(245,158,11,.12); border-color:rgba(245,158,11,.4); }
.lib-chars-profile-count.is-strong { color:#137333; background:rgba(34,197,94,.10); border-color:rgba(34,197,94,.35); }
.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-prompt {
color:var(--subtext); font-size:11.5px; font-style:italic;
white-space:normal; overflow-wrap:anywhere; word-break:normal; line-height:1.4;
vertical-align:top;
}
.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;
display:block; width:100%; min-width:0; min-height:42px; resize:vertical;
border:1px solid transparent; background:transparent; color:var(--text);
padding:4px 5px; border-radius:5px; font:inherit; line-height:1.35;
white-space:pre-wrap; overflow-wrap:anywhere;
}
.lib-chars-tbl-voice-note:hover, .lib-chars-tbl-voice-note:focus { border-color:var(--border); background:var(--panel); outline:none; }
.lib-chars-tbl-voice-note:focus { min-height:64px; }
.lib-chars-tbl-dash { opacity:.4; }
.lib-chars-tbl-check { display:inline-flex; }
.lib-chars-tbl-check.is-yes { color:#4caf50; }

View File

@ -0,0 +1,38 @@
{
"1": {
"class_type": "UNETLoader",
"inputs": {"unet_name": "flux1-schnell-fp8.safetensors", "weight_dtype": "fp8_e4m3fn"}
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["8", 0]}
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry, low resolution, distorted face, duplicate person, extra limbs, malformed hands, cropped figures, readable text, logo, watermark", "clip": ["8", 0]}
},
"4": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 1536, "height": 1024, "batch_size": 1}
},
"5": {
"class_type": "KSampler",
"inputs": {"seed": 425174, "steps": 4, "cfg": 1.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], "latent_image": ["4", 0]}
},
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["9", 0]}
},
"7": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "voice-creator/concept-sheet", "images": ["6", 0]}
}
,"8": {
"class_type": "DualCLIPLoader",
"inputs": {"clip_name1": "t5xxl_fp8_e4m3fn.safetensors", "clip_name2": "clip_l.safetensors", "type": "flux", "device": "cpu"}
},
"9": {
"class_type": "VAELoader",
"inputs": {"vae_name": "flux-vae-bf16.safetensors"}
}
}

View File

@ -0,0 +1,30 @@
{
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "flux1-dev-fp8.safetensors"}
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["1", 1]}
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry, low resolution, distorted face, duplicate person, extra limbs, malformed hands, cropped head, readable text, logo, watermark", "clip": ["1", 1]}
},
"4": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 1024, "height": 1024, "batch_size": 1}
},
"5": {
"class_type": "KSampler",
"inputs": {"seed": 0, "steps": 28, "cfg": 3.5, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], "latent_image": ["4", 0]}
},
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]}
},
"7": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "voice-creator/portrait", "images": ["6", 0]}
}
}