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 ## [1.20.36] — 2026-08-15
### Added ### Added

View File

@ -31,6 +31,7 @@ COPY CHANGELOG.md .
COPY VERSION . COPY VERSION .
COPY core/ core/ COPY core/ core/
COPY routes/ routes/ COPY routes/ routes/
COPY workflows/ workflows/
COPY static/ static/ COPY static/ static/
# ── Vendor Tesseract.js (client-side OCR for image-based PDF chapter headings) ─ # ── 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) _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): def _voice_audio_files(root: Path):
# Single rglob pass instead of one per extension (6× faster directory scan) # Single rglob pass instead of one per extension (6× faster directory scan)
for p in root.rglob("*"): 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 yield p
def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None: def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None:
for ext in _AUDIO_EXTS: for ext in _AUDIO_EXTS:
for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")): 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 p
return None return None
@ -322,7 +332,11 @@ def _voice_health(
def _voice_entry(p: Path) -> dict: def _voice_entry(p: Path) -> dict:
meta = _load_meta(p) meta = _load_meta(p)
parts = p.stem.split("_", 2) 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: try:
duration = round(_duration(p), 2) duration = round(_duration(p), 2)
except Exception: 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 from core.tts_helpers import _tts_benchmark_request
has_ref, _transcript = _read_reference_text(audio) has_ref, _transcript = _read_reference_text(audio)
meta = _load_meta(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] = [] runs: list[dict] = []
for label, text in sentences: for label, text in sentences:
try: try:

View File

@ -24,6 +24,7 @@ services:
- ./server.py:/app/server.py:ro - ./server.py:/app/server.py:ro
- ./core:/app/core:ro - ./core:/app/core:ro
- ./routes:/app/routes:ro - ./routes:/app/routes:ro
- ./workflows:/app/workflows:ro
- ./VERSION:/app/VERSION:ro - ./VERSION:/app/VERSION:ro
- ./CHANGELOG.md:/app/CHANGELOG.md: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)) raw = base64.b64decode(m.group(2))
except Exception: except Exception:
raise HTTPException(404, "Invalid image data") 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}") @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, " "- description: 2–3 sentences covering pitch (high/mid/low), pace, "
"timbre, accent/dialect, emotional default, and any distinctive speech trait " "timbre, accent/dialect, emotional default, and any distinctive speech trait "
"that fits the character's personality and role.\n" "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" "Respond with STRICT JSON only — no markdown, no explanation:\n"
'{"characters":[{"name":"NAME","gender":"M|F|N","language":"LANG",' '{"characters":[{"name":"NAME","gender":"M|F|N","language":"LANG",'
'"age":"30s","description":"voice description"}]}\n' '"age":"30s","description":"voice description"}]}\n'
@ -493,7 +500,7 @@ async def match_characters_voices(request: Request):
script = script[:5000] script = script[:5000]
def _vline(v: dict) -> str: 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"): if v.get("tags"):
meta.append("tags:" + str(v["tags"])) meta.append("tags:" + str(v["tags"]))
desc = str(v.get("description") or v.get("name") or "")[:120] 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 " "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 " "character's gender is clear from the script; only pick a different gender if no same-gender "
"voice exists in the catalogue. " "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. " "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. " "Reuse a voice for two characters only if no better distinct option exists. "
"Respond with STRICT JSON only, no markdown:\n" "Respond with STRICT JSON only, no markdown:\n"
@ -564,7 +573,19 @@ async def match_characters_voices(request: Request):
except Exception: except Exception:
continue continue
# Keep only assignments that reference a real catalogue voice # 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} return {"assignments": clean}
@ -1317,28 +1338,19 @@ async def character_generate_prompts(request: Request):
preamble preamble
+ visual_identity_rule + 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: an English image-generation prompt for a photorealistic audiobook character portrait, "
"(a single composite image, like a game/animation production turnaround), not just one portrait. " "written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
"FIRST, from the book title and profile, work out the story's genre, setting, and era (e.g. " "Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
"'medieval European-inspired high fantasy', 'grimdark low fantasy', 'space opera sci-fi', " "(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"
"'contemporary urban fantasy') — an image model has no idea what a book title implies and will " " Task: Generate a Character portrait.\n"
"default to generic modern/real-world imagery unless told explicitly, which is exactly wrong for " " Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
"an occupation like 'Admiral' or 'General' in a fantasy world (it will draw a 20th-century military " "content. Include every relevant visual fact from the profile.\n"
"uniform instead of that world's actual equivalent). State that genre/setting/era explicitly, as " " Context: a believable live-action character in the book's established original world and era.\n"
"its own descriptor near the START of the prompt, and make clear the world has no real-world 20th " " 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"
"or 21st century technology, uniforms, or clothing unless the story is actually confirmed " " Composition: a simple square headshot/bust on a subdued neutral studio background, facing forward with a subtle in-character expression.\n"
"contemporary/near-future — every other visual choice (armor, dress, rank insignia, weapons) must " " Lighting: flattering neutral photographic key light with soft rim separation, realistic shadow falloff, and no stylized color cast.\n"
"fit THAT world, not the real one. Then ask the image model for: (1) a full-body front-view " " 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"
"illustration as the anchor, (2) a turnaround panel with side and back views, (3) a small " " Output: one production-ready square live-action character portrait.\n\n"
"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"
'Respond with STRICT JSON only: {"image_prompt":""}/no-think' 'Respond with STRICT JSON only: {"image_prompt":""}/no-think'
), ),
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet), "silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
@ -1347,32 +1359,17 @@ async def character_generate_prompts(request: Request):
+ visual_identity_rule + 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 — 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). Use the following two reference layouts as the structural model, but replace EVERY " "(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 fact with the current character profile:\n" " Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
" Task: choose the best sheet purpose for the profile — a three-view character turnaround with " "content. Include every relevant visual fact from the profile. For each prop/accessory, state its exact count.\n"
"accessory callouts, or a character props/accessories callout sheet.\n" " Context: a costume and prop handoff reference for the book's established original world and era.\n"
" Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established " " 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"
"content. Include every relevant visual fact from the profile: age, species, face, skin, hair, eyes, " " 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"
"facial hair, marks, build, clothing layers, colors, armor, jewelry, weapons, tools, and signature " " Lighting: neutral studio reference lighting with gentle shading that keeps every construction detail readable.\n"
"accessories. For each profile-established prop/accessory, state its exact count; omit anything not in " " 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"
"the profile. Never copy the herbalist/cartographer examples.\n" " Output: one production-ready 3:2 NPC reference sheet.\n\n"
" Context: state the book's profile-derived genre, setting, era, and the production purpose of the sheet.\n"
" Style: retain the reference style principles — detailed hand-painted or painterly game concept art, "
"grounded leather/metal/cloth/wood materials, clear readable shape language, and coherent wear — but "
"choose the visual medium and palette treatment appropriate to this book's profile.\n"
" Composition: choose either (A) aligned full-body front, side, and back views across the top with a "
"dialogue/action pose and one clean accessory callout row below, or (B) one full-body three-quarter "
"hero figure at left with exactly one isolated callout for each profile-established accessory at right. "
"Use clean leader lines, generous spacing, safe margins, and keep every figure and accessory fully visible.\n"
" Lighting: use warm-neutral or neutral studio light that reveals material texture, with soft contact "
"shadows and no cinematic color cast unless the profile or book context explicitly requires one.\n"
" Constraints: same exact gender, face identity anchor, body proportions, silhouette, garment seams, "
"colors, wear, accessory scale, attachment points, and prop count in every view; each accessory appears "
"once and matches the carried version; no duplicate gear, disassembled fragments, floating objects, extra "
"limbs, readable text, logos, real-world emblems, franchise references, or watermark.\n"
" Output: one organized, production-ready 3:2 character reference sheet.\n\n"
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think' 'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
), ),
} }
@ -1547,6 +1544,47 @@ async def audiobook_consistency_check(request: Request):
return {"outliers": clean} 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, def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
prompt_field: str, output_node_id: str, prompt: str) -> str: prompt_field: str, output_node_id: str, prompt: str) -> str:
"""Submits a pre-exported ComfyUI API-format workflow with the given """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: if not comfyui_url:
raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)") 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: if not prompt_node_id or not output_node_id:
raise HTTPException(400, "ComfyUI prompt/output node IDs not set (Settings > Engines > Image Generation)") raise HTTPException(400, "ComfyUI prompt/output node IDs not set (Settings > Engines > Image Generation)")
try: 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 # 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 # 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. # 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": if provider == "openai":
api_key = (keys.get("openai_image") or "").strip() api_key = (keys.get("openai_image") or "").strip()
if not api_key: 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() api_key = (keys.get("google_image") or "").strip()
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-3.1-flash-image"
# Google retired the preview alias; keep older saved settings usable # Google retired the preview alias and moved image models to Interactions API.
# instead of turning a previously working Google selection into a 404. if gmodel in ["gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"]:
if gmodel == "gemini-2.5-flash-image-preview": gmodel = "gemini-3.1-flash-image"
gmodel = "gemini-2.5-flash-image"
try: 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( 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"}, headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
# Gemini image models do not return image bytes unless image json=payload,
# 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,
) )
# 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: if not resp.ok:
detail = resp.text[:400] detail = resp.text[:400]
try: try:
@ -1761,15 +1814,29 @@ 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}")
payload = resp.json()
parts = (payload.get("candidates") or [{}])[0].get("content", {}).get("parts", []) payload_json = resp.json()
inline = next((p.get("inlineData") or p.get("inline_data") for p in parts if p.get("inlineData") or p.get("inline_data")), None)
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: if not inline:
# Preserve Google's useful finish/safety text in the error; # Preserve Google's useful finish/safety text in the error;
# this makes quota, blocked prompts, and unavailable models # this makes quota, blocked prompts, and unavailable models
# distinguishable from a client parsing bug. # 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 — check the selected image model, quota, or prompt.")
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:
@ -1786,7 +1853,9 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
api_key = (keys.get("openrouter") or "").strip() api_key = (keys.get("openrouter") or "").strip()
if not api_key: if not api_key:
raise HTTPException(400, "OpenRouter API key not set (Settings > Engines > Language Models > OpenRouter)") 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: try:
resp = requests.post( resp = requests.post(
"https://openrouter.ai/api/v1/chat/completions", "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}") raise HTTPException(502, f"OpenRouter image generation failed: {e}")
if provider == "comfyui": 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( image = _comfyui_run(
(_settings.get("comfyui_url") or "").strip(), (_settings.get("comfyui_url") or "").strip(),
_settings.get("comfyui_workflow") or "", workflow_json,
(_settings.get("comfyui_prompt_node_id") or "").strip(), prompt_node_id,
(_settings.get("comfyui_prompt_field") or "text").strip(), prompt_field,
(_settings.get("comfyui_output_node_id") or "").strip(), output_node_id,
prompt, prompt,
) )
return {"image": image} return {"image": image}

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-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": "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": "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."}, {"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 re
import shutil import shutil
import uuid import uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
@ -39,6 +40,68 @@ from core.voice_index import (
router = APIRouter() 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) ────────────────── # ── Book/production profile (genre, setting, era, language) ──────────────────
# A one-time-per-book note the user fills in ("German, fantasy like Lord of # 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 = _load_meta(wav)
meta["enabled"] = enabled meta["enabled"] = enabled
for field in ("note", "rating", "flag", "gender", "loudness", "persona", "origin", "group", for field in ("note", "rating", "flag", "gender", "age", "lang", "language", "loudness", "persona", "origin", "group",
"name", "tag", "avatar", "voice_design_prompt"): "name", "tag", "source", "avatar", "voice_design_prompt"):
if field in data: 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: if "transcript" in data:
meta["needs_tts_restart"] = True meta["needs_tts_restart"] = True
_save_meta(wav, meta) _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") @router.post("/api/voices/normalize-active")
async def normalize_active_voices(): async def normalize_active_voices():
settings = _load_settings() settings = _load_settings()
@ -694,26 +796,35 @@ async def rename_voice(request: Request):
# ── Voice delete ────────────────────────────────────────────────────────────── # ── 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") @router.post("/api/voices/delete-group")
async def delete_voice_group(request: Request): async def delete_voice_group(request: Request):
"""Delete every voice whose meta `group` matches the given name. Used to bulk-remove """Move every voice in a group to the recoverable voice trash."""
a rehearser play's auto-designed voices."""
data = await request.json() data = await request.json()
group = str(data.get("group") or "").strip() group = str(data.get("group") or "").strip()
if not group: if not group:
raise HTTPException(400, "No group specified") raise HTTPException(400, "No group specified")
settings = _load_settings() settings = _load_settings()
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) 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): for p in _voice_audio_files(scan_dir):
meta = _load_meta(p) meta = _load_meta(p)
if str(meta.get("group") or "").strip() == group: if str(meta.get("group") or "").strip() == group:
for f in _voice_package_paths(p): _trash_voice_package(p, batch_dir)
if f.exists(): trashed_ids.append(p.stem)
f.unlink()
deleted_ids.append(p.stem)
remove_voice_from_index(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}") @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) wav = _find_voice_audio(voice_id, scan_dir)
if wav is None: if wav is None:
raise HTTPException(404, f"Voice '{voice_id}' not found") raise HTTPException(404, f"Voice '{voice_id}' not found")
deleted = [] batch_dir = _voice_trash_batch_dir(scan_dir, f"voice-{voice_id}")
for f in _voice_package_paths(wav): moved = _trash_voice_package(wav, batch_dir)
if f.exists():
f.unlink()
deleted.append(f.name)
remove_voice_from_index(voice_id) 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 ────────────────────────────────────────────── # ── Voice picture upload / serve ──────────────────────────────────────────────

View File

@ -16,18 +16,82 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from core.config import _load_settings 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.registry import _registry_put, TEMP_DIR
from core.validation import _validate_http_url from core.validation import _validate_http_url
from core.audio import _to_wav_24k, _duration from core.audio import _to_wav_24k, _duration
from core.voice import ( from core.voice import (
_AUDIO_EXTS, _active_voices_dir, _AUDIO_EXTS, _active_voices_dir,
_remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta, _remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta,
_benchmark_voice,
) )
from core.voice_index import upsert_voice_in_index from core.voice_index import upsert_voice_in_index
router = APIRouter() 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 helpers ──────────────────────────────────────────────────────
_VOICE_SOURCE_HEADERS = { _VOICE_SOURCE_HEADERS = {
@ -506,6 +570,9 @@ async def quick_import_voice(request: Request):
if not voice_id: if not voice_id:
raise HTTPException(400, "voice_id is required") raise HTTPException(400, "voice_id is required")
transcript = str(data.get("transcript") or "").strip() 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() suffix = Path(urlparse(audio_url).path).suffix.lower()
if suffix not in _AUDIO_EXTS: if suffix not in _AUDIO_EXTS:
@ -548,11 +615,28 @@ async def quick_import_voice(request: Request):
if transcript: if transcript:
(out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8") (out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8")
meta = _load_meta(wav_dest) meta = _load_meta(wav_dest)
meta["enabled"] = True meta.update({
meta["loudness"] = loudness "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) _save_meta(wav_dest, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest) 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") @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)", "groq_whisper": "Groq Whisper (cloud · free)",
} }
port = _backend_port_label(url) 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 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]]: def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
base = _validate_http_url(url, allow_private=True).rstrip("/") 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] = [] models: list[str] = []
ok = False ok = False
try: try:
r = requests.get(f"{base}/health", timeout=2) r = requests.get(f"{api_base}/health", timeout=2)
ok = r.status_code == 200 ok = r.status_code == 200
except Exception: except Exception:
pass pass
try: 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: if r.status_code == 200:
ok = True ok = True
payload = r.json() payload = r.json()
@ -174,42 +185,12 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
pass pass
if not ok: if not ok:
return False, models return False, models
try: # Do not send a real audio transcription as a health probe. WhisperX's
wav = _make_minimal_wav() # batch server uses one worker and a probe consumes the GPU for several
endpoint_found = False # seconds; while it runs, even /health blocks and the UI reports the
timed_out = False # otherwise healthy service as unreachable. The service health endpoint is
for path in ("/v1/audio/transcriptions", "/transcribe"): # the authoritative liveness check; the real transcription path is tested
try: # when the user actually transcribes audio.
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
return ok, models 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) stt_key = _stt_backend_api_key(settings, backend)
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {} hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
model = _stt_backend_model(backend) 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 resp = None
for path in paths: for path in paths:
with src.open("rb") as f: with src.open("rb") as f:
endpoint = f"{stt_url}{path}"
if path == "/../transcribe":
endpoint = f"{stt_url[:-3].rstrip('/')}/transcribe"
resp = requests.post( resp = requests.post(
f"{stt_url}{path}", endpoint,
files={"file": ("audio.wav", f, "audio/wav")}, files={"file": ("audio.wav", f, "audio/wav")},
data={"model": model, "response_format": "text"}, data={"model": model, "response_format": "text"},
headers=hdrs, headers=hdrs,

View File

@ -231,6 +231,35 @@ def _seed_finder_backend_ref_audio(audio: Path, settings: dict) -> str:
return f"/voices/{audio.name}" 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: def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
cfg = dict(voices.get(voice) or {}) cfg = dict(voices.get(voice) or {})
if cfg.get("ref_audio"): 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="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.36"> <meta name="app-version" content="1.20.37">
<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.36"> <link rel="stylesheet" href="/static/style.css?v=1.20.37">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── 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(', '); return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', ');
} }
function csMerge(map, sheets) { function csMerge(map, sheets, opts = {}) {
const changed = []; const changed = [];
for (const s of sheets) { for (const s of sheets) {
const name = (s.name || '').trim(); if (!name) continue; const name = (s.name || '').trim(); if (!name) continue;
s.aliases = csMergeAliases({ name, aliases: '' }, s); 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)) { if (!map.has(key)) {
const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] }; const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] };
CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); }); 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); } try { data = JSON.parse(raw); }
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); } 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 // Confirmed live: a passage featuring two characters together can
// make the model write one character's real name into ANOTHER // make the model write one character's real name into ANOTHER
// character's first_name/last_name field (e.g. a "Darrag" sheet // 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 (changed.length) prog.focus(changed[0].name);
if (!targetMode) { if (!targetMode) {
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); (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(); } } finally { prog.done(); }
if (_cs.cancel) { toast('Cancelled', 'error'); return null; } 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 // The live casting roster (and its Characters found sidebar) owns the
// display name. Any LLM-selected real name/role stays in aliases instead of // display name. Any LLM-selected real name/role stays in aliases instead of
// renaming the profile. // renaming the profile.
@ -1341,8 +1354,8 @@ function csAlignmentBar(score, arcDirection, arcNote) {
// ── Image & Voice prompt generators ────────────────────────────────────────── // ── Image & Voice prompt generators ──────────────────────────────────────────
// Fallback only — the LLM-generated image_prompt (routes/conversation.py // Fallback only — the LLM-generated image_prompt (routes/conversation.py
// character_generate_prompts) is preferred and asks for the same turnaround- // character_generate_prompts) is preferred. This is deliberately a
// sheet format; this covers the case where that hasn't been generated yet. // photorealistic *profile portrait*; concept sheets use concept_art_prompt.
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 = [];
@ -1368,20 +1381,19 @@ function csBuildImagePrompt(s, bookProfile) {
+ `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or ` + `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or `
+ `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 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, ` + `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. ` + `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 + 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" `
+ `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual ` + `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 ` + `choice should fit the world implied by the description, not the real one, unless the description itself is `
+ `explicitly modern/contemporary. ` + `explicitly modern/contemporary. `
+ `Include a full-body front view as the anchor, a turnaround panel (side and back views), an expression sheet ` + `Show a head-and-shoulders portrait facing camera, with a natural in-character expression, on a subdued neutral studio background. `
+ `with 3-5 headshots matching their personality, a color palette swatch for hair/eyes/outfit, and labeled callouts ` + `Photorealistic cinematic fantasy-film photography: natural skin texture and pores, believable anatomy, authentic cloth/leather/metal, physically plausible lighting and shadows. `
+ `for signature props or clothing details. Clean production concept-art layout, plain neutral background, ` + `Not a drawing, painting, anime, game render, plastic skin, collage, text, logo, watermark, or copyrighted character.`;
+ `original character not based on any copyrighted character.`;
} }
function csBuildVoicePrompt(s) { function csBuildVoicePrompt(s) {

View File

@ -191,24 +191,21 @@ function initElevenLabsBrowser() {
try { try {
const r = await fetch('/api/quick-import-voice', { const r = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, 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) { if (!r.ok) {
const e = await r.json().catch(() => ({})); const e = await r.json().catch(() => ({}));
throw new Error(e.detail || r.statusText); throw new Error(e.detail || r.statusText);
} }
const d = await r.json(); 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.innerHTML = '<span class="mdi mdi-check"></span> In library';
btn.classList.add('el-imported'); 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') { if (typeof loadVoiceLibrary === 'function') {
_libraryLoadPromise = null; _libraryLoadPromise = null;
await loadVoiceLibrary(); await loadVoiceLibrary();

View File

@ -8,6 +8,13 @@ async function libraryRenderCharacters() {
const container = document.getElementById('lib-chars-list'); const container = document.getElementById('lib-chars-list');
if (!container) return; 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 // Fetch BEFORE touching the DOM: a transient failure here (e.g. the
// server briefly unresponsive while a TTS container restart is in // server briefly unresponsive while a TTS container restart is in
// flight) must never blank out an already-populated list — confirmed // flight) must never blank out an already-populated list — confirmed
@ -98,6 +105,7 @@ async function libraryRenderCharacters() {
['gender', 'Geschlecht'], ['gender', 'Geschlecht'],
['voice', 'Stimme zugewiesen'], ['voice', 'Stimme zugewiesen'],
['rating', 'Stimmenbewertung'], ['rating', 'Stimmenbewertung'],
['profile', 'Profil vollständig'],
]; ];
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';
@ -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); }; 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 || ''); 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 || ''); }, 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 || ''); },
@ -205,6 +217,7 @@ async function libraryRenderCharacters() {
+ '<option value="comfyui">Local ComfyUI</option>' + '<option value="comfyui">Local ComfyUI</option>'
+ '</select>' + '</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-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-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>' + '<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>' + '</div></div>'
@ -277,6 +290,8 @@ async function libraryRenderCharacters() {
const designCount = prod.querySelector('.lib-chars-bulk-count-design'); const designCount = prod.querySelector('.lib-chars-bulk-count-design');
const imageBtn = prod.querySelector('.lib-chars-bulk-image-btn'); const imageBtn = prod.querySelector('.lib-chars-bulk-image-btn');
const imageCount = prod.querySelector('.lib-chars-bulk-count-image'); 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'); const imageProviderSel = prod.querySelector('.lib-chars-image-provider');
if (imageProviderSel) imageProviderSel.value = (typeof _appSettings !== 'undefined' && _appSettings.image_gen_provider) || ''; if (imageProviderSel) imageProviderSel.value = (typeof _appSettings !== 'undefined' && _appSettings.image_gen_provider) || '';
const deleteBtn = prod.querySelector('.lib-chars-bulk-delete-btn'); const deleteBtn = prod.querySelector('.lib-chars-bulk-delete-btn');
@ -297,6 +312,8 @@ async function libraryRenderCharacters() {
designBtn.disabled = n === 0; designBtn.disabled = n === 0;
imageCount.textContent = n; imageCount.textContent = n;
imageBtn.disabled = n === 0; imageBtn.disabled = n === 0;
conceptCount.textContent = n;
conceptBtn.disabled = n === 0;
deleteCount.textContent = n; deleteCount.textContent = n;
deleteBtn.disabled = n === 0; deleteBtn.disabled = n === 0;
syncTblSelectAllCb(); syncTblSelectAllCb();
@ -380,6 +397,12 @@ async function libraryRenderCharacters() {
const provider = imageProviderSel ? imageProviderSel.value : ''; const provider = imageProviderSel ? imageProviderSel.value : '';
runBulk(imageBtn, ids, 'Generating images', function (rec) { return _charAutoGenerateImage(rec, provider); }); 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'); const fixLangBtn = prod.querySelector('.lib-chars-fix-lang-btn');
fixLangBtn?.addEventListener('click', function () { fixLangBtn?.addEventListener('click', function () {
// No selection needed — scans every character in this production for // 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) { prod.querySelectorAll('.lib-chars-tbl-voice-note').forEach(function (input) {
let timer = null; let timer = null;
input.addEventListener('click', function (e) { e.stopPropagation(); }); 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 () { input.addEventListener('input', function () {
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(async function () { timer = setTimeout(async function () {
@ -848,6 +876,24 @@ function _charCardHtml(rec, allChars) {
+ '</div></div>'; + '</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 — // 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. // 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 // 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 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 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 voicePrompt = _libStr(voiceMeta?.voice_design_prompt || sh.voice_design_prompt);
const stars = voiceId const stars = voiceId
? '<div class="lib-chars-tbl-rating" role="group" aria-label="Voice rating">' + [1, 2, 3, 4, 5].map(function (n) { ? '<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 archetype = _libStr(sh.archetype);
const titleLabel = _libStr(sh.title); const titleLabel = _libStr(sh.title);
const tableMeta = [aliasLabel ? 'aka ' + aliasLabel : '', occupation ? 'Occupation: ' + occupation : '', titleLabel ? 'Title: ' + titleLabel : '', archetype ? 'Archetype: ' + archetype : ''].filter(Boolean).join(' · '); 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) + '">' 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-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>' + '<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-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-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()">' + '<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>'
+ '<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 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>' + '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
+ '</tr>'; + '</tr>';
}).join(''); }).join('');
@ -932,13 +986,13 @@ 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: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>' + '<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('rating', 'Rating', 'Rate the assigned voice') + 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 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>'; + '</tr></thead><tbody>' + rows + '</tbody></table></div>';
} }
@ -2794,11 +2848,9 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
group: rec.book || undefined, group: rec.book || undefined,
tag: rec.book || undefined, tag: rec.book || undefined,
transcript: sampleText, transcript: sampleText,
note: 'Voice Design: ' + instruct.slice(0, 240), // For a designed voice the instruct IS the voice's identity. The
// For a designed voice the instruct IS the voice's identity — the TTS // dedicated Voice Design Prompt column shows it in full; leave note
// engine reproduces it from this text alone. `note` is a short display // untouched for the user's own review comments.
// summary and is deliberately clipped, so it cannot be the source of
// truth; store the prompt in full here.
voice_design_prompt: instruct, voice_design_prompt: instruct,
}).catch(function () {}); }).catch(function () {});
} }
@ -2833,7 +2885,7 @@ async function _charAutoGenerateImage(rec, provider) {
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}; const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : ''); 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'); 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; if (provider) body.provider = provider;
const r = await fetch('/api/character-generate-image', { const r = await fetch('/api/character-generate-image', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@ -2865,7 +2917,7 @@ async function _charAutoGenerateConceptArt(rec, provider) {
const sh = rec.sheet || {}; const sh = rec.sheet || {};
const prompt = _libStr(sh.concept_art_prompt).trim(); 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'); 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; if (provider) body.provider = provider;
const r = await fetch('/api/character-generate-image', { const r = await fetch('/api/character-generate-image', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, 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; } if (!rehState.cast[sp]) { done++; continue; }
const info = byName[sp.toUpperCase().trim()] || {}; const info = byName[sp.toUpperCase().trim()] || {};
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N'; const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
const desc = info.description || const hardGender = gender === 'M' ? 'male' : gender === 'F' ? 'female' : 'gender-neutral';
`A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`; 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 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'; 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') { if (typeof saveMeta === 'function') {
await saveMeta(saved.voice_id, { await saveMeta(saved.voice_id, {
gender, gender,
age: info.age || '',
name: charName, name: charName,
avatar: _pickVoiceAvatar(gender, desc, sp), avatar: _pickVoiceAvatar(gender, desc, sp),
origin: 'designed', 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; } if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }
const candidates = lib.map(v => ({ 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), tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
})); }));
const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp); 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); _voices = _voices.filter(x => x.id !== v.id);
wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0'; 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); 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) { } catch(e) {
toast('Delete failed: ' + e.message, 'error'); toast('Delete failed: ' + e.message, 'error');
deleteGoBtn.disabled = false; deleteGoBtn.disabled = false;
@ -3351,7 +3351,7 @@ function renderVoiceGroupsBar() {
names.map(g => names.map(g =>
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}"> `<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
${escHtml(g)} <span class="vg-count">${groups[g]}</span> ${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>` </span>`
).join('') + ).join('') +
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : ''); (active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
@ -3377,7 +3377,7 @@ function renderVoiceGroupsBar() {
async function deleteVoiceGroup(group) { async function deleteVoiceGroup(group) {
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length; 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 { try {
const r = await fetch('/api/voices/delete-group', { const r = await fetch('/api/voices/delete-group', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, 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); } if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json(); const d = await r.json();
if (window._voiceGroupFilter === group) window._voiceGroupFilter = ''; 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(); await loadVoiceLibrary();
} catch(e) { toast('Delete failed: ' + e.message, 'error'); } } catch(e) { toast('Delete failed: ' + e.message, 'error'); }
} }
@ -3644,13 +3644,13 @@ function _showBulkDeleteConfirm(ids) {
const ov = document.createElement('div'); const ov = document.createElement('div');
ov.className = 'vl-bdc-overlay'; ov.className = 'vl-bdc-overlay';
ov.innerHTML = ` ov.innerHTML = `
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices"> <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-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div> <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 permanently removes the selected voice${plural} from your library. This cannot be undone.</p> <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-list">${names}${more}</div>
<div class="vl-bdc-actions"> <div class="vl-bdc-actions">
<button type="button" class="vl-bdc-cancel">Cancel</button> <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>
</div>`; </div>`;
@ -3670,9 +3670,9 @@ function _showBulkDeleteConfirm(ids) {
const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' }); const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (r.ok) done++; else errors++; if (r.ok) done++; else errors++;
} catch (_) { 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(); 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(); _bulkSelected.clear();
await loadVoiceLibrary(); await loadVoiceLibrary();
}); });

View File

@ -677,7 +677,7 @@
<div class="card" id="comfyui-config-panel" hidden style="margin-top:16px"> <div class="card" id="comfyui-config-panel" hidden style="margin-top:16px">
<div class="s-page-head"> <div class="s-page-head">
<h3><span class="mdi mdi-graph-outline"></span> Local ComfyUI Workflow</h3> <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>
<div style="display:flex; flex-direction:column; gap:14px"> <div style="display:flex; flex-direction:column; gap:14px">
<div class="s-field"> <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> <span class="s-hint">Base URL of your running ComfyUI instance (container port 8188 by default).</span>
</div> </div>
<div class="s-field"> <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> <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>
<div style="display:flex; gap:14px; flex-wrap:wrap"> <div style="display:flex; gap:14px; flex-wrap:wrap">
<div class="s-field" style="flex:1; min-width:160px"> <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); } .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 */ /* 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 { 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 {
@ -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. */ 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-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 { display:inline-flex; gap:1px; }
.lib-chars-tbl-rating-star { .lib-chars-tbl-rating-star {
border:0; background:transparent; padding:1px; color:#d1d5db; cursor:pointer; line-height:1; 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-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-note-cell { padding:5px 8px !important; }
.lib-chars-tbl-voice-note { .lib-chars-tbl-voice-note {
width:100%; min-width:0; border:1px solid transparent; background:transparent; color:var(--text); display:block; width:100%; min-width:0; min-height:42px; resize:vertical;
padding:4px 5px; border-radius:5px; font:inherit; 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: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-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; }

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]}
}
}