Release v1.20.37
This commit is contained in:
parent
62274a17fc
commit
189ca51e47
15
CHANGELOG.md
15
CHANGELOG.md
@ -5,6 +5,21 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.20.37] — 2026-08-19
|
||||
|
||||
### Added
|
||||
- **Character profile completeness is visible in the cast table.** The table now shows how many of the 32 profile fields are filled, with visual sparse/partial/complete states and sorting by completeness.
|
||||
- **ComfyUI character artwork workflows are available locally.** Profile portraits and 3:2 character concept sheets can be generated through the configured ComfyUI provider, including bulk generation from the cast table.
|
||||
|
||||
### Changed
|
||||
- **Voice-design prompts now enforce audible identity constraints.** Generated prompts preserve the book language and native accent, respect gender and apparent age, and require more distinct vocal fingerprints between characters.
|
||||
- **Voice ratings remain review feedback rather than casting filters.** One- to three-star voices stay eligible while their prompts and profiles are improved.
|
||||
|
||||
### Fixed
|
||||
- **Assigned voice ratings could appear to vanish in Characters / Cast.** The table now waits for the shared voice library metadata before rendering ratings.
|
||||
- **Character-table columns could shift out of alignment.** The Book / Script row cell now matches its header, keeping the profile-completion and export columns in the correct positions.
|
||||
- **WhisperX Blackwell status naming and health checks were inconsistent.** The engine and status bar now identify the service consistently, and health checks no longer send unnecessary transcription requests.
|
||||
|
||||
## [1.20.36] — 2026-08-15
|
||||
|
||||
### Added
|
||||
|
||||
@ -31,6 +31,7 @@ COPY CHANGELOG.md .
|
||||
COPY VERSION .
|
||||
COPY core/ core/
|
||||
COPY routes/ routes/
|
||||
COPY workflows/ workflows/
|
||||
COPY static/ static/
|
||||
|
||||
# ── Vendor Tesseract.js (client-side OCR for image-based PDF chapter headings) ─
|
||||
|
||||
@ -67,17 +67,27 @@ def _is_sound_asset_file(path: Path) -> bool:
|
||||
|
||||
_AUDIO_EXTS_SET = frozenset(_AUDIO_EXTS)
|
||||
|
||||
|
||||
def _is_trashed_voice_file(path: Path) -> bool:
|
||||
"""Keep recoverable voice packages out of normal library discovery."""
|
||||
return any(part.lower() == "trash_voices" for part in path.parts)
|
||||
|
||||
def _voice_audio_files(root: Path):
|
||||
# Single rglob pass instead of one per extension (6× faster directory scan)
|
||||
for p in root.rglob("*"):
|
||||
if p.suffix.lower() in _AUDIO_EXTS_SET and not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
|
||||
if (
|
||||
p.suffix.lower() in _AUDIO_EXTS_SET
|
||||
and not _is_internal_voice_file(p)
|
||||
and not _is_sound_asset_file(p)
|
||||
and not _is_trashed_voice_file(p)
|
||||
):
|
||||
yield p
|
||||
|
||||
|
||||
def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None:
|
||||
for ext in _AUDIO_EXTS:
|
||||
for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")):
|
||||
if not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
|
||||
if not _is_internal_voice_file(p) and not _is_sound_asset_file(p) and not _is_trashed_voice_file(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
@ -322,7 +332,11 @@ def _voice_health(
|
||||
def _voice_entry(p: Path) -> dict:
|
||||
meta = _load_meta(p)
|
||||
parts = p.stem.split("_", 2)
|
||||
lang = parts[0].upper() if parts else ""
|
||||
# A saved user choice always wins over the filename. Provider-prefixed
|
||||
# imports such as EL_EN_F_* use EL as an import/source marker, not a
|
||||
# language, so deriving the display language solely from the ID made a
|
||||
# manually selected DE revert after every refresh.
|
||||
lang = str(meta.get("lang") or (parts[0] if parts else "")).strip().upper()
|
||||
try:
|
||||
duration = round(_duration(p), 2)
|
||||
except Exception:
|
||||
@ -401,7 +415,31 @@ def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str
|
||||
from core.tts_helpers import _tts_benchmark_request
|
||||
has_ref, _transcript = _read_reference_text(audio)
|
||||
meta = _load_meta(audio)
|
||||
is_designed = meta.get("origin") == "designed" or not has_ref
|
||||
# An imported WAV without a transcript is still a clone voice. Treating
|
||||
# every such file as a prompt-designed voice sent it to the unrelated
|
||||
# Voice Design service (8021), producing a misleading 404. Qwen clone
|
||||
# synthesis requires the exact reference transcript, so surface that
|
||||
# prerequisite directly instead.
|
||||
if not has_ref:
|
||||
error = "Reference transcript required before this saved WAV voice can be benchmarked. Add or generate its exact transcript first."
|
||||
return {
|
||||
"ok": False,
|
||||
"realtime_ok": False,
|
||||
"elapsed_sec": None,
|
||||
"audio_sec": None,
|
||||
"rtf": None,
|
||||
"speed": None,
|
||||
"ttfa_ms": None,
|
||||
"bytes": 0,
|
||||
"clipped": False,
|
||||
"benchmarked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"text": sentences[0][1] if len(sentences) == 1 else "short / medium / long",
|
||||
"runs": [{"ok": False, "label": label, "text": text, "error": error} for label, text in sentences],
|
||||
"summary": {},
|
||||
"advice": ["Generate an exact reference transcript with STT, then retry the benchmark."],
|
||||
"error": error,
|
||||
}
|
||||
is_designed = meta.get("origin") == "designed"
|
||||
runs: list[dict] = []
|
||||
for label, text in sentences:
|
||||
try:
|
||||
|
||||
@ -24,6 +24,7 @@ services:
|
||||
- ./server.py:/app/server.py:ro
|
||||
- ./core:/app/core:ro
|
||||
- ./routes:/app/routes:ro
|
||||
- ./workflows:/app/workflows:ro
|
||||
- ./VERSION:/app/VERSION:ro
|
||||
- ./CHANGELOG.md:/app/CHANGELOG.md:ro
|
||||
|
||||
|
||||
@ -57,7 +57,11 @@ async def characters_image(char_id: str):
|
||||
raw = base64.b64decode(m.group(2))
|
||||
except Exception:
|
||||
raise HTTPException(404, "Invalid image data")
|
||||
return Response(content=raw, media_type=m.group(1))
|
||||
return Response(
|
||||
content=raw,
|
||||
media_type=m.group(1),
|
||||
headers={"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache", "Expires": "0"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/characters/{char_id:path}")
|
||||
|
||||
@ -407,7 +407,14 @@ async def analyze_characters(request: Request):
|
||||
"- description: 2–3 sentences covering pitch (high/mid/low), pace, "
|
||||
"timbre, accent/dialect, emotional default, and any distinctive speech trait "
|
||||
"that fits the character's personality and role.\n"
|
||||
"Characters with few lines: infer from their role name and context.\n"
|
||||
"Treat gender and age as hard casting constraints, not suggestions. Do not "
|
||||
"default every character to an adult neutral American voice. Preserve the "
|
||||
"script's language and specify a native accent/dialect when supported by "
|
||||
"the text or setting; do not invent a US accent. Give every character a "
|
||||
"different audible fingerprint (pitch register, rhythm, resonance, texture, "
|
||||
"or articulation) from the other characters in this response.\n"
|
||||
"Characters with few lines: infer cautiously from their role name and context, "
|
||||
"and mark uncertain age/gender as N/unknown rather than guessing confidently.\n"
|
||||
"Respond with STRICT JSON only — no markdown, no explanation:\n"
|
||||
'{"characters":[{"name":"NAME","gender":"M|F|N","language":"LANG",'
|
||||
'"age":"30s","description":"voice description"}]}\n'
|
||||
@ -493,7 +500,7 @@ async def match_characters_voices(request: Request):
|
||||
script = script[:5000]
|
||||
|
||||
def _vline(v: dict) -> str:
|
||||
meta = [str(v.get(k)) for k in ("gender", "language") if v.get(k)]
|
||||
meta = [str(v.get(k)) for k in ("gender", "age", "language") if v.get(k)]
|
||||
if v.get("tags"):
|
||||
meta.append("tags:" + str(v["tags"]))
|
||||
desc = str(v.get("description") or v.get("name") or "")[:120]
|
||||
@ -509,7 +516,9 @@ async def match_characters_voices(request: Request):
|
||||
"RULE 1 — GENDER FIRST: the voice's gender MUST match the character's gender whenever the "
|
||||
"character's gender is clear from the script; only pick a different gender if no same-gender "
|
||||
"voice exists in the catalogue. "
|
||||
"RULE 2 — then match apparent age, language, personality, and the voice's description/tags. "
|
||||
"RULE 2 — then match apparent age, language/accent, personality, and the voice's description/tags. "
|
||||
"RULE 3 — maximize cast diversity: do not reuse a voice when another suitable candidate exists; "
|
||||
"avoid voices with the same gender, age, accent, and timbre as an already assigned character. "
|
||||
"You MUST choose a voice_id that appears verbatim in the catalogue — never invent one. "
|
||||
"Reuse a voice for two characters only if no better distinct option exists. "
|
||||
"Respond with STRICT JSON only, no markdown:\n"
|
||||
@ -564,7 +573,19 @@ async def match_characters_voices(request: Request):
|
||||
except Exception:
|
||||
continue
|
||||
# Keep only assignments that reference a real catalogue voice
|
||||
clean = [a for a in assignments if isinstance(a, dict) and str(a.get("voice_id")) in valid_ids]
|
||||
clean = []
|
||||
used_ids = set()
|
||||
for a in assignments:
|
||||
if not isinstance(a, dict) or str(a.get("voice_id")) not in valid_ids:
|
||||
continue
|
||||
# A voice may be reused only as a last resort. Most books have enough
|
||||
# suitable voices, and reusing the same one is the main source of the
|
||||
# user's "all 3-star voices sound alike" complaint.
|
||||
vid = str(a.get("voice_id"))
|
||||
if vid in used_ids and len(used_ids) < len(valid_ids):
|
||||
continue
|
||||
used_ids.add(vid)
|
||||
clean.append(a)
|
||||
return {"assignments": clean}
|
||||
|
||||
|
||||
@ -1317,28 +1338,19 @@ async def character_generate_prompts(request: Request):
|
||||
preamble
|
||||
+ visual_identity_rule
|
||||
+ "Produce exactly this field:\n"
|
||||
"- image_prompt: a detailed English image-generation prompt for a full CHARACTER REFERENCE SHEET "
|
||||
"(a single composite image, like a game/animation production turnaround), not just one portrait. "
|
||||
"FIRST, from the book title and profile, work out the story's genre, setting, and era (e.g. "
|
||||
"'medieval European-inspired high fantasy', 'grimdark low fantasy', 'space opera sci-fi', "
|
||||
"'contemporary urban fantasy') — an image model has no idea what a book title implies and will "
|
||||
"default to generic modern/real-world imagery unless told explicitly, which is exactly wrong for "
|
||||
"an occupation like 'Admiral' or 'General' in a fantasy world (it will draw a 20th-century military "
|
||||
"uniform instead of that world's actual equivalent). State that genre/setting/era explicitly, as "
|
||||
"its own descriptor near the START of the prompt, and make clear the world has no real-world 20th "
|
||||
"or 21st century technology, uniforms, or clothing unless the story is actually confirmed "
|
||||
"contemporary/near-future — every other visual choice (armor, dress, rank insignia, weapons) must "
|
||||
"fit THAT world, not the real one. Then ask the image model for: (1) a full-body front-view "
|
||||
"illustration as the anchor, (2) a turnaround panel with side and back views, (3) a small "
|
||||
"expression sheet with 3-5 headshots showing this character's typical emotional range "
|
||||
"(calm/determined/etc. — pick expressions that fit their personality), (4) a color palette swatch "
|
||||
"panel for hair/eyes/outfit, (5) callouts for their signature props, tools, or clothing details "
|
||||
"with short labels. Include the explicit gender and every established visual trait: age impression, species, "
|
||||
"face shape, build, hair, eyes, skin, facial hair, distinguishing marks, clothing, armor, colors, "
|
||||
"jewelry, weapons, tools, and signature props when present. "
|
||||
"appropriate to the established setting. Specify a clean production-design/concept-art layout "
|
||||
"with a plain neutral background, and explicitly note this is an ORIGINAL character, not based on "
|
||||
"any copyrighted character. One dense paragraph, comma-separated descriptors are fine.\n\n"
|
||||
"- image_prompt: an English image-generation prompt for a photorealistic audiobook character portrait, "
|
||||
"written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
|
||||
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
|
||||
"(not a list). You MUST use the following STRICT stylistic constraints for the clauses, but replace the Subject facts with the current character profile:\n"
|
||||
" Task: Generate a Character portrait.\n"
|
||||
" Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
|
||||
"content. Include every relevant visual fact from the profile.\n"
|
||||
" Context: a believable live-action character in the book's established original world and era.\n"
|
||||
" Style: photorealistic cinematic character photography, as a premium live-action fantasy-film portrait; natural skin texture and pores, anatomically plausible face and hands, real cloth, leather and metal materials, and no illustration, painting, anime, game render, or plastic skin.\n"
|
||||
" Composition: a simple square headshot/bust on a subdued neutral studio background, facing forward with a subtle in-character expression.\n"
|
||||
" Lighting: flattering neutral photographic key light with soft rim separation, realistic shadow falloff, and no stylized color cast.\n"
|
||||
" Constraints: preserve the exact gender, age impression, species traits, face, hairstyle, garment layers and signature details established in the profile; no existing game references, logos, readable labels, floating fragments, or watermark.\n"
|
||||
" Output: one production-ready square live-action character portrait.\n\n"
|
||||
'Respond with STRICT JSON only: {"image_prompt":""}/no-think'
|
||||
),
|
||||
"silly_tavern_prompt": preamble + _silly_tavern_prompt_instruction(sheet),
|
||||
@ -1347,32 +1359,17 @@ async def character_generate_prompts(request: Request):
|
||||
+ visual_identity_rule
|
||||
+ "Produce exactly this field:\n"
|
||||
"- concept_art_prompt: an English image-generation prompt for a production-ready character/NPC "
|
||||
"reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
|
||||
"reference sheet, written as labelled clauses in this EXACT order — Subject, Context, Style, "
|
||||
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
|
||||
"(not a list). Use the following two reference layouts as the structural model, but replace EVERY "
|
||||
"subject fact with the current character profile:\n"
|
||||
" Task: choose the best sheet purpose for the profile — a three-view character turnaround with "
|
||||
"accessory callouts, or a character props/accessories callout sheet.\n"
|
||||
" Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
|
||||
"content. Include every relevant visual fact from the profile: age, species, face, skin, hair, eyes, "
|
||||
"facial hair, marks, build, clothing layers, colors, armor, jewelry, weapons, tools, and signature "
|
||||
"accessories. For each profile-established prop/accessory, state its exact count; omit anything not in "
|
||||
"the profile. Never copy the herbalist/cartographer examples.\n"
|
||||
" Context: state the book's profile-derived genre, setting, era, and the production purpose of the sheet.\n"
|
||||
" Style: retain the reference style principles — detailed hand-painted or painterly game concept art, "
|
||||
"grounded leather/metal/cloth/wood materials, clear readable shape language, and coherent wear — but "
|
||||
"choose the visual medium and palette treatment appropriate to this book's profile.\n"
|
||||
" Composition: choose either (A) aligned full-body front, side, and back views across the top with a "
|
||||
"dialogue/action pose and one clean accessory callout row below, or (B) one full-body three-quarter "
|
||||
"hero figure at left with exactly one isolated callout for each profile-established accessory at right. "
|
||||
"Use clean leader lines, generous spacing, safe margins, and keep every figure and accessory fully visible.\n"
|
||||
" Lighting: use warm-neutral or neutral studio light that reveals material texture, with soft contact "
|
||||
"shadows and no cinematic color cast unless the profile or book context explicitly requires one.\n"
|
||||
" Constraints: same exact gender, face identity anchor, body proportions, silhouette, garment seams, "
|
||||
"colors, wear, accessory scale, attachment points, and prop count in every view; each accessory appears "
|
||||
"once and matches the carried version; no duplicate gear, disassembled fragments, floating objects, extra "
|
||||
"limbs, readable text, logos, real-world emblems, franchise references, or watermark.\n"
|
||||
" Output: one organized, production-ready 3:2 character reference sheet.\n\n"
|
||||
"(not a list). You MUST use the following STRICT stylistic constraints for the clauses, but replace the Subject and Prop facts with the current character profile:\n"
|
||||
" Subject: an original adult [EXACT GENDER] [role/archetype] using ONLY the profile's established "
|
||||
"content. Include every relevant visual fact from the profile. For each prop/accessory, state its exact count.\n"
|
||||
" Context: a costume and prop handoff reference for the book's established original world and era.\n"
|
||||
" Style: refined traditional pencil-and-ink concept sketch with restrained watercolor washes, clear silhouette and costume construction, grounded materials, visible linework, and believable proportions; intentionally illustrative, not photorealistic and not cartoonish.\n"
|
||||
" Composition: full-body front, side, and back views across the top; below them, one dialogue pose and a single clean callout row containing exactly [list exactly the props/accessories from the profile] on a muted parchment landscape board.\n"
|
||||
" Lighting: neutral studio reference lighting with gentle shading that keeps every construction detail readable.\n"
|
||||
" Constraints: consistent face, silhouette, garment layers, [prop specifics], and palette in every view; no duplicate gear, no existing game references, logos, readable labels, floating fragments, extra limbs, or watermark.\n"
|
||||
" Output: one production-ready 3:2 NPC reference sheet.\n\n"
|
||||
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
|
||||
),
|
||||
}
|
||||
@ -1547,6 +1544,47 @@ async def audiobook_consistency_check(request: Request):
|
||||
return {"outliers": clean}
|
||||
|
||||
|
||||
def _bundled_comfyui_workflow(image_kind: str) -> tuple[str, str, str, str]:
|
||||
"""Return the validated local Flux workflows shipped with Voice Creator.
|
||||
|
||||
A custom API-format graph saved in Settings still takes precedence. The
|
||||
bundled workflows mean that selecting Local ComfyUI works immediately on
|
||||
the supported local installation instead of leaving every bulk run to
|
||||
fail because the workflow textarea was empty.
|
||||
"""
|
||||
name = "comfyui-character-concept-sheet.json" if image_kind == "concept_art" else "comfyui-character-portrait.json"
|
||||
path = Path(__file__).resolve().parent.parent / "workflows" / name
|
||||
try:
|
||||
return path.read_text(encoding="utf-8"), "2", "text", "7"
|
||||
except OSError as exc:
|
||||
raise HTTPException(500, f"Bundled ComfyUI workflow is unavailable: {exc}")
|
||||
|
||||
|
||||
def _apply_character_image_rendering_style(prompt: str, image_kind: str) -> str:
|
||||
"""Keep asset style deterministic, including for prompts saved before v1.20.36.
|
||||
|
||||
A profile and a concept sheet serve different purposes. The LLM provides
|
||||
character-specific content, while this final rendering directive makes the
|
||||
visual contract explicit to ComfyUI and prevents an old "painterly" prompt
|
||||
from silently turning every newly regenerated portrait into game art.
|
||||
"""
|
||||
if image_kind == "concept_art":
|
||||
return (
|
||||
f"{prompt}\n\n"
|
||||
"FINAL RENDERING DIRECTIVE: make this a refined hand-drawn production design sheet: "
|
||||
"pencil and ink linework, restrained watercolor washes, readable costume construction and props, "
|
||||
"subtle paper texture. It must be illustrative but not cartoonish; do not render it as a photograph, "
|
||||
"3D game art, anime, glossy digital painting, or a collage."
|
||||
)
|
||||
return (
|
||||
f"{prompt}\n\n"
|
||||
"FINAL RENDERING DIRECTIVE: render this as a photorealistic live-action cinematic casting portrait, "
|
||||
"with natural human skin texture and pores, realistic hair, physically plausible anatomy, authentic "
|
||||
"fabric/leather/metal, and photographic lens lighting. Do not make an illustration, painting, anime, "
|
||||
"cartoon, game render, CGI character, or plastic-skinned doll."
|
||||
)
|
||||
|
||||
|
||||
def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
|
||||
prompt_field: str, output_node_id: str, prompt: str) -> str:
|
||||
"""Submits a pre-exported ComfyUI API-format workflow with the given
|
||||
@ -1560,8 +1598,6 @@ def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
|
||||
"""
|
||||
if not comfyui_url:
|
||||
raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)")
|
||||
if not workflow_json:
|
||||
raise HTTPException(400, "No ComfyUI workflow configured — paste an API-format workflow export in Settings > Engines > Image Generation")
|
||||
if not prompt_node_id or not output_node_id:
|
||||
raise HTTPException(400, "ComfyUI prompt/output node IDs not set (Settings > Engines > Image Generation)")
|
||||
try:
|
||||
@ -1702,10 +1738,12 @@ async def character_generate_image(request: Request):
|
||||
# as long as one image generation took, which for a bulk "auto-generate
|
||||
# images" run across a whole cast could be tens of minutes of site-wide
|
||||
# freeze with no error, just silence. Runs in a worker thread instead.
|
||||
return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings)
|
||||
image_kind = "concept_art" if str(data.get("image_kind") or "").strip().lower() in {"concept", "concept_art", "sheet", "character_sheet"} else "portrait"
|
||||
prompt = _apply_character_image_rendering_style(prompt, image_kind)
|
||||
return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind)
|
||||
|
||||
|
||||
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict) -> dict:
|
||||
def _character_generate_image_sync(provider: str, model: str, prompt: str, keys: dict, _settings: dict, image_kind: str = "portrait") -> dict:
|
||||
if provider == "openai":
|
||||
api_key = (keys.get("openai_image") or "").strip()
|
||||
if not api_key:
|
||||
@ -1735,25 +1773,40 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
|
||||
api_key = (keys.get("google_image") or "").strip()
|
||||
if not api_key:
|
||||
raise HTTPException(400, "Google API key not set (Settings > Engines > Image Generation)")
|
||||
gmodel = model or "gemini-2.5-flash-image"
|
||||
# Google retired the preview alias; keep older saved settings usable
|
||||
# instead of turning a previously working Google selection into a 404.
|
||||
if gmodel == "gemini-2.5-flash-image-preview":
|
||||
gmodel = "gemini-2.5-flash-image"
|
||||
gmodel = model or "gemini-3.1-flash-image"
|
||||
# Google retired the preview alias and moved image models to Interactions API.
|
||||
if gmodel in ["gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"]:
|
||||
gmodel = "gemini-3.1-flash-image"
|
||||
|
||||
try:
|
||||
api_url = "https://generativelanguage.googleapis.com/v1beta/interactions"
|
||||
payload = {
|
||||
"model": gmodel,
|
||||
"input": [{"type": "user_input", "content": [{"type": "text", "text": prompt}]}],
|
||||
"response_modalities": ["image"]
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"https://generativelanguage.googleapis.com/v1/models/{gmodel}:generateContent",
|
||||
api_url,
|
||||
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
|
||||
# Gemini image models do not return image bytes unless image
|
||||
# output is explicitly requested. The old payload only sent
|
||||
# the prompt, so Google successfully returned text (or a
|
||||
# safety response) and the client reported "no image data".
|
||||
json={
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"responseModalities": ["IMAGE"]},
|
||||
},
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
# Fallback to the older generateContent endpoint if the model is not supported by Interactions API
|
||||
if resp.status_code == 404:
|
||||
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gmodel}:generateContent"
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"responseModalities": ["IMAGE"]},
|
||||
}
|
||||
resp = requests.post(
|
||||
api_url,
|
||||
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
detail = resp.text[:400]
|
||||
try:
|
||||
@ -1761,15 +1814,29 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(resp.status_code, f"Google image generation failed: {detail}")
|
||||
payload = resp.json()
|
||||
parts = (payload.get("candidates") or [{}])[0].get("content", {}).get("parts", [])
|
||||
inline = next((p.get("inlineData") or p.get("inline_data") for p in parts if p.get("inlineData") or p.get("inline_data")), None)
|
||||
|
||||
payload_json = resp.json()
|
||||
|
||||
def _find_inline_data(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "inlineData" in obj: return obj["inlineData"]
|
||||
if "inline_data" in obj: return obj["inline_data"]
|
||||
for k, v in obj.items():
|
||||
res = _find_inline_data(v)
|
||||
if res: return res
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
res = _find_inline_data(item)
|
||||
if res: return res
|
||||
return None
|
||||
|
||||
inline = _find_inline_data(payload_json)
|
||||
|
||||
if not inline:
|
||||
# Preserve Google's useful finish/safety text in the error;
|
||||
# this makes quota, blocked prompts, and unavailable models
|
||||
# distinguishable from a client parsing bug.
|
||||
finish = (payload.get("candidates") or [{}])[0].get("finishReason") or "unknown response"
|
||||
raise HTTPException(502, f"Google returned no image data ({finish}) — check the selected image model, quota, or prompt")
|
||||
raise HTTPException(502, f"Google returned no image data — check the selected image model, quota, or prompt.")
|
||||
mime = inline.get("mimeType", "image/png")
|
||||
return {"image": f"data:{mime};base64,{inline['data']}"}
|
||||
except HTTPException:
|
||||
@ -1786,7 +1853,9 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
|
||||
api_key = (keys.get("openrouter") or "").strip()
|
||||
if not api_key:
|
||||
raise HTTPException(400, "OpenRouter API key not set (Settings > Engines > Language Models > OpenRouter)")
|
||||
omodel = model or "google/gemini-2.5-flash-image-preview:free"
|
||||
omodel = model or "google/gemini-3.1-flash-image"
|
||||
if omodel in ["google/gemini-2.5-flash-image-preview:free", "google/gemini-2.5-flash-image"]:
|
||||
omodel = "google/gemini-3.1-flash-image"
|
||||
try:
|
||||
resp = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
@ -1824,12 +1893,26 @@ def _character_generate_image_sync(provider: str, model: str, prompt: str, keys:
|
||||
raise HTTPException(502, f"OpenRouter image generation failed: {e}")
|
||||
|
||||
if provider == "comfyui":
|
||||
workflow_json = _settings.get("comfyui_workflow") or ""
|
||||
prompt_node_id = (_settings.get("comfyui_prompt_node_id") or "").strip()
|
||||
prompt_field = (_settings.get("comfyui_prompt_field") or "text").strip()
|
||||
output_node_id = (_settings.get("comfyui_output_node_id") or "").strip()
|
||||
# Advanced users may still paste their own API workflow. Otherwise
|
||||
# use the paired local Flux workflows validated against ComfyUI.
|
||||
# A legacy/custom workflow is normally a square portrait graph. Do
|
||||
# not stretch that graph into a character sheet: concept art needs the
|
||||
# dedicated 3:2 composition workflow to keep the turnaround layout
|
||||
# readable. Portraits still honour the user's advanced override.
|
||||
if image_kind == "concept_art":
|
||||
workflow_json = ""
|
||||
if not workflow_json:
|
||||
workflow_json, prompt_node_id, prompt_field, output_node_id = _bundled_comfyui_workflow(image_kind)
|
||||
image = _comfyui_run(
|
||||
(_settings.get("comfyui_url") or "").strip(),
|
||||
_settings.get("comfyui_workflow") or "",
|
||||
(_settings.get("comfyui_prompt_node_id") or "").strip(),
|
||||
(_settings.get("comfyui_prompt_field") or "text").strip(),
|
||||
(_settings.get("comfyui_output_node_id") or "").strip(),
|
||||
workflow_json,
|
||||
prompt_node_id,
|
||||
prompt_field,
|
||||
output_node_id,
|
||||
prompt,
|
||||
)
|
||||
return {"image": image}
|
||||
@ -1882,7 +1965,7 @@ def _attribution_prepare(data: dict) -> dict:
|
||||
raise HTTPException(400, "No text provided")
|
||||
|
||||
lang_hint = f" The text language is {language}; keep names and wording in that language." if language else ""
|
||||
|
||||
|
||||
prompt_override = data.get("audiobook_prompt")
|
||||
if not isinstance(prompt_override, str):
|
||||
prompt_override = data.get("prompt")
|
||||
@ -2828,7 +2911,7 @@ async def conversation_llm_models(url: str = "", api_key: str = ""):
|
||||
try:
|
||||
base = _validate_http_url(base, allow_private=True)
|
||||
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": f"Bearer {key_to_use}"})
|
||||
|
||||
|
||||
# Fallback for gateways like LiteLLM that might prefer /models over /v1/models
|
||||
if r.status_code != 200 and base.endswith("/v1"):
|
||||
fallback_base = base[:-3]
|
||||
|
||||
@ -39,7 +39,7 @@ _LOCAL_CONTAINER_DEFS: list[dict] = [
|
||||
{"name": "nvidia-zeroshot-nim", "label": "NVIDIA Zeroshot NIM", "role": "tts", "port": 8093, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Zeroshot clone endpoint with audio_prompt."},
|
||||
{"name": "nvidia-flow-nim", "label": "NVIDIA Flow NIM", "role": "tts", "port": 8094, "stack": "nvidia-speech-gateway", "image": "local", "repo": "", "description": "Magpie Flow clone endpoint with audio_prompt and transcript."},
|
||||
{"name": "voicebox", "label": "Voicebox", "role": "llm", "port": 17493, "stack": "voicebox", "image": "local", "repo": "", "description": "Companion voice app/control plane. Not used as a normal TTS engine unless wrapped as an API."},
|
||||
{"name": "whisperx-blackwell", "label": "WhisperX Blackwell", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service. Use as custom STT if its API is OpenAI-compatible or /transcribe."},
|
||||
{"name": "stt-whisperx-blackwell", "label": "WhisperX · faster-whisper (Blackwell GPU)", "role": "stt", "port": 8003, "stack": "whisperx-blackwell", "image": "local", "repo": "https://github.com/m-bain/whisperX", "description": "WhisperX transcription/alignment service using faster-whisper on the Blackwell GPU. Use as custom STT if its API is OpenAI-compatible or /transcribe."},
|
||||
{"name": "xtts-dgx-spark", "label": "XTTS v2", "role": "tts", "port": 8005, "stack": "xtts_v2", "image": "local", "repo": "https://github.com/daswer123/xtts-api-server", "description": "XTTS v2 OpenAI-compatible speaker endpoint."},
|
||||
]
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import asyncio
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
@ -39,6 +40,68 @@ from core.voice_index import (
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_voice_prepare_jobs: dict[str, dict] = {}
|
||||
|
||||
|
||||
async def _prepare_voice_reference(audio: Path, settings: dict) -> dict:
|
||||
"""Transcribe, publish, warm, and benchmark one saved WAV voice."""
|
||||
meta = _load_meta(audio)
|
||||
meta["import_preparation"] = "transcribing"
|
||||
meta.pop("import_preparation_error", None)
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
try:
|
||||
from routes.stt import _transcribe_audio
|
||||
transcript, backend = await asyncio.to_thread(_transcribe_audio, audio, settings, "configured")
|
||||
transcript = transcript.strip()
|
||||
if not transcript:
|
||||
raise RuntimeError("STT returned an empty transcript")
|
||||
audio.with_suffix(".reference.txt").write_text(transcript, encoding="utf-8")
|
||||
from routes.tts import sync_voice_clone_reference
|
||||
meta = _load_meta(audio)
|
||||
await asyncio.to_thread(sync_voice_clone_reference, audio, transcript, str(meta.get("lang") or ""), settings)
|
||||
meta = _load_meta(audio)
|
||||
meta["reference_transcript_status"] = "ready"
|
||||
meta["reference_transcript_backend"] = backend
|
||||
meta["import_preparation"] = "benchmarking"
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
except Exception as exc:
|
||||
meta = _load_meta(audio)
|
||||
meta["reference_transcript_status"] = "pending"
|
||||
meta["import_preparation"] = "pending_stt"
|
||||
meta["import_preparation_error"] = str(exc)[:500]
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
return {"ok": False, "stage": "transcription", "error": str(exc)}
|
||||
|
||||
benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, list(_BENCHMARK_SENTENCES))
|
||||
meta = _load_meta(audio)
|
||||
meta["benchmark"] = benchmark
|
||||
meta["import_preparation"] = "ready" if benchmark.get("ok") else "pending_tts"
|
||||
if benchmark.get("ok"):
|
||||
meta.pop("import_preparation_error", None)
|
||||
else:
|
||||
meta["import_preparation_error"] = str(benchmark.get("error") or "Benchmark failed")[:500]
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
return {"ok": bool(benchmark.get("ok")), "stage": "benchmark", "error": benchmark.get("error", "")}
|
||||
|
||||
|
||||
async def _run_prepare_missing_job(job_id: str, voices: list[Path], settings: dict) -> None:
|
||||
job = _voice_prepare_jobs[job_id]
|
||||
job["state"] = "running"
|
||||
for index, audio in enumerate(voices, start=1):
|
||||
job.update({"current": audio.stem, "completed": index - 1})
|
||||
result = await _prepare_voice_reference(audio, settings)
|
||||
job["completed"] = index
|
||||
if result.get("ok"):
|
||||
job["ready"] += 1
|
||||
else:
|
||||
job["failed"] += 1
|
||||
job["errors"].append({"voice_id": audio.stem, "stage": result.get("stage"), "detail": result.get("error", "")})
|
||||
job.update({"state": "complete", "current": "", "finished_at": datetime.now().isoformat()})
|
||||
|
||||
|
||||
# ── Book/production profile (genre, setting, era, language) ──────────────────
|
||||
# A one-time-per-book note the user fills in ("German, fantasy like Lord of
|
||||
@ -381,10 +444,11 @@ async def update_voice_meta(request: Request):
|
||||
meta = _load_meta(wav)
|
||||
meta["enabled"] = enabled
|
||||
|
||||
for field in ("note", "rating", "flag", "gender", "loudness", "persona", "origin", "group",
|
||||
"name", "tag", "avatar", "voice_design_prompt"):
|
||||
for field in ("note", "rating", "flag", "gender", "age", "lang", "language", "loudness", "persona", "origin", "group",
|
||||
"name", "tag", "source", "avatar", "voice_design_prompt"):
|
||||
if field in data:
|
||||
meta[field] = data[field]
|
||||
# Older clients sent `language`; keep metadata canonical as `lang`.
|
||||
meta["lang" if field == "language" else field] = data[field]
|
||||
if "transcript" in data:
|
||||
meta["needs_tts_restart"] = True
|
||||
_save_meta(wav, meta)
|
||||
@ -525,6 +589,44 @@ async def benchmark_voices(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/voices/prepare-missing-references")
|
||||
async def prepare_missing_references(request: Request):
|
||||
"""Queue STT transcript + Qwen warm-up/benchmark for active voices missing text."""
|
||||
data = await request.json()
|
||||
requested_ids = {str(v).strip() for v in (data.get("voice_ids") or []) if str(v).strip()}
|
||||
settings = _load_settings()
|
||||
active_dir = _active_voices_dir(settings)
|
||||
candidates = []
|
||||
for audio in sorted(_voice_audio_files(active_dir), key=lambda p: p.stem.lower()):
|
||||
if requested_ids and audio.stem not in requested_ids:
|
||||
continue
|
||||
has_ref, text = _read_reference_text(audio)
|
||||
if not has_ref or not text.strip():
|
||||
candidates.append(audio)
|
||||
job_id = uuid.uuid4().hex
|
||||
_voice_prepare_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"state": "queued",
|
||||
"total": len(candidates),
|
||||
"completed": 0,
|
||||
"ready": 0,
|
||||
"failed": 0,
|
||||
"current": "",
|
||||
"errors": [],
|
||||
"started_at": datetime.now().isoformat(),
|
||||
}
|
||||
asyncio.create_task(_run_prepare_missing_job(job_id, candidates, settings))
|
||||
return {"job_id": job_id, "queued": len(candidates)}
|
||||
|
||||
|
||||
@router.get("/api/voices/prepare-missing-references/{job_id}")
|
||||
async def get_prepare_missing_references_job(job_id: str):
|
||||
job = _voice_prepare_jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Preparation job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.post("/api/voices/normalize-active")
|
||||
async def normalize_active_voices():
|
||||
settings = _load_settings()
|
||||
@ -694,26 +796,35 @@ async def rename_voice(request: Request):
|
||||
|
||||
# ── Voice delete ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _voice_trash_batch_dir(scan_dir: Path, label: str) -> Path:
|
||||
"""Create a non-discoverable, timestamped recycle-bin batch for voice packages."""
|
||||
safe_label = re.sub(r"[^A-Za-z0-9_-]+", "-", label).strip("-") or "voices"
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return scan_dir / "trash_voices" / f"{stamp}-{safe_label}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def _trash_voice_package(audio: Path, batch_dir: Path) -> Path:
|
||||
"""Move a complete voice package together so it can be restored intact later."""
|
||||
return _move_voice_package(audio, batch_dir / audio.stem)
|
||||
|
||||
@router.post("/api/voices/delete-group")
|
||||
async def delete_voice_group(request: Request):
|
||||
"""Delete every voice whose meta `group` matches the given name. Used to bulk-remove
|
||||
a rehearser play's auto-designed voices."""
|
||||
"""Move every voice in a group to the recoverable voice trash."""
|
||||
data = await request.json()
|
||||
group = str(data.get("group") or "").strip()
|
||||
if not group:
|
||||
raise HTTPException(400, "No group specified")
|
||||
settings = _load_settings()
|
||||
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
|
||||
deleted_ids = []
|
||||
trashed_ids = []
|
||||
batch_dir = _voice_trash_batch_dir(scan_dir, f"group-{group}")
|
||||
for p in _voice_audio_files(scan_dir):
|
||||
meta = _load_meta(p)
|
||||
if str(meta.get("group") or "").strip() == group:
|
||||
for f in _voice_package_paths(p):
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
deleted_ids.append(p.stem)
|
||||
_trash_voice_package(p, batch_dir)
|
||||
trashed_ids.append(p.stem)
|
||||
remove_voice_from_index(p.stem)
|
||||
return {"deleted": deleted_ids, "count": len(deleted_ids)}
|
||||
return {"trashed": trashed_ids, "count": len(trashed_ids), "trash_batch": str(batch_dir)}
|
||||
|
||||
|
||||
@router.delete("/api/voice/{voice_id}")
|
||||
@ -725,13 +836,10 @@ async def delete_voice(voice_id: str):
|
||||
wav = _find_voice_audio(voice_id, scan_dir)
|
||||
if wav is None:
|
||||
raise HTTPException(404, f"Voice '{voice_id}' not found")
|
||||
deleted = []
|
||||
for f in _voice_package_paths(wav):
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
deleted.append(f.name)
|
||||
batch_dir = _voice_trash_batch_dir(scan_dir, f"voice-{voice_id}")
|
||||
moved = _trash_voice_package(wav, batch_dir)
|
||||
remove_voice_from_index(voice_id)
|
||||
return {"deleted": deleted}
|
||||
return {"trashed": [p.name for p in _voice_package_paths(moved)], "trash_batch": str(batch_dir)}
|
||||
|
||||
|
||||
# ── Voice picture upload / serve ──────────────────────────────────────────────
|
||||
|
||||
@ -16,18 +16,82 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from core.config import _load_settings
|
||||
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR
|
||||
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES
|
||||
from core.registry import _registry_put, TEMP_DIR
|
||||
from core.validation import _validate_http_url
|
||||
from core.audio import _to_wav_24k, _duration
|
||||
from core.voice import (
|
||||
_AUDIO_EXTS, _active_voices_dir,
|
||||
_remove_audio_variants, _export_normalized_wav, _load_meta, _save_meta,
|
||||
_benchmark_voice,
|
||||
)
|
||||
from core.voice_index import upsert_voice_in_index
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _prepare_imported_voice(audio: Path, settings: dict) -> None:
|
||||
"""Make a downloaded reference usable for Qwen clone playback.
|
||||
|
||||
Public catalogue previews usually arrive without a transcript. Qwen clone
|
||||
needs that exact text before it can build a speaker embedding, so imports
|
||||
transcribe first and only then warm/benchmark the voice. Any unavailable
|
||||
local service is captured in metadata rather than making the import fail.
|
||||
"""
|
||||
meta = _load_meta(audio)
|
||||
meta["import_preparation"] = "transcribing"
|
||||
meta.pop("import_preparation_error", None)
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
|
||||
try:
|
||||
from routes.stt import _transcribe_audio
|
||||
transcript, backend = await asyncio.to_thread(_transcribe_audio, audio, settings, "configured")
|
||||
transcript = transcript.strip()
|
||||
if not transcript:
|
||||
raise RuntimeError("STT returned an empty transcript")
|
||||
audio.with_suffix(".reference.txt").write_text(transcript, encoding="utf-8")
|
||||
from routes.tts import sync_voice_clone_reference
|
||||
meta = _load_meta(audio)
|
||||
await asyncio.to_thread(
|
||||
sync_voice_clone_reference, audio, transcript, str(meta.get("lang") or ""), settings
|
||||
)
|
||||
meta = _load_meta(audio)
|
||||
meta["reference_transcript_status"] = "ready"
|
||||
meta["reference_transcript_backend"] = backend
|
||||
meta["import_preparation"] = "benchmarking"
|
||||
meta.pop("import_preparation_error", None)
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
except Exception as exc:
|
||||
meta = _load_meta(audio)
|
||||
meta["reference_transcript_status"] = "pending"
|
||||
meta["import_preparation"] = "pending_stt"
|
||||
meta["import_preparation_error"] = str(exc)[:500]
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
return
|
||||
|
||||
try:
|
||||
# Benchmark synthesis also creates/warms Qwen's cached speaker
|
||||
# embedding, so no separate duplicate warm-up request is required.
|
||||
benchmark = await asyncio.to_thread(_benchmark_voice, audio, settings, list(_BENCHMARK_SENTENCES))
|
||||
meta = _load_meta(audio)
|
||||
meta["benchmark"] = benchmark
|
||||
meta["import_preparation"] = "ready" if benchmark.get("ok") else "pending_tts"
|
||||
if benchmark.get("ok"):
|
||||
meta.pop("import_preparation_error", None)
|
||||
else:
|
||||
meta["import_preparation_error"] = str(benchmark.get("error") or "Benchmark failed")[:500]
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
except Exception as exc:
|
||||
meta = _load_meta(audio)
|
||||
meta["import_preparation"] = "pending_tts"
|
||||
meta["import_preparation_error"] = str(exc)[:500]
|
||||
_save_meta(audio, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, audio)
|
||||
|
||||
# ── Voice source helpers ──────────────────────────────────────────────────────
|
||||
|
||||
_VOICE_SOURCE_HEADERS = {
|
||||
@ -506,6 +570,9 @@ async def quick_import_voice(request: Request):
|
||||
if not voice_id:
|
||||
raise HTTPException(400, "voice_id is required")
|
||||
transcript = str(data.get("transcript") or "").strip()
|
||||
language = str(data.get("lang") or data.get("language") or "").strip().upper()[:12]
|
||||
gender = str(data.get("gender") or "").strip().upper()[:1]
|
||||
auto_prepare = bool(data.get("auto_prepare", False))
|
||||
|
||||
suffix = Path(urlparse(audio_url).path).suffix.lower()
|
||||
if suffix not in _AUDIO_EXTS:
|
||||
@ -548,11 +615,28 @@ async def quick_import_voice(request: Request):
|
||||
if transcript:
|
||||
(out_dir / f"{final_id}.reference.txt").write_text(transcript, encoding="utf-8")
|
||||
meta = _load_meta(wav_dest)
|
||||
meta["enabled"] = True
|
||||
meta["loudness"] = loudness
|
||||
meta.update({
|
||||
"enabled": True,
|
||||
"loudness": loudness,
|
||||
"lang": language or str(meta.get("lang") or "").upper(),
|
||||
"gender": gender if gender in {"F", "M", "N"} else meta.get("gender", ""),
|
||||
})
|
||||
for field in ("name", "tag", "group", "origin", "note", "source"):
|
||||
if data.get(field) is not None:
|
||||
meta[field] = str(data.get(field) or "").strip()[:500]
|
||||
if auto_prepare and not transcript:
|
||||
meta["reference_transcript_status"] = "pending"
|
||||
meta["import_preparation"] = "queued"
|
||||
_save_meta(wav_dest, meta)
|
||||
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
|
||||
return {"voice_id": final_id, "loudness": loudness}
|
||||
if auto_prepare and not transcript:
|
||||
asyncio.create_task(_prepare_imported_voice(wav_dest, settings))
|
||||
return {
|
||||
"voice_id": final_id,
|
||||
"loudness": loudness,
|
||||
"language": language,
|
||||
"preparation": "queued" if auto_prepare and not transcript else "ready",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/download-yt")
|
||||
|
||||
@ -127,7 +127,13 @@ def _stt_backend_label(backend: str, url: str) -> str:
|
||||
"groq_whisper": "Groq Whisper (cloud · free)",
|
||||
}
|
||||
port = _backend_port_label(url)
|
||||
label = labels.get(backend, backend)
|
||||
# The WhisperX Blackwell service is configured through the generic
|
||||
# faster_whisper backend, so identify it by its published port in the
|
||||
# status bar instead of making the engine card and status disagree.
|
||||
if backend == "faster_whisper" and port in {"8003", "8081"}:
|
||||
label = "WhisperX · faster-whisper (Blackwell GPU)"
|
||||
else:
|
||||
label = labels.get(backend, backend)
|
||||
return f"{port} {label}" if port else label
|
||||
|
||||
|
||||
@ -152,15 +158,20 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
|
||||
|
||||
def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
|
||||
base = _validate_http_url(url, allow_private=True).rstrip("/")
|
||||
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
|
||||
# health at the service root, while its transcription route is under
|
||||
# `/v1/audio/...`; without splitting the prefix we probe `/v1/health` and
|
||||
# `/v1/v1/models`, incorrectly marking a healthy WhisperX service down.
|
||||
api_base = base[:-3].rstrip("/") if base.endswith("/v1") else base
|
||||
models: list[str] = []
|
||||
ok = False
|
||||
try:
|
||||
r = requests.get(f"{base}/health", timeout=2)
|
||||
r = requests.get(f"{api_base}/health", timeout=2)
|
||||
ok = r.status_code == 200
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = requests.get(f"{base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
|
||||
r = requests.get(f"{api_base}/v1/models", timeout=3, headers={"Authorization": "Bearer sk-dummy-key"})
|
||||
if r.status_code == 200:
|
||||
ok = True
|
||||
payload = r.json()
|
||||
@ -174,42 +185,12 @@ def _stt_backend_health(url: str) -> tuple[bool, list[str]]:
|
||||
pass
|
||||
if not ok:
|
||||
return False, models
|
||||
try:
|
||||
wav = _make_minimal_wav()
|
||||
endpoint_found = False
|
||||
timed_out = False
|
||||
for path in ("/v1/audio/transcriptions", "/transcribe"):
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{base}{path}",
|
||||
files={"file": ("probe.wav", wav, "audio/wav")},
|
||||
data={"model": "whisper-1", "response_format": "text"},
|
||||
timeout=8,
|
||||
)
|
||||
if r.status_code in {404, 405}:
|
||||
continue
|
||||
if r.status_code == 500:
|
||||
try:
|
||||
detail = r.json().get("detail", "")
|
||||
audio_quality_words = ("too short", "no speech", "audio", "empty",
|
||||
"duration", "length", "silence")
|
||||
ok = bool(detail) and any(w in detail.lower() for w in audio_quality_words)
|
||||
except Exception:
|
||||
ok = False
|
||||
else:
|
||||
ok = True
|
||||
endpoint_found = True
|
||||
break
|
||||
except requests.exceptions.ConnectionError:
|
||||
ok = False
|
||||
endpoint_found = True
|
||||
break
|
||||
except Exception:
|
||||
timed_out = True
|
||||
if not endpoint_found and not timed_out:
|
||||
ok = False
|
||||
except Exception:
|
||||
pass
|
||||
# Do not send a real audio transcription as a health probe. WhisperX's
|
||||
# batch server uses one worker and a probe consumes the GPU for several
|
||||
# seconds; while it runs, even /health blocks and the UI reports the
|
||||
# otherwise healthy service as unreachable. The service health endpoint is
|
||||
# the authoritative liveness check; the real transcription path is tested
|
||||
# when the user actually transcribes audio.
|
||||
return ok, models
|
||||
|
||||
|
||||
@ -236,12 +217,20 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
|
||||
stt_key = _stt_backend_api_key(settings, backend)
|
||||
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
|
||||
model = _stt_backend_model(backend)
|
||||
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"]
|
||||
# Accept both `http://host:port` and OpenAI-style `http://host:port/v1`.
|
||||
# WhisperX's native fallback remains rooted at `/transcribe`.
|
||||
if stt_url.endswith("/v1"):
|
||||
paths = ["/audio/transcriptions"] if not backend.startswith("custom:") else ["/audio/transcriptions", "/../transcribe"]
|
||||
else:
|
||||
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"]
|
||||
resp = None
|
||||
for path in paths:
|
||||
with src.open("rb") as f:
|
||||
endpoint = f"{stt_url}{path}"
|
||||
if path == "/../transcribe":
|
||||
endpoint = f"{stt_url[:-3].rstrip('/')}/transcribe"
|
||||
resp = requests.post(
|
||||
f"{stt_url}{path}",
|
||||
endpoint,
|
||||
files={"file": ("audio.wav", f, "audio/wav")},
|
||||
data={"model": model, "response_format": "text"},
|
||||
headers=hdrs,
|
||||
|
||||
@ -231,6 +231,35 @@ def _seed_finder_backend_ref_audio(audio: Path, settings: dict) -> str:
|
||||
return f"/voices/{audio.name}"
|
||||
|
||||
|
||||
def sync_voice_clone_reference(audio: Path, transcript: str, language: str, settings: dict) -> None:
|
||||
"""Publish a saved reference transcript to Qwen's hot-reloaded voices.json."""
|
||||
transcript = str(transcript or "").strip()
|
||||
if not transcript:
|
||||
raise ValueError("reference transcript is required")
|
||||
voices_path = _seed_finder_voices_json_path(settings)
|
||||
if not voices_path.exists():
|
||||
raise RuntimeError(f"TTS voices.json not found at {voices_path}")
|
||||
language_name = {
|
||||
"DE": "German", "EN": "English", "FR": "French", "ES": "Spanish",
|
||||
"IT": "Italian", "PT": "Portuguese", "NL": "Dutch", "PL": "Polish",
|
||||
"ZH": "Chinese", "JA": "Japanese", "KO": "Korean",
|
||||
}.get(str(language or "").upper(), "Auto")
|
||||
with _SEED_FINDER_VOICE_LOCK:
|
||||
voices = json.loads(voices_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(voices, dict):
|
||||
raise RuntimeError("voices.json is not an object")
|
||||
cfg = dict(voices.get(audio.stem) or {})
|
||||
cfg["ref_audio"] = _seed_finder_backend_ref_audio(audio, settings)
|
||||
cfg["ref_text"] = transcript
|
||||
cfg["language"] = language_name
|
||||
# The old embedding belongs to a voice without this transcript.
|
||||
# Removing it forces one correct warm-up on the next synthesis.
|
||||
cfg.pop("speaker_embeddings", None)
|
||||
voices[audio.stem] = cfg
|
||||
voices_path.write_text(json.dumps(voices, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
time.sleep(0.35)
|
||||
|
||||
|
||||
def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
|
||||
cfg = dict(voices.get(voice) or {})
|
||||
if cfg.get("ref_audio"):
|
||||
|
||||
26
static/dist/main.min.js
vendored
26
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.20.36">
|
||||
<meta name="app-version" content="1.20.37">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.20.36">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.20.37">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
|
||||
@ -457,12 +457,16 @@ function csMergeAliases(existing, incoming) {
|
||||
return [...names.values()].filter(n => n.toLowerCase() !== String(existing.name || '').toLowerCase()).join(', ');
|
||||
}
|
||||
|
||||
function csMerge(map, sheets) {
|
||||
function csMerge(map, sheets, opts = {}) {
|
||||
const changed = [];
|
||||
for (const s of sheets) {
|
||||
const name = (s.name || '').trim(); if (!name) continue;
|
||||
s.aliases = csMergeAliases({ name, aliases: '' }, s);
|
||||
const key = csFindMergeKey(map, s);
|
||||
// A live casting roster is authoritative: two different names shown in
|
||||
// Characters found must never be folded together merely because the LLM
|
||||
// calls one an alias of the other. Alias-aware merging remains useful
|
||||
// while discovering characters without a roster, but is unsafe here.
|
||||
const key = opts.strictRoster ? name.toLowerCase() : csFindMergeKey(map, s);
|
||||
if (!map.has(key)) {
|
||||
const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] };
|
||||
CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); });
|
||||
@ -769,7 +773,13 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
|
||||
try { data = JSON.parse(raw); }
|
||||
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
|
||||
}
|
||||
const incomingSheets = targetMode ? (data.sheets || []).filter(s => csSheetMatchesRoster(s, roster)) : (data.sheets || []);
|
||||
// In roster mode the API must return the exact speaker name it was
|
||||
// asked to define. Matching through aliases here let a response
|
||||
// for e.g. "Zerwas" update the separately-cast "Alrik" profile.
|
||||
// Keep alias matching solely for free-form discovery mode.
|
||||
const incomingSheets = targetMode
|
||||
? (data.sheets || []).filter(s => roster.some(n => n.toLowerCase() === String(s?.name || '').trim().toLowerCase()))
|
||||
: (data.sheets || []);
|
||||
// Confirmed live: a passage featuring two characters together can
|
||||
// make the model write one character's real name into ANOTHER
|
||||
// character's first_name/last_name field (e.g. a "Darrag" sheet
|
||||
@ -790,7 +800,7 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
|
||||
}
|
||||
});
|
||||
});
|
||||
const changed = csMerge(map, incomingSheets);
|
||||
const changed = csMerge(map, incomingSheets, { strictRoster: targetMode });
|
||||
if (changed.length) prog.focus(changed[0].name);
|
||||
if (!targetMode) {
|
||||
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); });
|
||||
@ -833,7 +843,10 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) {
|
||||
}
|
||||
} finally { prog.done(); }
|
||||
if (_cs.cancel) { toast('Cancelled', 'error'); return null; }
|
||||
csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster);
|
||||
// Do not consolidate alias-linked entries during a cast pass. The
|
||||
// Characters-found sidebar owns the profile names and line counts; aliases
|
||||
// describe a profile but never replace or remove another roster entry.
|
||||
if (!targetMode) csConsolidateAliasDuplicates(map, establishedKeys, establishedCreated, roster);
|
||||
// The live casting roster (and its Characters found sidebar) owns the
|
||||
// display name. Any LLM-selected real name/role stays in aliases instead of
|
||||
// renaming the profile.
|
||||
@ -1341,8 +1354,8 @@ function csAlignmentBar(score, arcDirection, arcNote) {
|
||||
// ── Image & Voice prompt generators ──────────────────────────────────────────
|
||||
|
||||
// Fallback only — the LLM-generated image_prompt (routes/conversation.py
|
||||
// character_generate_prompts) is preferred and asks for the same turnaround-
|
||||
// sheet format; this covers the case where that hasn't been generated yet.
|
||||
// character_generate_prompts) is preferred. This is deliberately a
|
||||
// photorealistic *profile portrait*; concept sheets use concept_art_prompt.
|
||||
function csBuildImagePrompt(s, bookProfile) {
|
||||
if (_csStr(s.image_prompt).trim()) return _csStr(s.image_prompt).trim();
|
||||
const parts = [];
|
||||
@ -1368,20 +1381,19 @@ function csBuildImagePrompt(s, bookProfile) {
|
||||
+ `clothing materials, weaponry, ethnicity mix, technology level — must fit THAT world, not a generic modern or `
|
||||
+ `real-world default. `
|
||||
: '';
|
||||
return `Create a complete character reference sheet for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
|
||||
return `Create a photorealistic live-action square character portrait for an original character named ${s.name}, ${parts.filter(Boolean).join(', ')}. `
|
||||
+ `VISUAL IDENTITY LOCK: use the exact gender, age impression, species, face shape, skin tone, hair, eyes, facial hair, `
|
||||
+ `distinguishing marks, build, clothing, colors, armor, jewelry, weapons, tools, and signature props established here. `
|
||||
+ `Keep one identical face identity anchor across the portrait, front/side/back turnaround, expression sheet, and later concept art. `
|
||||
+ `Keep one identical face identity anchor for future concept art and profile images. `
|
||||
+ settingClause
|
||||
+ `Base the setting, era, and art style strictly on the character's own described archetype and clothing above — `
|
||||
+ `do not default to a modern or real-world 20th/21st-century look for occupation-sounding titles (e.g. an "Admiral" `
|
||||
+ `or "General" in a fantasy/period setting should NOT be drawn in a contemporary military uniform); every visual `
|
||||
+ `choice should fit the world implied by the description, not the real one, unless the description itself is `
|
||||
+ `explicitly modern/contemporary. `
|
||||
+ `Include a full-body front view as the anchor, a turnaround panel (side and back views), an expression sheet `
|
||||
+ `with 3-5 headshots matching their personality, a color palette swatch for hair/eyes/outfit, and labeled callouts `
|
||||
+ `for signature props or clothing details. Clean production concept-art layout, plain neutral background, `
|
||||
+ `original character not based on any copyrighted character.`;
|
||||
+ `Show a head-and-shoulders portrait facing camera, with a natural in-character expression, on a subdued neutral studio background. `
|
||||
+ `Photorealistic cinematic fantasy-film photography: natural skin texture and pores, believable anatomy, authentic cloth/leather/metal, physically plausible lighting and shadows. `
|
||||
+ `Not a drawing, painting, anime, game render, plastic skin, collage, text, logo, watermark, or copyrighted character.`;
|
||||
}
|
||||
|
||||
function csBuildVoicePrompt(s) {
|
||||
|
||||
@ -191,24 +191,21 @@ function initElevenLabsBrowser() {
|
||||
try {
|
||||
const r = await fetch('/api/quick-import-voice', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audio_url: url, voice_id: voiceId, transcript: '' }),
|
||||
body: JSON.stringify({
|
||||
audio_url: url, voice_id: voiceId, transcript: '', auto_prepare: true,
|
||||
lang, gender, name, tag: 'elevenlabs', group: 'elevenlabs', origin: 'cloned',
|
||||
note: (btn.dataset.description || '').slice(0, 180),
|
||||
source: `ElevenLabs shared voice${btn.dataset.voiceId ? ` · ${btn.dataset.voiceId}` : ''}`,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
const d = await r.json();
|
||||
if (typeof saveMeta === 'function') {
|
||||
await saveMeta(d.voice_id, {
|
||||
name, tag: 'elevenlabs', group: 'elevenlabs', origin: 'cloned',
|
||||
gender, language: lang,
|
||||
note: (btn.dataset.description || '').slice(0, 180),
|
||||
source: `ElevenLabs shared voice${btn.dataset.voiceId ? ` · ${btn.dataset.voiceId}` : ''}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
btn.innerHTML = '<span class="mdi mdi-check"></span> In library';
|
||||
btn.classList.add('el-imported');
|
||||
if (typeof toast === 'function') toast(`Imported "${name}" to Voice Library`, 'success');
|
||||
if (typeof toast === 'function') toast(`Imported "${name}" — transcript and benchmark are preparing in the background`, 'success');
|
||||
if (typeof loadVoiceLibrary === 'function') {
|
||||
_libraryLoadPromise = null;
|
||||
await loadVoiceLibrary();
|
||||
|
||||
@ -8,6 +8,13 @@ async function libraryRenderCharacters() {
|
||||
const container = document.getElementById('lib-chars-list');
|
||||
if (!container) return;
|
||||
|
||||
// This page can open before the shared voice library has finished loading.
|
||||
// Ratings live in window._voices, so rendering during that gap makes every
|
||||
// assigned voice look unrated even when its .meta.json still has a rating.
|
||||
if ((!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
|
||||
try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); }
|
||||
}
|
||||
|
||||
// Fetch BEFORE touching the DOM: a transient failure here (e.g. the
|
||||
// server briefly unresponsive while a TTS container restart is in
|
||||
// flight) must never blank out an already-populated list — confirmed
|
||||
@ -98,6 +105,7 @@ async function libraryRenderCharacters() {
|
||||
['gender', 'Geschlecht'],
|
||||
['voice', 'Stimme zugewiesen'],
|
||||
['rating', 'Stimmenbewertung'],
|
||||
['profile', 'Profil vollständig'],
|
||||
];
|
||||
const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); })
|
||||
? localStorage.getItem('ttsvc_libchars_sort') : 'tier';
|
||||
@ -154,6 +162,10 @@ async function libraryRenderCharacters() {
|
||||
const ratingOf = function (r) { return Number((window._voices || []).find(function (v) { return v.id === idOf(r); })?.rating || 0); };
|
||||
return ratingOf(b) - ratingOf(a) || (a.name || '').localeCompare(b.name || '');
|
||||
},
|
||||
profile: function (a, b) {
|
||||
const ca = _charProfileCompleteness(a.sheet), cb = _charProfileCompleteness(b.sheet);
|
||||
return cb.filled - ca.filled || (a.name || '').localeCompare(b.name || '');
|
||||
},
|
||||
age: function (a, b) { return (_charAgeSortVal(a.sheet) - _charAgeSortVal(b.sheet)) || (a.name || '').localeCompare(b.name || ''); },
|
||||
language: function (a, b) { return _charLangLabel(a).localeCompare(_charLangLabel(b)) || (a.name || '').localeCompare(b.name || ''); },
|
||||
align: function (a, b) { return (b.sheet?.moral_alignment_score ?? -1) - (a.sheet?.moral_alignment_score ?? -1) || (a.name || '').localeCompare(b.name || ''); },
|
||||
@ -205,6 +217,7 @@ async function libraryRenderCharacters() {
|
||||
+ '<option value="comfyui">Local ComfyUI</option>'
|
||||
+ '</select>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-image-btn" data-book="' + escHtml(book) + '" title="Generate a profile picture for every checked character, from their Character Image Prompt" disabled><span class="mdi mdi-image-outline"></span> Auto-generate images (<span class="lib-chars-bulk-count-image">0</span>)</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-concept-btn" data-book="' + escHtml(book) + '" title="Generate a 3:2 character reference sheet for every checked character, from their Concept Art Prompt" disabled><span class="mdi mdi-image-multiple-outline"></span> Auto-generate concept art (<span class="lib-chars-bulk-count-concept">0</span>)</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-delete-btn" data-book="' + escHtml(book) + '" title="Delete every checked character from the library — use this to clear out stale/corrupted entries before a fresh recast" disabled><span class="mdi mdi-trash-can-outline"></span> Delete selected (<span class="lib-chars-bulk-count-delete">0</span>)</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-fix-lang-btn" data-book="' + escHtml(book) + '" title="Find every character whose current voice does not match this book\'s language, and design a properly-matching one — no selection needed, scans the whole production"><span class="mdi mdi-earth-arrow-right"></span> Fix wrong-language voices</button>'
|
||||
+ '</div></div>'
|
||||
@ -277,6 +290,8 @@ async function libraryRenderCharacters() {
|
||||
const designCount = prod.querySelector('.lib-chars-bulk-count-design');
|
||||
const imageBtn = prod.querySelector('.lib-chars-bulk-image-btn');
|
||||
const imageCount = prod.querySelector('.lib-chars-bulk-count-image');
|
||||
const conceptBtn = prod.querySelector('.lib-chars-bulk-concept-btn');
|
||||
const conceptCount = prod.querySelector('.lib-chars-bulk-count-concept');
|
||||
const imageProviderSel = prod.querySelector('.lib-chars-image-provider');
|
||||
if (imageProviderSel) imageProviderSel.value = (typeof _appSettings !== 'undefined' && _appSettings.image_gen_provider) || '';
|
||||
const deleteBtn = prod.querySelector('.lib-chars-bulk-delete-btn');
|
||||
@ -297,6 +312,8 @@ async function libraryRenderCharacters() {
|
||||
designBtn.disabled = n === 0;
|
||||
imageCount.textContent = n;
|
||||
imageBtn.disabled = n === 0;
|
||||
conceptCount.textContent = n;
|
||||
conceptBtn.disabled = n === 0;
|
||||
deleteCount.textContent = n;
|
||||
deleteBtn.disabled = n === 0;
|
||||
syncTblSelectAllCb();
|
||||
@ -380,6 +397,12 @@ async function libraryRenderCharacters() {
|
||||
const provider = imageProviderSel ? imageProviderSel.value : '';
|
||||
runBulk(imageBtn, ids, 'Generating images', function (rec) { return _charAutoGenerateImage(rec, provider); });
|
||||
});
|
||||
conceptBtn.addEventListener('click', function () {
|
||||
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
|
||||
if (!ids.length) return;
|
||||
const provider = imageProviderSel ? imageProviderSel.value : '';
|
||||
runBulk(conceptBtn, ids, 'Generating concept art', function (rec) { return _charAutoGenerateConceptArt(rec, provider); });
|
||||
});
|
||||
const fixLangBtn = prod.querySelector('.lib-chars-fix-lang-btn');
|
||||
fixLangBtn?.addEventListener('click', function () {
|
||||
// No selection needed — scans every character in this production for
|
||||
@ -449,6 +472,11 @@ async function libraryRenderCharacters() {
|
||||
prod.querySelectorAll('.lib-chars-tbl-voice-note').forEach(function (input) {
|
||||
let timer = null;
|
||||
input.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
// The notes cell has an inline click handler for row navigation, which
|
||||
// the accessibility enhancer also exposes as a keyboard button. Stop
|
||||
// key events here so the document-level button shortcut cannot cancel
|
||||
// normal text editing (notably Space and Enter in a textarea).
|
||||
input.addEventListener('keydown', function (e) { e.stopPropagation(); });
|
||||
input.addEventListener('input', function () {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(async function () {
|
||||
@ -848,6 +876,24 @@ function _charCardHtml(rec, allChars) {
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
// These are the 32 substantive profile fields shown in the character sheet.
|
||||
// Identity/name and generated assets are intentionally excluded.
|
||||
const _CHAR_PROFILE_COMPLETENESS_FIELDS = [
|
||||
'gender', 'age_estimate', 'race_species', 'languages', 'nationality_background',
|
||||
'social_class', 'profession', 'title', 'archetype', 'physical', 'clothing',
|
||||
'alignment', 'arc_direction', 'arc_note', 'attribute_high', 'attribute_low',
|
||||
'skills', 'capabilities', 'backstory', 'relationships', 'motivation', 'fears',
|
||||
'mannerisms', 'communication_style', 'voice_pattern', 'secret', 'conflict_style',
|
||||
'win_condition', 'reputation', 'religious_beliefs', 'notes', 'inventory',
|
||||
];
|
||||
function _charProfileCompleteness(sheet) {
|
||||
const sh = sheet || {};
|
||||
const filled = _CHAR_PROFILE_COMPLETENESS_FIELDS.filter(function (key) {
|
||||
return _libStr(sh[key]).trim().length > 0;
|
||||
}).length;
|
||||
return { filled: filled, total: _CHAR_PROFILE_COMPLETENESS_FIELDS.length };
|
||||
}
|
||||
|
||||
// Compact alternative to the card grid for scanning a large cast at once —
|
||||
// one row per character with the fields that matter for casting decisions.
|
||||
// Rows carry the "lib-char-card" class too so they're picked up by the same
|
||||
@ -865,7 +911,11 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
const voiceMeta = voiceId ? (window._voices || []).find(function (v) { return v.id === voiceId; }) : null;
|
||||
const voiceMissing = !!voiceId && !_voiceExists(voiceId);
|
||||
const voiceRating = Math.max(0, Math.min(5, Number(voiceMeta?.rating || 0)));
|
||||
const voiceNote = _libStr(voiceMeta?.note);
|
||||
// Designed voices used to copy a clipped Voice Design Prompt into the
|
||||
// general note field. The prompt now has its own column; keep Notes for
|
||||
// user-authored comments only, and don't display that legacy duplicate.
|
||||
const rawVoiceNote = _libStr(voiceMeta?.note);
|
||||
const voiceNote = /^voice design\s*:/i.test(rawVoiceNote) ? '' : rawVoiceNote;
|
||||
const voicePrompt = _libStr(voiceMeta?.voice_design_prompt || sh.voice_design_prompt);
|
||||
const stars = voiceId
|
||||
? '<div class="lib-chars-tbl-rating" role="group" aria-label="Voice rating">' + [1, 2, 3, 4, 5].map(function (n) {
|
||||
@ -892,6 +942,8 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
const archetype = _libStr(sh.archetype);
|
||||
const titleLabel = _libStr(sh.title);
|
||||
const tableMeta = [aliasLabel ? 'aka ' + aliasLabel : '', occupation ? 'Occupation: ' + occupation : '', titleLabel ? 'Title: ' + titleLabel : '', archetype ? 'Archetype: ' + archetype : ''].filter(Boolean).join(' · ');
|
||||
const profile = _charProfileCompleteness(sh);
|
||||
const profileClass = profile.filled <= 8 ? 'is-sparse' : profile.filled <= 20 ? 'is-partial' : 'is-strong';
|
||||
return '<tr class="lib-char-card lib-chars-tbl-row" data-char-id="' + escHtml(rec.id) + '">'
|
||||
+ '<td class="lib-chars-tbl-cb" onclick="event.stopPropagation()"><input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '"></td>'
|
||||
+ '<td class="lib-chars-tbl-avatar-cell"><div class="lib-char-avatar lib-chars-tbl-avatar" data-char-id="' + escHtml(rec.id) + '" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + avatarInner + '</div></td>'
|
||||
@ -918,9 +970,11 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
+ '<td class="lib-chars-tbl-rating-cell" onclick="event.stopPropagation()">' + stars + '</td>'
|
||||
+ '<td class="lib-chars-tbl-prompt" title="' + escHtml(voicePrompt) + '">' + (voicePrompt ? escHtml(voicePrompt) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||||
+ '<td class="lib-chars-tbl-note-cell" onclick="event.stopPropagation()">'
|
||||
+ (voiceId ? '<input type="text" class="lib-chars-tbl-voice-note" data-voice-id="' + escHtml(voiceId) + '" value="' + escHtml(voiceNote) + '" placeholder="Add note…" title="Voice Library note">' : '<span class="lib-chars-tbl-dash">—</span>')
|
||||
+ (voiceId ? '<textarea class="lib-chars-tbl-voice-note" data-voice-id="' + escHtml(voiceId) + '" rows="2" placeholder="Add note…" title="Voice Library note">' + escHtml(voiceNote) + '</textarea>' : '<span class="lib-chars-tbl-dash">—</span>')
|
||||
+ '</td>'
|
||||
+ '<td class="lib-chars-tbl-tags"><div class="lib-chars-tbl-tags-wrap">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</div></td>'
|
||||
+ '<td class="lib-chars-tbl-book-cell">' + (bookLabel ? escHtml(bookLabel) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
|
||||
+ '<td class="lib-chars-tbl-profile"><span class="lib-chars-profile-count ' + profileClass + '" title="' + profile.filled + ' von ' + profile.total + ' Profilfeldern ausgefüllt">' + profile.filled + ' / ' + profile.total + '</span></td>'
|
||||
+ '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
@ -932,13 +986,13 @@ function _charsTableHtml(chars, sortMode, sortDir) {
|
||||
// the same row. Fixed explicit widths sidestep the whole class of bug.
|
||||
+ '<colgroup><col style="width:28px"><col style="width:60px"><col style="width:300px">'
|
||||
+ '<col style="width:88px"><col style="width:80px"><col style="width:72px"><col style="width:100px">'
|
||||
+ '<col style="width:140px"><col style="width:230px"><col style="width:126px"><col style="width:360px"><col style="width:260px"><col style="width:220px"><col style="width:200px">'
|
||||
+ '<col style="width:140px"><col style="width:230px"><col style="width:126px"><col style="width:440px"><col style="width:260px"><col style="width:220px"><col style="width:200px"><col style="width:90px">'
|
||||
+ '<col style="width:36px"></colgroup>'
|
||||
+ '<thead><tr>'
|
||||
+ '<th class="lib-chars-tbl-cb"><input type="checkbox" class="lib-chars-tbl-select-all-cb" title="Select all"></th><th></th>' + th('alpha', 'Name') + th('gender', 'Geschlecht') + th('age', 'Alter', 'Estimated age')
|
||||
+ th('lines', 'Zeilen', 'Anzahl Zeilen') + th('language', 'Sprache') + th('align', 'Gut/Böse', 'Moralische Gesinnung') + th('voice', 'Stimme') + th('rating', 'Rating', 'Rate the assigned voice')
|
||||
+ '<th title="The exact prompt used to create the assigned voice">Voice Design Prompt</th><th>Notes</th>'
|
||||
+ '<th>Tags</th><th>Book / Script</th><th></th>'
|
||||
+ '<th>Tags</th><th>Book / Script</th><th class="lib-chars-tbl-sortable" data-sort-key="profile" title="Filled profile fields">Profil</th><th></th>'
|
||||
+ '</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
@ -2794,11 +2848,9 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
|
||||
group: rec.book || undefined,
|
||||
tag: rec.book || undefined,
|
||||
transcript: sampleText,
|
||||
note: 'Voice Design: ' + instruct.slice(0, 240),
|
||||
// For a designed voice the instruct IS the voice's identity — the TTS
|
||||
// engine reproduces it from this text alone. `note` is a short display
|
||||
// summary and is deliberately clipped, so it cannot be the source of
|
||||
// truth; store the prompt in full here.
|
||||
// For a designed voice the instruct IS the voice's identity. The
|
||||
// dedicated Voice Design Prompt column shows it in full; leave note
|
||||
// untouched for the user's own review comments.
|
||||
voice_design_prompt: instruct,
|
||||
}).catch(function () {});
|
||||
}
|
||||
@ -2833,7 +2885,7 @@ async function _charAutoGenerateImage(rec, provider) {
|
||||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {};
|
||||
const prompt = hasExplicitPrompt ? _libStr(sh.image_prompt).trim() : (typeof csBuildImagePrompt === 'function' ? csBuildImagePrompt(sh, bookProfile) : '');
|
||||
if (!prompt) throw new Error('No image prompt to work from yet');
|
||||
const body = { prompt: prompt };
|
||||
const body = { prompt: prompt, image_kind: 'portrait' };
|
||||
if (provider) body.provider = provider;
|
||||
const r = await fetch('/api/character-generate-image', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@ -2865,7 +2917,7 @@ async function _charAutoGenerateConceptArt(rec, provider) {
|
||||
const sh = rec.sheet || {};
|
||||
const prompt = _libStr(sh.concept_art_prompt).trim();
|
||||
if (!prompt) throw new Error('No concept art prompt to work from yet — generate the prompt first');
|
||||
const body = { prompt: prompt };
|
||||
const body = { prompt: prompt, image_kind: 'concept_art' };
|
||||
if (provider) body.provider = provider;
|
||||
const r = await fetch('/api/character-generate-image', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@ -2187,8 +2187,14 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
|
||||
if (!rehState.cast[sp]) { done++; continue; }
|
||||
const info = byName[sp.toUpperCase().trim()] || {};
|
||||
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
|
||||
const desc = info.description ||
|
||||
`A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`;
|
||||
const hardGender = gender === 'M' ? 'male' : gender === 'F' ? 'female' : 'gender-neutral';
|
||||
const hardAge = info.age || 'age unspecified';
|
||||
const hardLanguage = info.language || language || 'the script language';
|
||||
const desc = [
|
||||
`HARD CASTING CONSTRAINTS: ${hardGender} voice, apparent age ${hardAge}, spoken in ${hardLanguage}.`,
|
||||
info.description || `Character named ${sp}; natural expressive audiobook voice.`,
|
||||
`Make this voice audibly distinct from the other cast members: use a memorable vocal fingerprint, not a generic narrator voice.`,
|
||||
].join(' ');
|
||||
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
|
||||
|
||||
const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
|
||||
@ -2223,6 +2229,7 @@ $('reh-autodesign-btn')?.addEventListener('click', async () => {
|
||||
if (typeof saveMeta === 'function') {
|
||||
await saveMeta(saved.voice_id, {
|
||||
gender,
|
||||
age: info.age || '',
|
||||
name: charName,
|
||||
avatar: _pickVoiceAvatar(gender, desc, sp),
|
||||
origin: 'designed',
|
||||
@ -2310,7 +2317,7 @@ $('reh-matchlib-btn')?.addEventListener('click', async () => {
|
||||
if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }
|
||||
|
||||
const candidates = lib.map(v => ({
|
||||
id: v.id, gender: v.gender || '', language: v.lang || '',
|
||||
id: v.id, gender: v.gender || '', age: v.age || '', language: v.lang || '',
|
||||
tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
|
||||
}));
|
||||
const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp);
|
||||
|
||||
@ -3320,7 +3320,7 @@ function makeVoiceRow(v) {
|
||||
_voices = _voices.filter(x => x.id !== v.id);
|
||||
wrap.style.transition = 'opacity .3s'; wrap.style.opacity = '0';
|
||||
setTimeout(() => { wrap.remove(); $('voice-count').textContent = _voices.filter(x => $('show-disabled-cb').checked || x.enabled !== false).length + ' / ' + _voices.length + ' voices'; }, 300);
|
||||
toast(`Deleted: ${v.id}`, 'success');
|
||||
toast(`Moved to Voice Trash: ${v.id}`, 'success');
|
||||
} catch(e) {
|
||||
toast('Delete failed: ' + e.message, 'error');
|
||||
deleteGoBtn.disabled = false;
|
||||
@ -3351,7 +3351,7 @@ function renderVoiceGroupsBar() {
|
||||
names.map(g =>
|
||||
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
|
||||
${escHtml(g)} <span class="vg-count">${groups[g]}</span>
|
||||
<button class="vg-del" data-group="${escHtml(g)}" title="Delete all ${groups[g]} voices in this group">×</button>
|
||||
<button class="vg-del" data-group="${escHtml(g)}" title="Move all ${groups[g]} voices in this group to Voice Trash">×</button>
|
||||
</span>`
|
||||
).join('') +
|
||||
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
|
||||
@ -3377,7 +3377,7 @@ function renderVoiceGroupsBar() {
|
||||
|
||||
async function deleteVoiceGroup(group) {
|
||||
const count = (_voices || []).filter(v => (v.group || '').trim() === group).length;
|
||||
if (!confirm(`Delete all ${count} voice${count!==1?'s':''} in group "${group}"? This cannot be undone.`)) return;
|
||||
if (!confirm(`Move all ${count} voice${count!==1?'s':''} in group "${group}" to Voice Trash? You can restore the complete packages from the trash folder later.`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/voices/delete-group', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@ -3386,7 +3386,7 @@ async function deleteVoiceGroup(group) {
|
||||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
if (window._voiceGroupFilter === group) window._voiceGroupFilter = '';
|
||||
toast(`Deleted ${d.count} voice${d.count!==1?'s':''} from "${group}"`, 'success');
|
||||
toast(`Moved ${d.count} voice${d.count!==1?'s':''} from "${group}" to Voice Trash`, 'success');
|
||||
await loadVoiceLibrary();
|
||||
} catch(e) { toast('Delete failed: ' + e.message, 'error'); }
|
||||
}
|
||||
@ -3644,13 +3644,13 @@ function _showBulkDeleteConfirm(ids) {
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'vl-bdc-overlay';
|
||||
ov.innerHTML = `
|
||||
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm delete voices">
|
||||
<div class="vl-bdc-title"><span class="mdi mdi-alert-circle-outline"></span> Delete ${ids.length} voice${plural}?</div>
|
||||
<p class="vl-bdc-sub">This permanently removes the selected voice${plural} from your library. This cannot be undone.</p>
|
||||
<div class="vl-bdc-modal" role="alertdialog" aria-modal="true" aria-label="Confirm move voices to trash">
|
||||
<div class="vl-bdc-title"><span class="mdi mdi-delete-outline"></span> Move ${ids.length} voice${plural} to Voice Trash?</div>
|
||||
<p class="vl-bdc-sub">This removes the selected voice${plural} from the library while preserving each complete package in the Voice Trash folder.</p>
|
||||
<div class="vl-bdc-list">${names}${more}</div>
|
||||
<div class="vl-bdc-actions">
|
||||
<button type="button" class="vl-bdc-cancel">Cancel</button>
|
||||
<button type="button" class="vl-bdc-go">Delete ${ids.length} voice${plural}</button>
|
||||
<button type="button" class="vl-bdc-go">Move ${ids.length} voice${plural} to Trash</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@ -3670,9 +3670,9 @@ function _showBulkDeleteConfirm(ids) {
|
||||
const r = await fetch(`/api/voice/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
if (r.ok) done++; else errors++;
|
||||
} catch (_) { errors++; }
|
||||
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Deleting ${n}/${ids.length}…`; });
|
||||
}, 5, (n) => { goBtn.innerHTML = `<span class="reh-imsdb-spinner"></span> Moving ${n}/${ids.length}…`; });
|
||||
close();
|
||||
toast(`Deleted ${done} voice${done !== 1 ? 's' : ''}${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
|
||||
toast(`Moved ${done} voice${done !== 1 ? 's' : ''} to Voice Trash${errors ? ` (${errors} errors)` : ''}`, errors ? 'error' : 'success');
|
||||
_bulkSelected.clear();
|
||||
await loadVoiceLibrary();
|
||||
});
|
||||
|
||||
@ -677,7 +677,7 @@
|
||||
<div class="card" id="comfyui-config-panel" hidden style="margin-top:16px">
|
||||
<div class="s-page-head">
|
||||
<h3><span class="mdi mdi-graph-outline"></span> Local ComfyUI Workflow</h3>
|
||||
<p>Runs a workflow on your own GPU instead of a paid API. Export it from ComfyUI's own UI first — Workflow menu → Export (API Format) — then paste the result below. The same workflow is used for both single portraits and character concept sheets; only the prompt text sent to it differs.</p>
|
||||
<p>Runs on your own GPU, with no paid API. Voice Creator includes validated high-quality local Flux workflows: a square profile portrait and a 3:2 concept-art sheet. Leave the advanced workflow fields blank to use them automatically. An advanced workflow below overrides profile portraits; concept-art sheets always retain their dedicated 3:2 workflow.</p>
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:14px">
|
||||
<div class="s-field">
|
||||
@ -686,9 +686,9 @@
|
||||
<span class="s-hint">Base URL of your running ComfyUI instance (container port 8188 by default).</span>
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label>Workflow JSON (API format)</label>
|
||||
<label>Advanced workflow JSON (API format, optional override)</label>
|
||||
<textarea id="comfyui-workflow" rows="6" spellcheck="false" placeholder='{"3": {"class_type": "KSampler", "inputs": {...}}, "6": {"class_type": "CLIPTextEncode", "inputs": {"text": "", ...}}, ...}' style="font-family:monospace; font-size:12px"></textarea>
|
||||
<span class="s-hint">Paste the raw JSON exported from ComfyUI's "Export (API Format)" action — not a regular saved workflow (that format has "nodes"/"links" keys and won't work here).</span>
|
||||
<span class="s-hint">Leave blank for Voice Creator's validated Flux Dev portrait and concept-sheet workflows. Paste raw JSON exported from ComfyUI's "Export (API Format)" only when you deliberately want to replace both.</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:14px; flex-wrap:wrap">
|
||||
<div class="s-field" style="flex:1; min-width:160px">
|
||||
|
||||
@ -608,7 +608,11 @@ audio { width: 100%; }
|
||||
.lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
|
||||
/* Table view — dense alternative to the card grid for scanning a large cast */
|
||||
.lib-chars-tbl-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:12px; background:var(--surface); position:relative; }
|
||||
.lib-chars-tbl-wrap {
|
||||
width:100%; max-width:100%; min-width:0; overflow-x:auto; overflow-y:hidden;
|
||||
border:1px solid var(--border); border-radius:12px; background:var(--surface);
|
||||
position:relative; scrollbar-gutter:stable;
|
||||
}
|
||||
.lib-chars-tbl { width:100%; min-width:2410px; table-layout:fixed; border-collapse:collapse; font-size:12.9px; white-space:nowrap; }
|
||||
.lib-chars-tbl td { overflow: hidden; text-overflow: ellipsis; }
|
||||
.lib-chars-tbl thead th {
|
||||
@ -686,18 +690,34 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
matching its own header entirely). Flex only the inner wrapper instead. */
|
||||
.lib-chars-tbl-tags { white-space:normal; max-width:260px; }
|
||||
.lib-chars-tbl-tags-wrap { display:flex; flex-wrap:wrap; gap:3px; }
|
||||
.lib-chars-tbl-profile { text-align:center; }
|
||||
.lib-chars-profile-count {
|
||||
display:inline-block; min-width:54px; padding:3px 6px; border-radius:999px;
|
||||
font-size:11.5px; font-weight:800; font-variant-numeric:tabular-nums;
|
||||
border:1px solid var(--border); background:var(--panel); color:var(--subtext);
|
||||
}
|
||||
.lib-chars-profile-count.is-sparse { color:#b42318; background:rgba(239,68,68,.10); border-color:rgba(239,68,68,.35); }
|
||||
.lib-chars-profile-count.is-partial { color:#a15c00; background:rgba(245,158,11,.12); border-color:rgba(245,158,11,.4); }
|
||||
.lib-chars-profile-count.is-strong { color:#137333; background:rgba(34,197,94,.10); border-color:rgba(34,197,94,.35); }
|
||||
.lib-chars-tbl-rating { display:inline-flex; gap:1px; }
|
||||
.lib-chars-tbl-rating-star {
|
||||
border:0; background:transparent; padding:1px; color:#d1d5db; cursor:pointer; line-height:1;
|
||||
}
|
||||
.lib-chars-tbl-rating-star:hover, .lib-chars-tbl-rating-star.is-on { color:#f59e0b; }
|
||||
.lib-chars-tbl-prompt { color:var(--subtext); font-size:11.5px; font-style:italic; }
|
||||
.lib-chars-tbl-prompt {
|
||||
color:var(--subtext); font-size:11.5px; font-style:italic;
|
||||
white-space:normal; overflow-wrap:anywhere; word-break:normal; line-height:1.4;
|
||||
vertical-align:top;
|
||||
}
|
||||
.lib-chars-tbl-note-cell { padding:5px 8px !important; }
|
||||
.lib-chars-tbl-voice-note {
|
||||
width:100%; min-width:0; border:1px solid transparent; background:transparent; color:var(--text);
|
||||
padding:4px 5px; border-radius:5px; font:inherit;
|
||||
display:block; width:100%; min-width:0; min-height:42px; resize:vertical;
|
||||
border:1px solid transparent; background:transparent; color:var(--text);
|
||||
padding:4px 5px; border-radius:5px; font:inherit; line-height:1.35;
|
||||
white-space:pre-wrap; overflow-wrap:anywhere;
|
||||
}
|
||||
.lib-chars-tbl-voice-note:hover, .lib-chars-tbl-voice-note:focus { border-color:var(--border); background:var(--panel); outline:none; }
|
||||
.lib-chars-tbl-voice-note:focus { min-height:64px; }
|
||||
.lib-chars-tbl-dash { opacity:.4; }
|
||||
.lib-chars-tbl-check { display:inline-flex; }
|
||||
.lib-chars-tbl-check.is-yes { color:#4caf50; }
|
||||
|
||||
38
workflows/comfyui-character-concept-sheet.json
Normal file
38
workflows/comfyui-character-concept-sheet.json
Normal 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"}
|
||||
}
|
||||
}
|
||||
30
workflows/comfyui-character-portrait.json
Normal file
30
workflows/comfyui-character-portrait.json
Normal 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]}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user