chore: release 1.21.0 - Consistent ComfyUI Character Renders and Audiobook Export
- Deterministic hash-based character portraits and reference sheets - Implemented strict early Renaissance aesthetic controls and role-specific headwear rules - Added audiobook batch export scripts and progress tooling - Verified book profiles with a new 'confirmed' state tracking mechanism - Updated Pillow dependency for robust image handling - Configured .gitignore for audiobook and audio cache exports
This commit is contained in:
parent
fce5a0aa5d
commit
f7264a8c07
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,6 +41,9 @@ config/*.db
|
||||
config/*.db-shm
|
||||
config/*.db-wal
|
||||
config/reader_library/
|
||||
config/audiobook_batch_cache/
|
||||
config/audiobook_exports/
|
||||
config/line_audio_cache/
|
||||
logs/*.log
|
||||
logs/*.log.*
|
||||
|
||||
|
||||
@ -4,6 +4,15 @@ All notable changes to **TTS Voice Creator — Clone and Design** are documented
|
||||
Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned with [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [1.21.0] — 2026-08-23
|
||||
|
||||
### Added
|
||||
- **Consistent Character Renders in ComfyUI.** Replaced the generic fallback image pipeline with a deterministic, layout-first prompting strategy. A new `/api/character-generate-consistent-images` endpoint generates matching 1:1 portrait avatars and 3:2 character reference sheets (turnarounds) for the entire cast, maintaining strict character identity via hash-based deterministic seeds.
|
||||
- **Period and Setting Enforcement.** Image prompts now strictly enforce an Aventurian early-Renaissance (c. 1420-1500) style, explicitly clamping down on anachronistic modern fashion, high-fantasy glowing magic, and generic medieval tropes. Roles and classes automatically assign period-accurate clothing layers and headwear (e.g., chaperons, coifs).
|
||||
- **Automated Audiobook Export.** Added a batch script (`scripts/export_all_audiobooks.py`) for sequential, resumable exporting of all completed audiobooks into merged per-chapter MP3s.
|
||||
- **Book Profile Validation.** Added a `confirmed` status field to book profiles to track data completion.
|
||||
- **Dependency Updates.** Added `Pillow>=10.3.0` to requirements to support the new image processing pipeline.
|
||||
|
||||
## [1.20.46] — 2026-08-19
|
||||
|
||||
|
||||
@ -6,4 +6,5 @@ numpy>=1.26.0
|
||||
pedalboard>=0.9.0
|
||||
scipy>=1.12.0
|
||||
requests>=2.31.0
|
||||
Pillow>=10.3.0
|
||||
yt-dlp>=2024.3.10
|
||||
|
||||
@ -5,6 +5,7 @@ import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
@ -17,6 +18,7 @@ from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from typing import Optional
|
||||
from PIL import Image, ImageDraw, ImageOps
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
@ -1248,6 +1250,482 @@ def _silly_tavern_prompt_instruction(sheet: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _visual_prompt_value(value) -> str:
|
||||
"""Flatten character-sheet values without leaking Python reprs into prompts."""
|
||||
if isinstance(value, list):
|
||||
return ", ".join(_visual_prompt_value(item) for item in value if _visual_prompt_value(item))
|
||||
if isinstance(value, dict):
|
||||
return ", ".join(
|
||||
f"{key}: {_visual_prompt_value(item)}"
|
||||
for key, item in value.items() if _visual_prompt_value(item)
|
||||
)
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _primary_visual_role(sheet: dict) -> str:
|
||||
"""Choose the role most directly supported by the established costume.
|
||||
|
||||
Extracted profiles often accumulate plot roles (guard, commander, parent)
|
||||
alongside the day-to-day occupation. Sending every role to an image model
|
||||
caused it to add unrelated armor and weapons. Prefer the shortest role
|
||||
explicitly echoed by the clothing description, otherwise the first role.
|
||||
"""
|
||||
profession = _visual_prompt_value(sheet.get("profession"))
|
||||
roles = [part.strip() for part in re.split(r"[,;/\n]+", profession) if part.strip()]
|
||||
if not roles:
|
||||
return ""
|
||||
explicit = _visual_prompt_value(sheet.get("visual_role")).strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
clothing = _established_visual_value(sheet, "visual_costume", "clothing", 320).casefold()
|
||||
supported = [role for role in roles if len(role) >= 4 and role.casefold() in clothing]
|
||||
return min(supported, key=len) if supported else roles[0]
|
||||
|
||||
|
||||
def _established_visual_value(sheet: dict, override_key: str, source_key: str,
|
||||
limit: int = 320) -> str:
|
||||
"""Return visual facts that are established rather than speculative prose.
|
||||
|
||||
Character extraction is intentionally generous and sometimes records its
|
||||
reasoning ("probably wears...", "not described, but suitable for...").
|
||||
Those guesses are useful in the profile, but they must not become costume
|
||||
facts. A user-supplied visual override is authoritative; otherwise only
|
||||
non-speculative sentences survive.
|
||||
"""
|
||||
override = _visual_prompt_value(sheet.get(override_key)).strip()
|
||||
if override:
|
||||
return override[:limit]
|
||||
raw = _visual_prompt_value(sheet.get(source_key)).strip()
|
||||
if not raw:
|
||||
return ""
|
||||
uncertain = re.compile(
|
||||
r"\b(probably|possibly|perhaps|likely|assumed|inferred|uncertain|not described|"
|
||||
r"wahrscheinlich|vermutlich|vielleicht|möglicherweise|angenommen|unsicher|"
|
||||
r"nicht (?:näher |genau )?beschrieben|nicht detailliert beschrieben|"
|
||||
r"wir müssen vorsichtig|es ist unklar|laut (?:dem )?text|im kontext|"
|
||||
r"wird bei einem anderen|could be|might be)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
sentences = re.split(r"(?<=[.!?])\s+", raw)
|
||||
grounded = [sentence.strip() for sentence in sentences if sentence.strip() and not uncertain.search(sentence)]
|
||||
value = " ".join(grounded).strip()
|
||||
if len(value) > limit:
|
||||
value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
|
||||
return value
|
||||
|
||||
|
||||
def _character_visual_facts(name: str, sheet: dict, include_items: bool = True) -> str:
|
||||
"""Build the replaceable Subject block used by every character image."""
|
||||
def clipped(key: str, limit: int) -> str:
|
||||
value = _visual_prompt_value(sheet.get(key))
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
return value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
|
||||
|
||||
species = _established_visual_value(sheet, "visual_species", "race_species", 100)
|
||||
appearance = _established_visual_value(sheet, "visual_appearance", "physical", 320)
|
||||
costume = _established_visual_value(sheet, "visual_costume", "clothing", 320)
|
||||
rows = [
|
||||
("name", name),
|
||||
("gender", clipped("gender", 40)),
|
||||
("age", clipped("age_estimate", 100)),
|
||||
("species", species),
|
||||
("title", clipped("title", 100)),
|
||||
("role", clipped("profession", 180) if include_items else _primary_visual_role(sheet)),
|
||||
("archetype", clipped("archetype", 100)),
|
||||
("appearance", appearance),
|
||||
("established worn costume", costume),
|
||||
]
|
||||
if include_items:
|
||||
props = _character_prop_item_list(sheet)
|
||||
if props:
|
||||
rows.append(("verified portable items", ", ".join(props)))
|
||||
return "; ".join(f"{label}: {value}" for label, value in rows if value)
|
||||
|
||||
|
||||
def _book_visual_context(book: str, book_profile: dict) -> str:
|
||||
values = [
|
||||
_visual_prompt_value(book_profile.get("genre")),
|
||||
_visual_prompt_value(book_profile.get("setting")),
|
||||
_visual_prompt_value(book_profile.get("era")),
|
||||
_visual_prompt_value(book_profile.get("visual_context")),
|
||||
]
|
||||
context = "; ".join(value for value in values if value)
|
||||
return context or (
|
||||
f"the established world and period of {book}" if book else
|
||||
"the story's established world and historical period"
|
||||
)
|
||||
|
||||
|
||||
def _character_has_established_headwear(sheet: dict) -> bool:
|
||||
# Inventory is deliberately excluded. A helmet/hood listed as a prop or
|
||||
# recovered object does not mean the character wears it. Treat only the
|
||||
# established appearance/costume as authority for worn headgear.
|
||||
# A user/visual costume correction is more authoritative than an older
|
||||
# extracted clothing paragraph. This matters for characters such as
|
||||
# Lysandra, whose early armor description must not override the selected
|
||||
# forest-fighter design.
|
||||
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
|
||||
established = " ".join(filter(None, (
|
||||
_visual_prompt_value(sheet.get("visual_appearance") or sheet.get("physical")).lower(),
|
||||
(costume or _visual_prompt_value(sheet.get("clothing"))).lower(),
|
||||
)))
|
||||
return bool(re.search(
|
||||
# Do not treat German "hat" ("has") as the English noun "hat" —
|
||||
# that exact false positive let Gamba keep his modern felt hat.
|
||||
r"\b(headwear|hood|cap|helmet|coif|veil|turban|hut|m[üu]tze|kapuze|haube|helm|schleier)\b|"
|
||||
r"\b(?:wears?|wearing|with)\s+(?:an?\s+)?(?:[a-z-]+\s+){0,2}hat\b",
|
||||
established,
|
||||
))
|
||||
|
||||
|
||||
def _period_lock(book: str, book_profile: dict, sheet: dict) -> str:
|
||||
headwear = (
|
||||
"Use only the explicitly established period headwear and reproduce it unchanged in every view."
|
||||
if _character_has_established_headwear(sheet) else
|
||||
"The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed."
|
||||
)
|
||||
return (
|
||||
f"Book lock: {_book_visual_context(book, book_profile)}. "
|
||||
"Use museum-authentic Aventurian early-Renaissance c.1420-1500 wool, linen, leather, forged metal, hand stitching, period armor, tools, weapons, fastenings, and footwear. "
|
||||
"Garments use ties, lacing, toggles, hand-sewn buttons, hooks, or simple forged buckles; footwear uses stitched leather soles and period construction. "
|
||||
"Every visible design element is pre-industrial and period-authentic, with natural materials, hand-worked construction, flat stitched-leather soles, and age-appropriate wear. "
|
||||
f"{headwear}"
|
||||
)
|
||||
|
||||
|
||||
def _character_prop_item_list(sheet: dict) -> list[str]:
|
||||
"""Return zero to three verified, visually distinct portable items.
|
||||
|
||||
Companions, creatures and worn costume pieces belong neither in a prop
|
||||
callout nor in the identity pass. Empty slots stay empty: inventing a
|
||||
generic tool is worse than showing fewer props. Near-synonyms also share
|
||||
a category, so three sword variants cannot fill all three slots.
|
||||
"""
|
||||
explicit = sheet.get("visual_props")
|
||||
raw = explicit if isinstance(explicit, list) else sheet.get("inventory")
|
||||
values = raw if isinstance(raw, list) else re.split(r"[,;\n]+", str(raw or ""))
|
||||
excluded = re.compile(
|
||||
r"\b(location|room|place|viewpoint|observation point|residence|stairs?|tower|"
|
||||
r"post|outpost|station|ort|raum|platz|posten|beobachtungspunkt|beobachtungsposten|wendeltreppe|wohnort|turm|"
|
||||
r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|"
|
||||
r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|"
|
||||
r"hat|headwear|hood|cap|helmet|veil|cloak|coat|shirt|robe|armor set|armour set|"
|
||||
r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|kleidung|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
uncertain = re.compile(r"\b(implied|inferred|possible|impliziert|vermutlich|wahrscheinlich)\b", re.IGNORECASE)
|
||||
|
||||
def category(item: str) -> str:
|
||||
lower = item.casefold()
|
||||
groups = (
|
||||
("edged-weapon", r"\b(sword|blade|sabre|saber|rapier|dagger|knife|zweih[aä]nder)\b|(?:schwert|s[äa]bel|degen|dolch|messer)\b"),
|
||||
("ranged-weapon", r"\b(bow|crossbow|longbow|shortbow)\b|(?:bogen|armbrust)\b"),
|
||||
("staff", r"\b(staff|wand|rod|stave)\b|(?:stab|st[äa]be|rute)\b"),
|
||||
("key", r"\b(key|keys)\b|schl[üu]ssel\b"),
|
||||
("hammer", r"hammer\b"),
|
||||
("tongs", r"\btongs\b|zange(?:n)?\b"),
|
||||
("document", r"\b(scroll|map|book|letter|seal|document|rolle|karte|buch|brief|siegel)\b"),
|
||||
("vial", r"\b(vial|flask|bottle|phiole|fl[äa]schchen|flasche)\b"),
|
||||
("light", r"\b(torch|lantern|fackel|laterne)\b"),
|
||||
)
|
||||
for label, pattern in groups:
|
||||
if re.search(pattern, lower):
|
||||
return label
|
||||
# Unknown items remain eligible, but duplicate normalized wording is
|
||||
# still collapsed below.
|
||||
return re.sub(r"\W+", "-", lower).strip("-") or "item"
|
||||
|
||||
clean: list[str] = []
|
||||
seen_categories: set[str] = set()
|
||||
for value in values:
|
||||
item = _visual_prompt_value(value).strip(" .;,")
|
||||
item_category = category(item)
|
||||
if (item and not excluded.search(item) and not uncertain.search(item)
|
||||
and item.casefold() not in {v.casefold() for v in clean}
|
||||
and item_category not in seen_categories):
|
||||
clean.append(item)
|
||||
seen_categories.add(item_category)
|
||||
if len(clean) == 3:
|
||||
break
|
||||
return clean
|
||||
|
||||
|
||||
def _character_prop_items(sheet: dict) -> str:
|
||||
"""Backward-compatible text form used by prompt previews/tests."""
|
||||
return ", ".join(_character_prop_item_list(sheet))
|
||||
|
||||
|
||||
def _prop_render_description(item: str) -> str:
|
||||
"""Add an English silhouette lock to short, grounded prop labels."""
|
||||
value = _visual_prompt_value(item).strip()
|
||||
lowered = value.lower()
|
||||
rules = (
|
||||
(r"\bcrossbow\b|armbrust\b", "one complete early-Renaissance crossbow: a horizontal wooden bow mounted across a wooden stock, with visible string, nut, trigger, and stirrup"),
|
||||
(r"\b(longbow|shortbow|bow)\b|bogen\b", "one complete traditional wooden archery bow: one long curved wooden stave with one taut bowstring running continuously from tip to tip"),
|
||||
(r"\b(zweihänder|sword|blade)\b|schwert\b", "one complete forged sword with exactly one blade, one guard, one grip, and one pommel"),
|
||||
(r"\b(dagger|knife)\b|(?:dolch|messer)\b", "one complete short dagger with exactly one blade, one guard, one grip, and one pommel"),
|
||||
(r"\bkey\b|schlüssel\b", "one single large hand-forged iron key with one circular bow, one straight shaft, and one toothed bit"),
|
||||
(r"\b(staff|rod|wand)\b|stab\b", "one complete 180-centimeter ash-wood walking staff: one continuous straight round wooden shaft from its plain ferrule to one modest carved spiral handgrip, shown vertically at full length"),
|
||||
(r"hammer\b", "one complete blacksmith's forging hammer with one dark forged-iron hammer head fixed to one worn ash-wood handle"),
|
||||
(r"\b(tongs|pliers)\b|zange(?:n)?\b", "one complete pair of long blacksmith's forging tongs: two forged arms joined at one pivot with gripping jaws"),
|
||||
(r"\bhorseshoe\b|hufeisen\b", "one single U-shaped hand-forged iron horseshoe with evenly spaced nail holes"),
|
||||
(r"\b(rolle|schriftrolle|scroll|document|map|karte)\b", "one complete rolled parchment document tied with a plain cord, its surface blank and unlettered"),
|
||||
(r"\b(phiole|vial|fläschchen|bottle)\b", "one small complete period glass vial with a plain fitted stopper"),
|
||||
(r"\b(laterne|lantern|lamp)\b", "one complete early-Renaissance hand lantern of forged iron and translucent horn panels"),
|
||||
)
|
||||
for pattern, description in rules:
|
||||
if re.search(pattern, lowered, re.IGNORECASE):
|
||||
return f"{value} — {description}"
|
||||
return f"{value} — one complete portable object matching this exact noun, silhouette, and function"
|
||||
|
||||
|
||||
def _character_scene_evidence(sheet: dict) -> str:
|
||||
"""Choose one representative, source-grounded action for the lower panel."""
|
||||
override = _visual_prompt_value(sheet.get("visual_scene")).strip()
|
||||
if override:
|
||||
return override[:700]
|
||||
sources = sheet.get("sources") if isinstance(sheet.get("sources"), list) else []
|
||||
priorities = {
|
||||
"action": 12, "mannerisms": 10, "capabilities": 9,
|
||||
"conflict_style": 8, "communication_style": 7,
|
||||
"profession": 5, "motivation": 4,
|
||||
}
|
||||
action_words = re.compile(
|
||||
r"\b(said|spoke|shouted|ordered|fought|raised|led|watched|looked|signaled|"
|
||||
r"sprach|sagte|rief|schrie|befahl|k[äa]mpfte|hob|f[üu]hrte|blickte|beobachtete|winkte|griff|zog|st[üu]rzte|schlug)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
best: tuple[int, str] | None = None
|
||||
for source in sources:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
quote_text = _visual_prompt_value(source.get("quote")).strip()
|
||||
if not quote_text:
|
||||
continue
|
||||
hint = _visual_prompt_value(source.get("line_hint")).casefold()
|
||||
score = max((value for key, value in priorities.items() if key in hint), default=0)
|
||||
if action_words.search(quote_text):
|
||||
score += 3
|
||||
else:
|
||||
score -= 8
|
||||
if any(word in hint for word in ("physical", "inventory", "identity")):
|
||||
score -= 3
|
||||
candidate = (score, quote_text)
|
||||
if best is None or candidate[0] > best[0]:
|
||||
best = candidate
|
||||
if best:
|
||||
return best[1][:700]
|
||||
for key in ("mannerisms", "capabilities", "conflict_style", "profession"):
|
||||
value = _visual_prompt_value(sheet.get(key)).strip()
|
||||
if value:
|
||||
return value[:700]
|
||||
return "The character performs their established everyday role in a natural, in-character moment."
|
||||
|
||||
|
||||
def _build_character_portrait_prompt(name: str, book: str, sheet: dict, book_profile: dict,
|
||||
visual_style: str = "cinematic") -> str:
|
||||
# Tools belong in the equipment panel, not in the portrait identity
|
||||
# anchor. Including inventory here made the image model hang every tool
|
||||
# from the costume even when the composition explicitly requested empty
|
||||
# hands.
|
||||
facts = _character_visual_facts(name, sheet, include_items=False)
|
||||
if visual_style == "concept":
|
||||
style = (
|
||||
"detailed semi-realistic hand-painted fantasy production portrait from the same fixed studio artist as the cast sheets, "
|
||||
"with consistent facial construction, restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, "
|
||||
"and believable weathered materials; no photorealism, anime, cartoon, glossy 3D render, or mixed media"
|
||||
)
|
||||
else:
|
||||
style = (
|
||||
"photorealistic documentary-editorial historical fantasy photography from one fixed production photographer, "
|
||||
"with honest skin texture, natural hair, weathered period materials, restrained warm-neutral color science, "
|
||||
"subtle film grain, and no illustration, anime, painterly rendering, beauty retouching, plastic skin, or glossy game-render finish"
|
||||
)
|
||||
return (
|
||||
"Task: Generate one historical-fantasy audiobook cast portrait. "
|
||||
f"Subject: original fictional character — {facts}. "
|
||||
f"Context: cast portrait for {book or 'this audiobook'}; {_period_lock(book, book_profile, sheet)} "
|
||||
f"Style: {style}. "
|
||||
"Composition: one person, chest-up square at eye level, 70 mm, calm three-quarter gaze, warm-gray studio background, face unobstructed. "
|
||||
"Lighting: fixed soft north-window key camera-right, restrained fill and rim, natural shadows, no color cast. "
|
||||
"Constraints: identity, gender, age, species, occupation, clothing, and marks must be unmistakable; no extra people, text, logos, watermark, modern styling, or real-person likeness. "
|
||||
"Output: production-ready 1024x1024 portrait, safe for a circular crop."
|
||||
)
|
||||
|
||||
|
||||
def _build_character_sheet_prompt(name: str, book: str, sheet: dict, book_profile: dict,
|
||||
visual_style: str = "concept") -> str:
|
||||
facts = _character_visual_facts(name, sheet)
|
||||
if visual_style == "cinematic":
|
||||
style = (
|
||||
"photorealistic historical-fantasy costume-continuity photography from the same fixed production photographer as the cast portraits, "
|
||||
"with a real performer, honest skin texture, restrained warm-neutral color science, grounded anatomy, and physically believable "
|
||||
"weathered wool, linen, leather, wood, and forged metal; no illustration, anime, cartoon, glossy 3D render, photo collage, or mixed media"
|
||||
)
|
||||
else:
|
||||
style = (
|
||||
"detailed semi-realistic hand-painted fantasy production concept art from one fixed studio artist, with consistent facial construction, "
|
||||
"restrained natural earth-and-jewel colors, clear shape language, grounded anatomy, and believable weathered wool, linen, leather, wood, "
|
||||
"and forged metal; no anime, cartoon, glossy 3D render, photo collage, or mixed media"
|
||||
)
|
||||
return (
|
||||
"Task: Generate one production character turnaround and equipment reference sheet. "
|
||||
f"Subject: the same original fictional character — {facts}. "
|
||||
f"Context: costume and continuity handoff for {book or 'this audiobook'}; {_period_lock(book, book_profile, sheet)} "
|
||||
f"Style: {style}. "
|
||||
"Composition: clean production board; three equal-scale, fully visible full-body orthographic views across the top: strict front, true left profile, strict back, same neutral stance. "
|
||||
"Below, one source-grounded story moment showing the character acting, speaking, working, or fighting as they do in the book, plus zero to three verified portable items; each item is visually distinct, complete, shown once, assembled, isolated, and unlabeled. Empty prop slots remain empty. "
|
||||
"Lighting: identical neutral museum-soft light, gentle contact shadows, no color cast. "
|
||||
"Constraints: identical face, age, species, proportions, silhouette, garment seams, palette, and wear in every view; inventory never implies that armor or clothing is worn; no invented or duplicate gear, no repeated weapon category, no disassembled/floating fragments, extra limbs, generated text, logos, or watermark. "
|
||||
"Output: crisp production-ready square continuity sheet assembled from individually controlled panels."
|
||||
)
|
||||
|
||||
|
||||
def _saved_prompt_subject(prompt: str, fallback: str, limit: int = 1100) -> str:
|
||||
"""Keep user edits to the Subject clause without re-sending a long master.
|
||||
|
||||
Layout/style/period are application invariants; the editable Subject is
|
||||
the character-specific portion users reasonably need to refine.
|
||||
"""
|
||||
match = re.search(r"\bSubject:\s*(.*?)(?=\s+Context:)", prompt or "", flags=re.DOTALL | re.IGNORECASE)
|
||||
value = (match.group(1).strip() if match else "") or fallback
|
||||
if len(value) > limit:
|
||||
value = value[:limit].rsplit(" ", 1)[0].rstrip(" ,;:") + "…"
|
||||
return value
|
||||
|
||||
|
||||
def _positive_conditioning_text(value: str) -> str:
|
||||
"""Remove negated visual concepts before positive image conditioning.
|
||||
|
||||
Qwen's positive encoder can visualize nouns even when they occur after
|
||||
words such as ``no``, ``without`` or ``ohne``. Structured positive facts
|
||||
remain authoritative; exclusions belong in the workflow's negative
|
||||
conditioning.
|
||||
"""
|
||||
value = re.sub(
|
||||
r"\b(?:no|without|never|not\s+wearing|do\s+not|don't|"
|
||||
r"kein(?:e|en|em|er|es)?|ohne)\b[^;,.·]*",
|
||||
"",
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
value = re.sub(
|
||||
r"\b(?:absolutely\s+)?bareheaded\b",
|
||||
"with the complete natural hairstyle visible",
|
||||
value,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
value = re.sub(r"\s+([,;.])", r"\1", value)
|
||||
value = re.sub(r"([,;])\s*([,;])", r"\1", value)
|
||||
return re.sub(r"\s{2,}", " ", value).strip(" ,;")
|
||||
|
||||
|
||||
def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
book_profile: dict, visual_style: str,
|
||||
saved_profile: str, saved_reference: str) -> tuple[str, str, str, str, str, list[str]]:
|
||||
"""Short, layout-first prompts for the local Qwen/Flux encoder.
|
||||
|
||||
The detailed masters remain visible/editable in the UI. These render
|
||||
prompts avoid truncating the constraints and stop the edit model turning
|
||||
a requested turnaround into another portrait poster.
|
||||
"""
|
||||
facts = _character_visual_facts(name, sheet, include_items=False)
|
||||
portrait_subject = _saved_prompt_subject(saved_profile, facts)
|
||||
sheet_subject = _saved_prompt_subject(saved_reference, facts)
|
||||
# Standard generated prompts append inventory as a final labelled clause.
|
||||
# Strip that clause from the shared person/costume source; the dedicated
|
||||
# equipment pass below remains the sole place where those objects appear.
|
||||
portrait_subject = re.sub(
|
||||
r"\s*;\s*(?:signature items|verified portable items)\s*:\s*.*$", "", portrait_subject,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
).strip()
|
||||
sheet_subject = re.sub(
|
||||
r"\s*;\s*(?:signature items|verified portable items)\s*:\s*.*$", "", sheet_subject,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
).strip()
|
||||
# A saved v3 Subject is the user's editable character direction. Do not
|
||||
# silently replace its carefully chosen visual role with an extracted
|
||||
# profession token (for example "Vampir" or "Inquisitor"), because those
|
||||
# broad labels strongly trigger generic costume stereotypes. The
|
||||
# structured fallback already uses _primary_visual_role when no edited
|
||||
# Subject is available.
|
||||
props = _character_prop_item_list(sheet)
|
||||
scene_evidence = _positive_conditioning_text(_character_scene_evidence(sheet))
|
||||
has_headwear = _character_has_established_headwear(sheet)
|
||||
portrait_subject = _positive_conditioning_text(portrait_subject)
|
||||
sheet_subject = _positive_conditioning_text(sheet_subject)
|
||||
no_headwear = (
|
||||
"Use only the explicitly established period headwear."
|
||||
if has_headwear else
|
||||
"The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed."
|
||||
)
|
||||
bareheaded = not has_headwear
|
||||
head_lock = (
|
||||
"HEAD CONTINUITY: In all three figures, clearly show the exposed scalp, crown, hairline, ears, and complete natural hairstyle in front, profile, and rear views. "
|
||||
if bareheaded else
|
||||
"HEADWEAR LOCK: reproduce only the explicitly established period headwear, identically in all three views. "
|
||||
)
|
||||
period = (
|
||||
f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. Aventurian early Renaissance c.1420-1500 only: "
|
||||
"wool, linen, leather, hand-worked fastenings, restrained forged-metal details, and flat stitched-leather footwear. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. "
|
||||
f"{no_headwear}"
|
||||
)
|
||||
if visual_style == "concept":
|
||||
portrait_style = "One fixed semi-realistic hand-painted fantasy production artist with grounded anatomy and weathered materials."
|
||||
sheet_style = portrait_style
|
||||
else:
|
||||
portrait_style = "Strict live-action photorealistic historical-fantasy production photography; real actor, honest skin, natural hair, restrained warm-neutral film color."
|
||||
sheet_style = "Strict live-action photorealistic costume-continuity studio photography by that same production photographer, with a real actor and physically believable period materials."
|
||||
portrait = (
|
||||
("HEAD CONTINUITY: Clearly show the exposed scalp, crown, hairline, ears, and complete natural hairstyle. " if bareheaded else "")
|
||||
+ "CREATE ONE VERTICAL FULL-BODY CHARACTER IDENTITY. "
|
||||
f"CHARACTER: {portrait_subject}. {period} {portrait_style} "
|
||||
"EXACTLY ONE ADULT PERSON. Interpret all metaphors, memories, relationships, titles, and story actions only as character direction. The background is completely empty. "
|
||||
"Complete head-to-boots silhouette, relaxed front three-quarter stance, eye level, 50mm, plain uniform warm-white seamless studio background, fixed soft north-window key camera-left. "
|
||||
"Occupation is immediately obvious from the costume and material wear alone. Show occupational work clothing only. Both hands are empty; the belt, back, and clothing are clear of carried equipment. Face unobstructed. "
|
||||
"Leave generous empty space above the hair and around the complete body. Keep the complete head, hair, hands, and feet fully inside frame. Output 1024x1536."
|
||||
)
|
||||
# Generate the three orthographic views together in one T2I frame. The
|
||||
# edit model preserved a source portrait too literally: it frequently
|
||||
# returned three frontal portraits and even copied a stick-figure pose
|
||||
# guide into the costume. A single native triptych gives Qwen explicit
|
||||
# spatial slots while keeping one face/costume interpretation across all
|
||||
# three views. The app then crops the fixed thirds and assembles them.
|
||||
front_view = (
|
||||
f"{head_lock}CREATE ONE CLEAN THREE-PANEL ORTHOGRAPHIC CHARACTER TURNAROUND, WITH EXACTLY THREE FULL-BODY FIGURES IN ONE HORIZONTAL ROW. "
|
||||
f"EXACT CHARACTER: {sheet_subject}. {period} {sheet_style} "
|
||||
"LEFT THIRD: one complete full-body strict front view, shoulders hips knees and feet square to camera. "
|
||||
"CENTER THIRD: one complete full-body strict side view with the entire body rotated exactly 90 degrees toward image-left. "
|
||||
"RIGHT THIRD: one complete full-body strict rear view looking directly away, showing the back of head, back garment seams, backs of legs and heels. "
|
||||
"All three figures are the identical adult person with identical face, species, age, body proportions, hair, facial hair, costume construction, seams, palette, material wear, and footwear. Keep the exact anatomically established facial structure and species traits from Subject in every view. Use the same full-length 50mm camera distance in every panel. Align all three crowns and all three soles horizontally; each complete head-to-boots silhouette occupies the same height and remains centered in its equal-width third. Use a neutral stance, relaxed arms, visibly empty hands, unobstructed belts, and unobstructed backs. "
|
||||
"Plain empty warm-white seamless studio background, neutral museum-soft light, soft contact shadows. Keep generous margin above every head and below every foot. The frame contains exactly the three required figures and the plain seamless background. Keep every surface unlettered and free of graphic overlays. Output 1536x1024."
|
||||
)
|
||||
side_view = (
|
||||
"Unused: the side view is generated in the controlled turnaround triptych."
|
||||
)
|
||||
back_view = (
|
||||
"Unused: the rear view is generated in the controlled turnaround triptych."
|
||||
)
|
||||
scene_prompt = (
|
||||
("HEAD CONTINUITY: Every visible person's scalp, crown, hairline, ears, and natural hairstyle are clearly visible and unobstructed. " if bareheaded else "")
|
||||
+ "CREATE ONE HORIZONTAL PHOTOREALISTIC SOURCE-GROUNDED STORY MOMENT WITH NATURAL ACTION. "
|
||||
f"EXACT CHARACTER: {sheet_subject}. "
|
||||
f"BOOK-SOURCE EVIDENCE: {scene_evidence}. "
|
||||
"Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor. Preserve the stated face, age, species, build, hair, costume, materials, and period. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. "
|
||||
f"{period} {sheet_style} Use a believable location from the book, restrained cinematic staging, and complete uncropped head and hands. Keep all surfaces unlettered and free of graphic overlays. Output 768x512."
|
||||
)
|
||||
prop_prompts = [
|
||||
(
|
||||
"CREATE ONE SINGLE ISOLATED MUSEUM-CATALOG OBJECT PHOTOGRAPH. "
|
||||
f"OBJECT IDENTITY — FOLLOW LITERALLY: {_prop_render_description(item)}. Show exactly one complete assembled object, fully visible and centered on a plain uniform warm-white sweep with at least fifteen percent empty margin on every side. "
|
||||
f"{period} Use neutral museum-soft product lighting and physically believable, hand-worked period materials. The frame is an uncluttered object-only catalog plate containing a single centered object and empty space around it. Keep every surface unlettered and free of graphic overlays. Output 512x512."
|
||||
)
|
||||
for item in props
|
||||
]
|
||||
return portrait, front_view, side_view, back_view, scene_prompt, prop_prompts
|
||||
|
||||
|
||||
@router.post("/api/character-generate-prompts")
|
||||
async def character_generate_prompts(request: Request):
|
||||
"""Turn an already-extracted character sheet into four ready-to-use external
|
||||
@ -1262,6 +1740,11 @@ async def character_generate_prompts(request: Request):
|
||||
name: str = (data.get("name") or "").strip()
|
||||
book: str = (data.get("book") or "").strip()
|
||||
sheet: dict = data.get("sheet") or {}
|
||||
book_profile: dict = data.get("book_profile") or {}
|
||||
voice_feedback: str = (data.get("voice_feedback") or "").strip()
|
||||
visual_style: str = (data.get("visual_style") or "cinematic").strip().lower()
|
||||
if visual_style not in {"cinematic", "concept"}:
|
||||
visual_style = "cinematic"
|
||||
language: str = (data.get("language") or "").strip()
|
||||
_settings = _load_settings()
|
||||
llm_url: str = (data.get("llm_url") or _settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
|
||||
@ -1284,6 +1767,19 @@ async def character_generate_prompts(request: Request):
|
||||
("Secret/flaw", _f("secret")), ("Inventory", _f("inventory")),
|
||||
] if v)
|
||||
|
||||
if voice_feedback:
|
||||
profile += "\nPrevious voice review feedback — correct these issues explicitly: " + voice_feedback
|
||||
feedback_lower = voice_feedback.lower()
|
||||
hard_corrections = []
|
||||
if "us accent" in feedback_lower or "american" in feedback_lower:
|
||||
hard_corrections.append("HARD CORRECTION: use an authentic native German accent with zero American or English accent coloring")
|
||||
if "female" in feedback_lower and ("male" in feedback_lower or "not female" in feedback_lower):
|
||||
hard_corrections.append("HARD CORRECTION: the result must unmistakably sound female, never male or androgynous")
|
||||
elif "male" in feedback_lower and ("female" in feedback_lower or "not male" in feedback_lower):
|
||||
hard_corrections.append("HARD CORRECTION: the result must unmistakably sound male, never female or androgynous")
|
||||
if hard_corrections:
|
||||
profile += "\n" + "\n".join(hard_corrections)
|
||||
|
||||
lang_note = f" Write every prompt in {language} EXCEPT where told to use English." if language else ""
|
||||
preamble = (
|
||||
"You are a prompt engineer who turns a fiction character's profile into ready-to-paste prompts for "
|
||||
@ -1296,7 +1792,13 @@ async def character_generate_prompts(request: Request):
|
||||
"weapons, or props. Never add lifestyle items, underwear, cosmetics, brands, or extra gear unless the profile "
|
||||
"explicitly establishes them.\n\n"
|
||||
)
|
||||
user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\n"
|
||||
context = ", ".join(str(book_profile.get(k) or "").strip() for k in ("genre", "setting", "era") if str(book_profile.get(k) or "").strip())
|
||||
context_block = (
|
||||
f"BOOK PRODUCTION CONTEXT: {context}\n"
|
||||
"Treat this as a hard continuity constraint. Clothing, uniforms, hats, tools, architecture, materials, and props must belong to this period and setting. Do not introduce modern or 19th/20th-century items unless the book context explicitly says so.\n\n"
|
||||
if context else ""
|
||||
)
|
||||
user = f"BOOK: {book or 'unspecified'}\n\n{context_block}CHARACTER PROFILE:\n{profile or name}\n\n"
|
||||
visual_identity_rule = (
|
||||
"VISUAL IDENTITY LOCK: Treat the profile as a production design bible. "
|
||||
"Gender is mandatory and must be stated explicitly; never reinterpret a male character as female or vice versa. "
|
||||
@ -1330,7 +1832,7 @@ async def character_generate_prompts(request: Request):
|
||||
"deliberate pauses before key words), and a specific emotional baseline drawn from THIS character's "
|
||||
"own personality/backstory (guarded warmth, brittle confidence, weary sarcasm — not just 'friendly' "
|
||||
"or 'energetic'). Two characters with the same age and gender in your profile should still end up "
|
||||
"with visibly different prompts once you've done this. Do not mention plot events — describe only "
|
||||
"with visibly different prompts once you've done this. If previous voice review feedback appears in the profile, treat every item as a hard correction and make the new prompt audibly address it. Do not mention plot events — describe only "
|
||||
"how the voice should SOUND.\n\n"
|
||||
'Respond with STRICT JSON only: {"voice_design_prompt":""}/no-think'
|
||||
),
|
||||
@ -1345,11 +1847,11 @@ async def character_generate_prompts(request: Request):
|
||||
" 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"
|
||||
" Context: a believable live-action character in the book's established original world and era; if the book context says medieval, use historically plausible medieval fantasy clothing and materials only.\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"
|
||||
" Constraints: preserve the exact gender, age impression, species traits, face, hairstyle, garment layers and signature details established in the profile; no modern shirts, jeans, business suits, neckties, wristwatches, firearms, zippers, synthetic sportswear, 1950s hats, or other anachronistic items unless explicitly established by the book; 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'
|
||||
),
|
||||
@ -1359,22 +1861,31 @@ 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 — Subject, Context, Style, "
|
||||
"reference sheet, written as labelled clauses in this EXACT order — Task, Subject, Context, Style, "
|
||||
"Composition, Lighting, Constraints, Output — each a single sentence, all as one dense paragraph "
|
||||
"(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"
|
||||
" Task: create a three-view character continuity sheet with a source-grounded story moment and optional verified prop callouts.\n"
|
||||
" Subject: write an original adult [EXACT GENDER] [role/archetype] using ONLY established profile content. Include age, species, face, skin, hair, eyes, facial hair, marks, build, and established worn clothing. Inventory does NOT prove that clothing or armor is worn. Include only portable props explicitly established by the profile; omit uncertain and inferred objects. 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: aligned full-body strict front, true left-side profile, and strict back views across the top; below them, one representative action/dialogue/work scene supported by the book evidence and zero to three isolated portable props. Keep empty prop slots empty. Use generous safe margins and keep every head, foot, hand, and object 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, species, face identity anchor, body proportions, silhouette, garment seams, colors, and wear in every view; each prop appears once; choose at most one item from each visual category so several swords or near-identical tools cannot fill the row; no invented gear, duplicate gear, disassembled fragments, floating objects, extra limbs, generated text, logos, real-world emblems, franchise references, or watermark.\n"
|
||||
" Output: one organized, production-ready square character reference sheet assembled from separately controlled panels.\n\n"
|
||||
'Respond with STRICT JSON only: {"concept_art_prompt":""}/no-think'
|
||||
),
|
||||
}
|
||||
requested = [f for f in (data.get("fields") or []) if f in all_groups]
|
||||
field_groups = [((f,), all_groups[f]) for f in (requested or all_groups.keys())]
|
||||
wanted = requested or list(all_groups.keys())
|
||||
# Camera, lighting, layout, artist and period are fixed across the cast;
|
||||
# only structured character facts vary. This is intentionally
|
||||
# deterministic instead of asking the LLM to redesign each production.
|
||||
deterministic = {}
|
||||
if "image_prompt" in wanted:
|
||||
deterministic["image_prompt"] = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style)
|
||||
if "concept_art_prompt" in wanted:
|
||||
deterministic["concept_art_prompt"] = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style)
|
||||
field_groups = [((f,), all_groups[f]) for f in wanted if f not in deterministic]
|
||||
# The SillyTavern card is a full structured card + scenario + first
|
||||
# message now, not a few short labelled lines — needs a much bigger
|
||||
# budget than the other three (single-paragraph) prompt fields or it
|
||||
@ -1425,7 +1936,7 @@ async def character_generate_prompts(request: Request):
|
||||
results = await asyncio.gather(*[
|
||||
asyncio.to_thread(_call_group, fields, system) for fields, system in field_groups
|
||||
])
|
||||
out = {}
|
||||
out = dict(deterministic)
|
||||
for (fields, _), result in zip(field_groups, results):
|
||||
for f in fields:
|
||||
out[f] = str(result.get(f) or "").strip()
|
||||
@ -1586,7 +2097,8 @@ def _apply_character_image_rendering_style(prompt: str, image_kind: str) -> str:
|
||||
|
||||
|
||||
def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
|
||||
prompt_field: str, output_node_id: str, prompt: str) -> str:
|
||||
prompt_field: str, output_node_id: str, prompt: str,
|
||||
seed_key: str = "") -> str:
|
||||
"""Submits a pre-exported ComfyUI API-format workflow with the given
|
||||
prompt text injected into one designated node/field, waits for it to
|
||||
finish, and returns the resulting image as a data: URI.
|
||||
@ -1611,6 +2123,19 @@ def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
|
||||
|
||||
graph = copy.deepcopy(workflow)
|
||||
graph[prompt_node_id].setdefault("inputs", {})[prompt_field or "text"] = prompt
|
||||
# Bundled diffusion graphs used to ship with seed 0 for every character.
|
||||
# Combined with recurring fantasy terms, that repeatedly selected the
|
||||
# same unwanted broad-brimmed-hat composition across the cast. Derive a
|
||||
# stable prompt-specific seed instead: regenerating an unchanged prompt
|
||||
# remains reproducible while different identities no longer share one
|
||||
# latent stereotype.
|
||||
prompt_seed = int.from_bytes(
|
||||
hashlib.sha256(("voice-creator-image-v5|" + (seed_key or prompt)).encode("utf-8")).digest()[:8],
|
||||
"big",
|
||||
)
|
||||
for node in graph.values():
|
||||
if node.get("class_type") == "KSampler" and "seed" in node.get("inputs", {}):
|
||||
node["inputs"]["seed"] = prompt_seed
|
||||
client_id = str(uuid.uuid4())
|
||||
base = comfyui_url.rstrip("/")
|
||||
|
||||
@ -1675,6 +2200,261 @@ def _comfyui_run(comfyui_url: str, workflow_json: str, prompt_node_id: str,
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
|
||||
def _comfyui_upload_data_uri(comfyui_url: str, data_uri: str) -> str:
|
||||
"""Upload a browser-stored identity image into ComfyUI's input folder."""
|
||||
if not data_uri or not data_uri.startswith("data:"):
|
||||
raise HTTPException(400, "A valid identity image is required")
|
||||
try:
|
||||
header, encoded = data_uri.split(",", 1)
|
||||
raw = base64.b64decode(encoded)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Identity image is not valid base64: {exc}")
|
||||
mime = header.split(";", 1)[0].split(":", 1)[-1].lower()
|
||||
ext = ".jpg" if mime in {"image/jpeg", "image/jpg"} else ".webp" if mime == "image/webp" else ".png"
|
||||
filename = f"voice_creator_identity_{uuid.uuid4().hex}.png"
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{comfyui_url.rstrip('/')}/upload/image",
|
||||
files={"image": (filename, raw, mime or "image/png")},
|
||||
data={"overwrite": "true", "type": "input"},
|
||||
timeout=60,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise HTTPException(resp.status_code, f"ComfyUI rejected the identity image: {resp.text[:400]}")
|
||||
result = resp.json()
|
||||
return str(result.get("name") or filename)
|
||||
except HTTPException:
|
||||
raise
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise HTTPException(502, f"Cannot upload the identity image to ComfyUI: {exc}")
|
||||
|
||||
|
||||
def _pose_guide_data_uri(view: str) -> str:
|
||||
"""Create a neutral mannequin guide for Qwen's second reference image.
|
||||
|
||||
Text-only camera directions were not reliable enough: a front-facing
|
||||
identity frequently stayed front-facing even when the prompt requested a
|
||||
side or rear view. The guide carries orientation only; the first Qwen
|
||||
image remains the identity/costume authority.
|
||||
"""
|
||||
width, height = 512, 1024
|
||||
image = Image.new("RGB", (width, height), (245, 241, 233))
|
||||
draw = ImageDraw.Draw(image)
|
||||
ink = (76, 79, 82)
|
||||
shade = (151, 155, 158)
|
||||
floor = (205, 199, 188)
|
||||
draw.line((74, 930, 438, 930), fill=floor, width=4)
|
||||
if view == "side":
|
||||
draw.ellipse((222, 72, 282, 162), fill=ink)
|
||||
draw.polygon([(222, 105), (205, 115), (222, 126)], fill=ink) # nose points left
|
||||
draw.rectangle((238, 154, 268, 194), fill=ink)
|
||||
draw.rounded_rectangle((215, 185, 285, 565), radius=30, fill=shade, outline=ink, width=5)
|
||||
draw.rounded_rectangle((216, 220, 246, 610), radius=14, fill=ink)
|
||||
draw.rounded_rectangle((252, 220, 282, 610), radius=14, fill=(105, 108, 111))
|
||||
draw.rounded_rectangle((220, 545, 252, 900), radius=15, fill=ink)
|
||||
draw.rounded_rectangle((255, 545, 287, 900), radius=15, fill=(105, 108, 111))
|
||||
draw.ellipse((192, 884, 252, 924), fill=ink)
|
||||
draw.ellipse((226, 884, 288, 924), fill=(105, 108, 111))
|
||||
else:
|
||||
draw.ellipse((211, 72, 301, 162), fill=ink)
|
||||
draw.rectangle((233, 154, 279, 200), fill=ink)
|
||||
draw.polygon([(190, 195), (322, 195), (302, 570), (210, 570)], fill=shade, outline=ink)
|
||||
draw.rounded_rectangle((145, 205, 205, 625), radius=25, fill=ink)
|
||||
draw.rounded_rectangle((307, 205, 367, 625), radius=25, fill=ink)
|
||||
draw.rounded_rectangle((206, 548, 252, 900), radius=20, fill=ink)
|
||||
draw.rounded_rectangle((260, 548, 306, 900), radius=20, fill=ink)
|
||||
draw.ellipse((170, 880, 252, 925), fill=ink)
|
||||
draw.ellipse((260, 880, 342, 925), fill=ink)
|
||||
if view == "front":
|
||||
draw.ellipse((232, 108, 240, 116), fill=(230, 230, 230))
|
||||
draw.ellipse((272, 108, 280, 116), fill=(230, 230, 230))
|
||||
else: # rear guide: visible center-back seam, no facial marks
|
||||
draw.line((256, 198, 256, 565), fill=ink, width=5)
|
||||
out = io.BytesIO()
|
||||
image.save(out, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def _contain_image(raw: bytes, width: int, height: int, padding: int = 12) -> Image.Image:
|
||||
panel = Image.new("RGB", (width, height), (246, 242, 234))
|
||||
with Image.open(io.BytesIO(raw)) as source:
|
||||
fitted = ImageOps.contain(source.convert("RGB"), (max(1, width - 2 * padding), max(1, height - 2 * padding)), Image.Resampling.LANCZOS)
|
||||
panel.paste(fitted, ((width - fitted.width) // 2, (height - fitted.height) // 2))
|
||||
return panel
|
||||
|
||||
|
||||
def _compose_character_reference(images: dict[str, bytes], prop_count: int) -> str:
|
||||
"""Assemble panels without cropping or asking the model to draw a layout."""
|
||||
canvas = Image.new("RGB", (1536, 1536), (246, 242, 234))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
with Image.open(io.BytesIO(images["turnaround"])) as source:
|
||||
turnaround = ImageOps.fit(source.convert("RGB"), (1536, 1024), Image.Resampling.LANCZOS)
|
||||
# The model receives an explicit equal-thirds triptych. Crop only those
|
||||
# fixed panel boundaries; every generated person must already fit inside
|
||||
# their third with safe head/foot margins.
|
||||
for index in range(3):
|
||||
raw_panel = turnaround.crop((index * 512, 0, (index + 1) * 512, 1024))
|
||||
buffer = io.BytesIO()
|
||||
raw_panel.save(buffer, format="PNG")
|
||||
panel = _contain_image(buffer.getvalue(), 512, 1024, 8)
|
||||
canvas.paste(panel, (index * 512, 0))
|
||||
|
||||
if prop_count == 0:
|
||||
scene_width = 1536
|
||||
elif prop_count == 1:
|
||||
scene_width = 1024
|
||||
else:
|
||||
scene_width = 768
|
||||
scene = _contain_image(images["scene"], scene_width, 512, 12)
|
||||
canvas.paste(scene, (0, 1024))
|
||||
remaining = 1536 - scene_width
|
||||
if prop_count:
|
||||
tile_width = remaining // prop_count
|
||||
for index in range(prop_count):
|
||||
width = remaining - tile_width * index if index == prop_count - 1 else tile_width
|
||||
panel = _contain_image(images[f"prop{index + 1}"], width, 512, 18)
|
||||
x = scene_width + tile_width * index
|
||||
canvas.paste(panel, (x, 1024))
|
||||
draw.line((x, 1024, x, 1536), fill=(205, 198, 186), width=3)
|
||||
draw.line((0, 1024, 1536, 1024), fill=(205, 198, 186), width=4)
|
||||
draw.line((512, 0, 512, 1024), fill=(218, 212, 202), width=2)
|
||||
draw.line((1024, 0, 1024, 1024), fill=(218, 212, 202), width=2)
|
||||
out = io.BytesIO()
|
||||
canvas.save(out, format="PNG", optimize=True)
|
||||
return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str,
|
||||
identity_image: str, profile_prompt: str,
|
||||
front_prompt: str, side_prompt: str,
|
||||
back_prompt: str, scene_prompt: str,
|
||||
prop_prompts: list[str], style_key: str = "") -> dict:
|
||||
"""Generate independently controlled views, scene and unique prop tiles."""
|
||||
try:
|
||||
workflow = json.loads(workflow_json)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Consistent-image workflow isn't valid JSON: {exc}")
|
||||
graph = copy.deepcopy(workflow)
|
||||
graph["4"].setdefault("inputs", {})["image"] = _comfyui_upload_data_uri(comfyui_url, identity_image)
|
||||
graph["66"].setdefault("inputs", {})["text"] = front_prompt
|
||||
graph["70"].setdefault("inputs", {})["text"] = scene_prompt
|
||||
# Disconnect the failed image-edit camera branches. Structural camera and
|
||||
# action changes are handled by native T2I branches below.
|
||||
for old_output in ("front_save", "side_save", "back_save", "scene_save"):
|
||||
graph.pop(old_output, None)
|
||||
prop_prompts = prop_prompts[:3]
|
||||
for index, (prompt_node, save_node) in enumerate((("23", "prop1_save"), ("50", "prop2_save"), ("54", "prop3_save"))):
|
||||
if index < len(prop_prompts):
|
||||
graph[prompt_node].setdefault("inputs", {})["text"] = prop_prompts[index]
|
||||
else:
|
||||
# A disconnected branch is not executed by ComfyUI. Empty slots
|
||||
# therefore stay truly empty instead of becoming invented props.
|
||||
graph.pop(save_node, None)
|
||||
# Stable view-specific seeds keep the cast reproducible without forcing
|
||||
# front, side and rear edits toward the same latent camera pose.
|
||||
if style_key:
|
||||
digest = hashlib.sha256(style_key.encode("utf-8")).digest()
|
||||
shared_seed = int.from_bytes(digest[:8], "big")
|
||||
else:
|
||||
shared_seed = int(uuid.uuid4().int % 18446744073709551615)
|
||||
def view_seed(label: str) -> int:
|
||||
return int.from_bytes(
|
||||
hashlib.sha256(f"{shared_seed}|{label}".encode("utf-8")).digest()[:8],
|
||||
"big",
|
||||
)
|
||||
graph["68"].setdefault("inputs", {})["seed"] = view_seed("identity-turnaround")
|
||||
graph["72"].setdefault("inputs", {})["seed"] = view_seed("source-scene")
|
||||
for node_id, label in (("26", "prop-1"), ("52", "prop-2"), ("56", "prop-3")):
|
||||
graph[node_id].setdefault("inputs", {})["seed"] = view_seed(label)
|
||||
client_id = str(uuid.uuid4())
|
||||
base = comfyui_url.rstrip("/")
|
||||
try:
|
||||
resp = requests.post(f"{base}/prompt", json={"prompt": graph, "client_id": client_id}, timeout=30)
|
||||
if not resp.ok:
|
||||
detail = resp.text[:500]
|
||||
try:
|
||||
detail = resp.json().get("error", {}).get("message", detail)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(resp.status_code, f"ComfyUI rejected the consistent-image workflow: {detail}")
|
||||
prompt_id = resp.json().get("prompt_id")
|
||||
if not prompt_id:
|
||||
raise HTTPException(502, "ComfyUI didn't return a prompt_id")
|
||||
except HTTPException:
|
||||
raise
|
||||
except requests.exceptions.RequestException as exc:
|
||||
raise HTTPException(502, f"Cannot reach ComfyUI at {base}: {exc}")
|
||||
|
||||
# Qwen Image Edit runs the profile and the larger 3:2 sheet serially on
|
||||
# this local GPU. At the current model/runtime speed a pair can exceed
|
||||
# ten minutes; keep the HTTP request alive so a successful Comfy job is
|
||||
# not reported as a misleading 502 just after it finishes.
|
||||
deadline = time.time() + 1800
|
||||
history = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
hr = requests.get(f"{base}/history/{prompt_id}", timeout=10)
|
||||
if hr.ok:
|
||||
data = hr.json()
|
||||
if prompt_id in data:
|
||||
history = data[prompt_id]
|
||||
status = history.get("status", {})
|
||||
if status.get("completed") is True or status.get("status_str") == "success":
|
||||
break
|
||||
if status.get("status_str") == "error":
|
||||
msgs = status.get("messages", [])
|
||||
raise HTTPException(502, f"ComfyUI consistent-image generation failed: {msgs[-1] if msgs else 'unknown error'}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
if not history:
|
||||
raise HTTPException(504, "ComfyUI consistent-image generation timed out after 30 minutes")
|
||||
|
||||
raw_images: dict[str, bytes] = {}
|
||||
output_nodes = [
|
||||
("profile", "profile_save"),
|
||||
("turnaround", "turnaround_save"),
|
||||
("scene", "scene_t2i_save"),
|
||||
] + [(f"prop{index + 1}", f"prop{index + 1}_save") for index in range(len(prop_prompts))]
|
||||
result: dict[str, str] = {}
|
||||
for key, node_id in output_nodes:
|
||||
images = (history.get("outputs", {}).get(node_id, {}) or {}).get("images") or []
|
||||
if not images:
|
||||
raise HTTPException(502, f"ComfyUI output '{node_id}' produced no image")
|
||||
img = images[0]
|
||||
try:
|
||||
vr = requests.get(f"{base}/view", params={
|
||||
"filename": img.get("filename", ""), "subfolder": img.get("subfolder", ""),
|
||||
"type": img.get("type", "output"),
|
||||
}, timeout=30)
|
||||
vr.raise_for_status()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Fetching the ComfyUI {key} image failed: {exc}")
|
||||
raw_images[key] = vr.content
|
||||
if key == "profile":
|
||||
result["profile"] = f"data:{vr.headers.get('Content-Type', 'image/png')};base64,{base64.b64encode(vr.content).decode('ascii')}"
|
||||
result["reference"] = _compose_character_reference(raw_images, len(prop_prompts))
|
||||
return result
|
||||
|
||||
|
||||
def _bundled_consistent_comfyui_workflow() -> str:
|
||||
path = Path(__file__).resolve().parent.parent / "workflows" / "comfyui-character-consistent-images.json"
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise HTTPException(500, f"Bundled consistent-image workflow is unavailable: {exc}")
|
||||
|
||||
|
||||
def _bundled_character_identity_workflow() -> str:
|
||||
path = Path(__file__).resolve().parent.parent / "workflows" / "comfyui-character-identity-v3.json"
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise HTTPException(500, f"Bundled character-identity workflow is unavailable: {exc}")
|
||||
|
||||
|
||||
@router.post("/api/character-image-from-url")
|
||||
async def character_image_from_url(request: Request):
|
||||
"""Download an image from a user-supplied URL server-side and return it
|
||||
@ -1743,6 +2523,87 @@ async def character_generate_image(request: Request):
|
||||
return await asyncio.to_thread(_character_generate_image_sync, provider, model, prompt, keys, _settings, image_kind)
|
||||
|
||||
|
||||
@router.post("/api/character-generate-consistent-images")
|
||||
async def character_generate_consistent_images(request: Request):
|
||||
"""Generate a matching profile portrait and character reference sheet.
|
||||
|
||||
The identity image is face-cropped first so source backgrounds, extra
|
||||
people, and unrelated props cannot leak into the result. The clean profile
|
||||
output then becomes the identity source for the reference sheet, keeping
|
||||
the face consistent across both deliverables.
|
||||
"""
|
||||
data = await request.json()
|
||||
name = str(data.get("name") or "").strip()
|
||||
book = str(data.get("book") or "").strip()
|
||||
sheet: dict = data.get("sheet") or {}
|
||||
saved_profile_prompt = str(data.get("profile_prompt") or data.get("image_prompt") or "").strip()
|
||||
saved_reference_prompt = str(data.get("reference_prompt") or data.get("concept_art_prompt") or "").strip()
|
||||
book_profile: dict = data.get("book_profile") or {}
|
||||
visual_style = str(data.get("visual_style") or "cinematic").strip().lower()
|
||||
if visual_style not in {"cinematic", "concept"}:
|
||||
visual_style = "cinematic"
|
||||
if not saved_profile_prompt or not saved_reference_prompt:
|
||||
raise HTTPException(400, "Both a profile image prompt and a character reference prompt are required")
|
||||
settings = _load_settings()
|
||||
comfy_url = str(settings.get("comfyui_url") or "").strip()
|
||||
if not comfy_url:
|
||||
raise HTTPException(400, "ComfyUI URL not set (Settings > Engines > Image Generation)")
|
||||
# Rebuild the fixed production frame from structured data. Saved prompts
|
||||
# stay usable as user-editable direction, but cannot override the book
|
||||
# lock, headwear rule, camera, or turnaround layout.
|
||||
portrait_master = _build_character_portrait_prompt(name, book, sheet, book_profile, visual_style)
|
||||
sheet_master = _build_character_sheet_prompt(name, book, sheet, book_profile, visual_style)
|
||||
# Prompts created before the standardized v3 masters may contain the old
|
||||
# hats, modern clothing, variable layouts and conflicting art styles. Do
|
||||
# not import those into the corrected render. Once a v3 prompt has been
|
||||
# generated, user edits retain its marker and are honored here.
|
||||
profile_direction = saved_profile_prompt if sheet.get("image_prompt_user_edited") is True and saved_profile_prompt.startswith(
|
||||
"Task: Generate one historical-fantasy audiobook cast portrait."
|
||||
) else ""
|
||||
reference_direction = saved_reference_prompt if sheet.get("concept_art_prompt_user_edited") is True and saved_reference_prompt.startswith(
|
||||
"Task: Generate one production character turnaround and equipment reference sheet."
|
||||
) else ""
|
||||
# The UI stores the full editable production masters. The local image
|
||||
# encoder receives compact, layout-first versions so its limited context
|
||||
# cannot truncate FRONT/SIDE/BACK or the period/headwear constraints.
|
||||
profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts = _build_compact_character_render_prompts(
|
||||
name, book, sheet, book_profile, visual_style,
|
||||
profile_direction or portrait_master,
|
||||
reference_direction or sheet_master,
|
||||
)
|
||||
# Do not perpetuate the modern hats, clothing, drinks, or scenery present
|
||||
# in a bad legacy portrait. Reuse is opt-in only after visual review.
|
||||
identity_image = str(data.get("identity_image") or "").strip() if data.get("reuse_identity") is True else ""
|
||||
if identity_image.startswith("/"):
|
||||
# Library records store same-origin image URLs rather than embedding
|
||||
# large data URIs. Resolve those URLs server-side before uploading the
|
||||
# image to ComfyUI.
|
||||
try:
|
||||
local_image = requests.get(f"http://127.0.0.1:7890{identity_image}", timeout=30)
|
||||
local_image.raise_for_status()
|
||||
identity_image = "data:" + (local_image.headers.get("Content-Type") or "image/png") + ";base64," + base64.b64encode(local_image.content).decode("ascii")
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Could not load the existing identity image: {exc}")
|
||||
# Each click gets a fresh latent family so a rejected composition is not
|
||||
# reproduced forever. The same family key is shared by the identity,
|
||||
# turnaround, scene and prop branches to preserve within-run cohesion.
|
||||
generation_key = f"voice-creator-cast-v6|{book}|{visual_style}|{name}|{uuid.uuid4().hex}"
|
||||
if not identity_image:
|
||||
anchor = await asyncio.to_thread(
|
||||
_comfyui_run,
|
||||
comfy_url, _bundled_character_identity_workflow(),
|
||||
"5", "text", "identity_save", profile_prompt, generation_key,
|
||||
)
|
||||
identity_image = anchor
|
||||
result = await asyncio.to_thread(
|
||||
_comfyui_run_consistent_images,
|
||||
comfy_url, _bundled_consistent_comfyui_workflow(), identity_image,
|
||||
profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts,
|
||||
generation_key,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@ -126,16 +126,18 @@ async def get_book_profile(book: str):
|
||||
|
||||
@router.post("/api/book-profile")
|
||||
async def save_book_profile(request: Request):
|
||||
from core.database import state_put
|
||||
from core.database import state_get, state_put
|
||||
data = await request.json()
|
||||
book = str(data.get("book", "")).strip()
|
||||
if not book:
|
||||
raise HTTPException(400, "book is required")
|
||||
previous = state_get(_book_profile_key(book), {}) or {}
|
||||
profile = {
|
||||
"genre": str(data.get("genre", "")).strip()[:200],
|
||||
"setting": str(data.get("setting", "")).strip()[:200],
|
||||
"era": str(data.get("era", "")).strip()[:200],
|
||||
"language": str(data.get("language", "")).strip()[:60],
|
||||
"confirmed": bool(data.get("confirmed", previous.get("confirmed", False))),
|
||||
}
|
||||
state_put(_book_profile_key(book), profile)
|
||||
return {"ok": True, "profile": profile}
|
||||
|
||||
269
scripts/export_all_audiobooks.py
Normal file
269
scripts/export_all_audiobooks.py
Normal file
@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resumable overnight export of the three DSA audiobooks, one MP3 per chapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:7890"
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CACHE = ROOT / "config" / "audiobook_batch_cache"
|
||||
PAUSE_SENTENCE_MS = 120
|
||||
PAUSE_PARAGRAPH_MS = 300
|
||||
CHUNK_LIMIT = 900
|
||||
BOOK_ORDER = [
|
||||
"Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm",
|
||||
"Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung",
|
||||
"Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone",
|
||||
]
|
||||
|
||||
CHAPTER = re.compile(
|
||||
r"(?im)(?:^|\n\s*\n|\n\s*)(?P<header>(?:(?:\d{1,3}|[ivxlcdm]+)[.)]?\s*)?"
|
||||
r"(?:kapitel|chapter|prolog|epilog)\b[^\n]{0,80})"
|
||||
)
|
||||
|
||||
|
||||
def key(value: str) -> str:
|
||||
return str(value or "").strip().casefold()
|
||||
|
||||
|
||||
def safe(value: str, limit: int = 80) -> str:
|
||||
value = re.sub(r"[^A-Za-z0-9ÄÖÜäöüß _.-]+", "", value).strip()
|
||||
return re.sub(r"\s+", "_", value)[:limit] or "chapter"
|
||||
|
||||
|
||||
def split_text(text: str) -> list[tuple[str, int]]:
|
||||
"""Return (text, pause-after-ms), preferring paragraph/sentence edges.
|
||||
|
||||
The old exporter flattened whitespace and cut at commas/spaces, which made
|
||||
the TTS engine restart in the middle of a sentence. A paragraph is kept as
|
||||
one request whenever possible. Only an oversized paragraph is divided,
|
||||
first at sentence endings and only as a last resort at a clause/word edge.
|
||||
"""
|
||||
raw = str(text or "").replace("\r\n", "\n").strip()
|
||||
paragraphs = [re.sub(r"\s+", " ", p).strip() for p in re.split(r"\n\s*\n+", raw) if p.strip()]
|
||||
result: list[tuple[str, int]] = []
|
||||
sentence_re = re.compile(r".+?(?:[.!?…]+[\"»”’']*|$)", re.S)
|
||||
for paragraph in paragraphs:
|
||||
if len(paragraph) <= CHUNK_LIMIT:
|
||||
result.append((paragraph, PAUSE_PARAGRAPH_MS))
|
||||
continue
|
||||
sentences = [s.strip() for s in sentence_re.findall(paragraph) if s.strip()]
|
||||
pieces: list[str] = []
|
||||
current = ""
|
||||
for sentence in sentences or [paragraph]:
|
||||
if len(sentence) > CHUNK_LIMIT:
|
||||
clauses = re.split(r"(?<=[,;:])\s+", sentence)
|
||||
else:
|
||||
clauses = [sentence]
|
||||
for clause in clauses:
|
||||
clause = clause.strip()
|
||||
if not clause:
|
||||
continue
|
||||
joined = f"{current} {clause}".strip() if current else clause
|
||||
if len(joined) <= CHUNK_LIMIT:
|
||||
current = joined
|
||||
else:
|
||||
if current:
|
||||
pieces.append(current)
|
||||
current = clause
|
||||
while len(current) > CHUNK_LIMIT:
|
||||
cut = current.rfind(" ", 80, CHUNK_LIMIT + 1)
|
||||
if cut <= 80:
|
||||
cut = CHUNK_LIMIT
|
||||
pieces.append(current[:cut].strip())
|
||||
current = current[cut:].strip()
|
||||
if current:
|
||||
pieces.append(current)
|
||||
for index, piece in enumerate(pieces):
|
||||
result.append((piece, PAUSE_PARAGRAPH_MS if index == len(pieces) - 1 else PAUSE_SENTENCE_MS))
|
||||
return result
|
||||
|
||||
|
||||
def wav_pcm(body: bytes) -> tuple[bytes, wave._wave_params]:
|
||||
with wave.open(io.BytesIO(body), "rb") as stream:
|
||||
params = stream.getparams()
|
||||
if params.sampwidth != 2:
|
||||
raise RuntimeError(f"TTS returned non-16-bit WAV: {params}")
|
||||
return stream.readframes(stream.getnframes()), params
|
||||
|
||||
|
||||
def make_wav(raw: bytes, params: wave._wave_params) -> bytes:
|
||||
output = io.BytesIO()
|
||||
with wave.open(output, "wb") as stream:
|
||||
stream.setnchannels(params.nchannels)
|
||||
stream.setsampwidth(params.sampwidth)
|
||||
stream.setframerate(params.framerate)
|
||||
stream.writeframes(raw)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def request_json(method: str, url: str, **kwargs):
|
||||
kwargs.setdefault("timeout", 90)
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
response = requests.request(method, url, **kwargs)
|
||||
if response.ok:
|
||||
return response
|
||||
if response.status_code < 500:
|
||||
raise RuntimeError(f"{method} {url}: {response.status_code} {response.text[:300]}")
|
||||
raise RuntimeError(f"server {response.status_code}: {response.text[:300]}")
|
||||
except Exception:
|
||||
if attempt == 3:
|
||||
raise
|
||||
time.sleep(attempt * 3)
|
||||
|
||||
|
||||
def tts(voice: str, text: str) -> tuple[bytes, wave._wave_params]:
|
||||
seed = int.from_bytes(hashlib.sha256(voice.encode("utf-8")).digest()[:8], "big") % 2147483647
|
||||
response = request_json(
|
||||
"POST", f"{BASE}/api/tts-preview",
|
||||
json={"voice": voice, "text": text, "response_format": "wav", "backend": "voice_clone", "seed": seed},
|
||||
timeout=180,
|
||||
)
|
||||
return wav_pcm(response.content)
|
||||
|
||||
|
||||
def chapter_parts(segments: list[dict]) -> list[dict]:
|
||||
"""Split segments at headings even when OCR put a heading inside a segment."""
|
||||
buckets: list[dict] = [{"title": "", "segments": []}]
|
||||
for segment in segments:
|
||||
text = str(segment.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
matches = list(CHAPTER.finditer(text))
|
||||
if not matches:
|
||||
buckets[-1]["segments"].append({**segment, "text": text})
|
||||
continue
|
||||
prefix = text[:matches[0].start()].strip()
|
||||
if prefix:
|
||||
buckets[-1]["segments"].append({**segment, "text": prefix})
|
||||
for index, match in enumerate(matches):
|
||||
if buckets[-1]["segments"] or buckets[-1]["title"]:
|
||||
buckets.append({"title": match.group("header").strip(), "segments": []})
|
||||
else:
|
||||
buckets[-1]["title"] = match.group("header").strip()
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
body = text[match.end():end].strip()
|
||||
if body:
|
||||
buckets[-1]["segments"].append({**segment, "text": body, "type": "narration", "speaker": "Narrator"})
|
||||
return [bucket for bucket in buckets if bucket["segments"]]
|
||||
|
||||
|
||||
def voice_map(book: str) -> tuple[dict[str, str], str]:
|
||||
characters = request_json("GET", f"{BASE}/api/characters").json().get("characters", [])
|
||||
mapping: dict[str, str] = {}
|
||||
for character in characters:
|
||||
if character.get("book") != book or not character.get("voice"):
|
||||
continue
|
||||
voice = str(character["voice"])
|
||||
mapping[key(character.get("name"))] = voice
|
||||
for alias in re.split(r"[,;|]", str((character.get("sheet") or {}).get("aliases") or "")):
|
||||
if alias.strip():
|
||||
mapping[key(alias)] = voice
|
||||
narrator = mapping.get("narrator")
|
||||
if not narrator:
|
||||
raise RuntimeError(f"{book}: no Narrator voice assignment found")
|
||||
return mapping, narrator
|
||||
|
||||
|
||||
def render_book(doc: dict) -> None:
|
||||
book = doc["title"]
|
||||
doc_id = doc["id"]
|
||||
cast = request_json("GET", f"{BASE}/api/reader/docs/{doc_id}/scripts/cast").json()
|
||||
mapping, narrator = voice_map(book)
|
||||
buckets = chapter_parts(cast.get("segments", []))
|
||||
book_dir = CACHE / safe(book)
|
||||
book_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"[{book}] {len(buckets)} chapter buckets", flush=True)
|
||||
for chapter_index, bucket in enumerate(buckets, 1):
|
||||
title = bucket["title"] or f"Chapter {chapter_index}"
|
||||
chapter_dir = book_dir / f"{chapter_index:03d}_{safe(title, 48)}"
|
||||
# New cache namespace: chunk boundaries changed from unsafe fixed
|
||||
# character cuts to paragraph/sentence boundaries.
|
||||
chunks_dir = chapter_dir / "chunks_v2_sentence_aware"
|
||||
chunks_dir.mkdir(parents=True, exist_ok=True)
|
||||
chunks: list[tuple[str, str, int]] = []
|
||||
fallback = 0
|
||||
for segment in bucket["segments"]:
|
||||
speaker = key(segment.get("speaker") or "narrator")
|
||||
voice = mapping.get(speaker, narrator)
|
||||
if speaker not in mapping and speaker != "narrator":
|
||||
fallback += 1
|
||||
for text, pause_ms in split_text(segment.get("text")):
|
||||
chunks.append((voice, text, pause_ms))
|
||||
pcm_parts: list[bytes] = []
|
||||
params = None
|
||||
for index, (voice, text, pause_ms) in enumerate(chunks):
|
||||
chunk_path = chunks_dir / f"{index:05d}.pcm"
|
||||
meta_path = chunks_dir / f"{index:05d}.json"
|
||||
if chunk_path.is_file() and meta_path.is_file():
|
||||
pcm_parts.append(chunk_path.read_bytes())
|
||||
if params is None:
|
||||
params = wave._wave_params(**json.loads(meta_path.read_text()))
|
||||
continue
|
||||
raw, current = tts(voice, text)
|
||||
if params is None:
|
||||
params = current
|
||||
elif (current.nchannels, current.sampwidth, current.framerate, current.comptype, current.compname) != (params.nchannels, params.sampwidth, params.framerate, params.comptype, params.compname):
|
||||
raise RuntimeError(f"{book} chapter {chapter_index}: WAV format changed mid-chapter")
|
||||
chunk_path.write_bytes(raw)
|
||||
meta_path.write_text(json.dumps({"nchannels": current.nchannels, "sampwidth": current.sampwidth, "framerate": current.framerate, "nframes": len(raw) // current.sampwidth // current.nchannels, "comptype": current.comptype, "compname": current.compname}))
|
||||
pcm_parts.append(raw)
|
||||
if (index + 1) % 25 == 0:
|
||||
print(f"[{book}] chapter {chapter_index} {index + 1}/{len(chunks)} chunks", flush=True)
|
||||
if not pcm_parts or params is None:
|
||||
continue
|
||||
merged_parts = []
|
||||
for i, part in enumerate(pcm_parts):
|
||||
if i:
|
||||
pause_ms = chunks[i - 1][2]
|
||||
silence = b"\0" * int(params.framerate * pause_ms / 1000) * params.sampwidth * params.nchannels
|
||||
merged_parts.append(silence)
|
||||
merged_parts.append(part)
|
||||
merged = b"".join(merged_parts)
|
||||
wav = make_wav(merged, params)
|
||||
encoded = request_json("POST", f"{BASE}/api/audio/encode-mp3", data=wav, timeout=180).content
|
||||
filename = f"{safe(book, 45)} - {chapter_index:02d} - {safe(title, 55)}.mp3"
|
||||
upload_url = f"{BASE}/api/audiobook-export/{quote(book, safe='')}/{quote(filename, safe='')}"
|
||||
request_json("POST", upload_url, data=encoded, timeout=180)
|
||||
(chapter_dir / "complete.json").write_text(json.dumps({"filename": filename, "title": title, "chunks": len(chunks), "narrator_fallbacks": fallback, "bytes": len(encoded)}))
|
||||
print(f"[{book}] COMPLETE chapter {chapter_index}/{len(buckets)}: {filename} ({len(encoded) / 1048576:.1f} MiB)", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
docs = request_json("GET", f"{BASE}/api/reader/docs").json().get("docs", [])
|
||||
by_title = {doc.get("title"): doc for doc in docs}
|
||||
for title in BOOK_ORDER:
|
||||
if title not in by_title:
|
||||
raise RuntimeError(f"Book document not found: {title}")
|
||||
render_book(by_title[title])
|
||||
(CACHE / "ALL_THREE_BOOKS_COMPLETE").write_text(time.strftime("%Y-%m-%dT%H:%M:%S%z"))
|
||||
print("ALL THREE AUDIOBOOKS COMPLETE", flush=True)
|
||||
if os.environ.get("RUN_IMAGE_SAMPLES") == "1":
|
||||
print("Restarting Voice Creator before the ten image samples…", flush=True)
|
||||
subprocess.run(["docker", "compose", "restart", "tts-voice-creator-clone-and-design-2"], cwd=ROOT, check=True)
|
||||
time.sleep(3)
|
||||
subprocess.run([sys.executable, str(ROOT / "scripts" / "generate_character_image_samples.py")], cwd=ROOT, check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted; completed chunks remain cached for resume.", file=sys.stderr)
|
||||
raise
|
||||
except Exception as exc:
|
||||
print(f"BATCH FAILED: {exc}", file=sys.stderr)
|
||||
raise
|
||||
52
scripts/generate_character_image_samples.py
Normal file
52
scripts/generate_character_image_samples.py
Normal file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate ten concept-sheet shared-identity character image pairs after export."""
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:7890"
|
||||
TARGETS = [
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Darrag"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Eolan"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Baronin Ira von Seewiesen"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm", "Gamba"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Alrik von Blautann"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Andra"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung", "Cindira"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Admiral Sanin"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Arthag"),
|
||||
("Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone", "Anshelm"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
all_chars = requests.get(f"{BASE}/api/characters", timeout=30).json()["characters"]
|
||||
for book, name in TARGETS:
|
||||
item = next(c for c in all_chars if c.get("book") == book and c.get("name") == name)
|
||||
record = requests.get(f"{BASE}/api/characters/{item['id']}", timeout=30).json()
|
||||
sheet = record.get("sheet") or {}
|
||||
print(f"[{name}] generating concept profile + reference sheet", flush=True)
|
||||
response = requests.post(
|
||||
f"{BASE}/api/character-generate-consistent-images",
|
||||
json={
|
||||
"profile_prompt": sheet.get("image_prompt", ""),
|
||||
"reference_prompt": sheet.get("concept_art_prompt", ""),
|
||||
"visual_style": "concept",
|
||||
"identity_image": record.get("image", ""),
|
||||
},
|
||||
# The local Qwen graph renders the portrait and 3:2 sheet in
|
||||
# sequence; allow the app's 25-minute server-side deadline.
|
||||
timeout=1800,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
record["image"] = result["profile"]
|
||||
record["sheet"] = {**sheet, "concept_art_image": result["reference"]}
|
||||
saved = requests.put(f"{BASE}/api/characters/{item['id']}", json=record, timeout=60)
|
||||
saved.raise_for_status()
|
||||
print(f"[{name}] saved", flush=True)
|
||||
print("TEN CHARACTER IMAGE SAMPLES COMPLETE", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
137
scripts/render_trial_chapters.mjs
Normal file
137
scripts/render_trial_chapters.mjs
Normal file
@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env node
|
||||
/** Render six dialogue-focused audiobook chapter trials through the live API.
|
||||
*
|
||||
* This deliberately reuses each saved character's assigned voice. Unknown
|
||||
* one-off speakers use the book's Narrator voice rather than creating a new
|
||||
* voice during a render.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const base = process.env.VOICE_CREATOR_URL || 'http://127.0.0.1:7890';
|
||||
const selections = [
|
||||
{ cast: '/tmp/b1-cast.json', chapter: 6, slug: 'book-1-chapter-6' },
|
||||
{ cast: '/tmp/b1-cast.json', chapter: 9, slug: 'book-1-chapter-9' },
|
||||
{ cast: '/tmp/b2-cast.json', chapter: 5, slug: 'book-2-chapter-5' },
|
||||
{ cast: '/tmp/b2-cast.json', chapter: 13, slug: 'book-2-chapter-13' },
|
||||
{ cast: '/tmp/b3-cast.json', chapter: 4, slug: 'book-3-chapter-4' },
|
||||
{ cast: '/tmp/b3-cast.json', chapter: 9, slug: 'book-3-chapter-9' },
|
||||
];
|
||||
|
||||
const outDir = '/tmp/voice-creator-trials';
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const key = value => String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
|
||||
function chapterBounds(segments, chapter) {
|
||||
const starts = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const match = String(segments[i].text || '').match(/(?:^|\s)(\d{1,2})\.\s*Kapitel\b/i);
|
||||
if (match) starts.push({ index: i, chapter: Number(match[1]) });
|
||||
}
|
||||
const startAt = starts.findIndex(s => s.chapter === chapter);
|
||||
if (startAt < 0) throw new Error(`Chapter ${chapter} was not found`);
|
||||
return [starts[startAt].index, starts[startAt + 1]?.index ?? segments.length];
|
||||
}
|
||||
|
||||
function splitText(text, limit = 620) {
|
||||
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
||||
if (!clean) return [];
|
||||
const pieces = [];
|
||||
let remaining = clean;
|
||||
while (remaining.length > limit) {
|
||||
const candidates = [remaining.lastIndexOf('. ', limit), remaining.lastIndexOf('! ', limit), remaining.lastIndexOf('? ', limit), remaining.lastIndexOf(', ', limit), remaining.lastIndexOf(' ', limit)];
|
||||
const cut = Math.max(...candidates);
|
||||
const end = cut > 80 ? cut + 1 : limit;
|
||||
pieces.push(remaining.slice(0, end).trim());
|
||||
remaining = remaining.slice(end).trim();
|
||||
}
|
||||
if (remaining) pieces.push(remaining);
|
||||
return pieces;
|
||||
}
|
||||
|
||||
function wavData(buffer) {
|
||||
const b = Buffer.from(buffer);
|
||||
if (b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') throw new Error('TTS response was not a WAV file');
|
||||
let p = 12;
|
||||
while (p + 8 <= b.length) {
|
||||
const id = b.toString('ascii', p, p + 4);
|
||||
const size = b.readUInt32LE(p + 4);
|
||||
if (id === 'data') return b.subarray(p + 8, p + 8 + size);
|
||||
p += 8 + size + (size % 2);
|
||||
}
|
||||
throw new Error('WAV data chunk was not found');
|
||||
}
|
||||
|
||||
function wavFile(data) {
|
||||
const header = Buffer.alloc(44);
|
||||
header.write('RIFF', 0); header.writeUInt32LE(36 + data.length, 4); header.write('WAVEfmt ', 8);
|
||||
header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22);
|
||||
header.writeUInt32LE(24000, 24); header.writeUInt32LE(48000, 28); header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34);
|
||||
header.write('data', 36); header.writeUInt32LE(data.length, 40);
|
||||
return Buffer.concat([header, data]);
|
||||
}
|
||||
|
||||
async function charactersFor(title) {
|
||||
const response = await fetch(`${base}/api/characters`);
|
||||
if (!response.ok) throw new Error(`Cannot read character library: ${response.status}`);
|
||||
const all = (await response.json()).characters || [];
|
||||
const relevant = all.filter(c => c.book === title);
|
||||
const voices = new Map();
|
||||
for (const c of relevant) {
|
||||
if (!c.voice) continue;
|
||||
voices.set(key(c.name), c.voice);
|
||||
for (const a of String(c.sheet?.aliases || '').split(/[,;|]/)) if (a.trim()) voices.set(key(a), c.voice);
|
||||
}
|
||||
return voices;
|
||||
}
|
||||
|
||||
async function tts(voice, text) {
|
||||
const response = await fetch(`${base}/api/tts-preview`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice, text, response_format: 'wav', backend: 'voice_clone' }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
|
||||
return wavData(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function render(selection) {
|
||||
const cast = JSON.parse(fs.readFileSync(selection.cast, 'utf8'));
|
||||
const [from, to] = chapterBounds(cast.segments, selection.chapter);
|
||||
const voiceMap = await charactersFor(cast.title);
|
||||
const narrator = voiceMap.get('narrator');
|
||||
if (!narrator) throw new Error(`${cast.title}: narrator has no voice`);
|
||||
const chunks = [];
|
||||
let fallbackCount = 0;
|
||||
for (const segment of cast.segments.slice(from, to)) {
|
||||
const speaker = key(segment.speaker || 'Narrator');
|
||||
const voice = voiceMap.get(speaker) || narrator;
|
||||
if (!voiceMap.has(speaker) && speaker !== 'narrator') fallbackCount++;
|
||||
for (const text of splitText(segment.text)) chunks.push({ voice, text });
|
||||
}
|
||||
const chunkDir = path.join(outDir, selection.slug);
|
||||
fs.mkdirSync(chunkDir, { recursive: true });
|
||||
let start = 0;
|
||||
while (start < chunks.length && fs.existsSync(path.join(chunkDir, `${String(start).padStart(4, '0')}.pcm`))) start++;
|
||||
console.log(`[${selection.slug}] chapter ${selection.chapter}, segments ${to - from}, requests ${chunks.length}, resume ${start}, narrator fallbacks ${fallbackCount}`);
|
||||
for (let i = start; i < chunks.length; i++) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try { fs.writeFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`), await tts(chunks[i].voice, chunks[i].text)); lastError = null; break; }
|
||||
catch (err) { lastError = err; console.warn(`[${selection.slug}] request ${i + 1}/${chunks.length} attempt ${attempt}: ${err.message}`); await pause(1500 * attempt); }
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
if ((i + 1) % 10 === 0 || i + 1 === chunks.length) console.log(`[${selection.slug}] ${i + 1}/${chunks.length}`);
|
||||
}
|
||||
const silence = Buffer.alloc(24000 / 4 * 2); // 250 ms 24kHz / mono / 16-bit
|
||||
const raw = chunks.map((_, i) => fs.readFileSync(path.join(chunkDir, `${String(i).padStart(4, '0')}.pcm`)));
|
||||
const wav = wavFile(Buffer.concat(raw.flatMap((x, i) => i ? [silence, x] : [x])));
|
||||
const file = `${selection.slug}.wav`;
|
||||
fs.writeFileSync(path.join(outDir, file), wav);
|
||||
const upload = await fetch(`${base}/api/audiobook-export/${encodeURIComponent(cast.title)}/${file}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav });
|
||||
if (!upload.ok) throw new Error(`Export upload failed: ${upload.status} ${await upload.text()}`);
|
||||
console.log(`[${selection.slug}] complete ${(wav.length / 1024 / 1024).toFixed(1)} MiB ${await upload.text()}`);
|
||||
}
|
||||
|
||||
for (const selection of selections) await render(selection);
|
||||
console.log('All six trial chapters are complete.');
|
||||
65
scripts/run_voice_quality_pass.mjs
Normal file
65
scripts/run_voice_quality_pass.mjs
Normal file
@ -0,0 +1,65 @@
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const base = process.env.TTSC_URL || 'http://localhost:7890';
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || '/snap/bin/chromium' });
|
||||
const page = await browser.newPage();
|
||||
page.on('console', msg => { if (msg.type() === 'error') console.error('[browser]', msg.text()); });
|
||||
await page.goto(base + '/#s-library', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.clGetAll === 'function' && typeof window._charAutoDesignVoice === 'function', null, { timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
if (typeof window.loadVoiceLibrary === 'function') await window.loadVoiceLibrary();
|
||||
});
|
||||
|
||||
const targets = await page.evaluate(async () => {
|
||||
const books = new Set([
|
||||
'Hohlbein & Hennen - DSA - Das Jahr des Greifen 01 - Der Sturm',
|
||||
'Hohlbein & Hennen - DSA - Das Jahr des Greifen 02 - Die Entdeckung',
|
||||
'Hohlbein & Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone',
|
||||
]);
|
||||
const chars = await window.clGetAll();
|
||||
const voices = window._voices || [];
|
||||
const ratings = new Map(voices.map(v => [v.id, Number(v.rating || 0)]));
|
||||
return chars.filter(c => books.has(c.book) && c.voice && ratings.get(typeof c.voice === 'object' ? c.voice.id : c.voice) >= 1 && ratings.get(typeof c.voice === 'object' ? c.voice.id : c.voice) <= 2)
|
||||
.map(c => {
|
||||
const voiceId = typeof c.voice === 'object' ? c.voice.id : c.voice;
|
||||
const voice = voices.find(v => v.id === voiceId) || {};
|
||||
return { id: c.id, book: c.book, name: c.name, rating: ratings.get(voiceId), feedback: String(voice.note || '').trim() };
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`Quality pass starting for ${targets.length} voices`);
|
||||
let done = 0;
|
||||
for (const target of targets) {
|
||||
const result = await page.evaluate(async (target) => {
|
||||
const all = await window.clGetAll();
|
||||
const rec = all.find(c => c.id === target.id);
|
||||
if (!rec) throw new Error('Character no longer exists');
|
||||
const sheet = rec.sheet || {};
|
||||
const sample = [sheet.physical, sheet.backstory, sheet.motivation].filter(Boolean).join(' ');
|
||||
const language = typeof window.detectLang === 'function' && sample ? (window.detectLang(sample) || '') : '';
|
||||
const llm = typeof window.statusLlmTarget === 'function' ? window.statusLlmTarget() : { url: '', model: '' };
|
||||
const promptResponse = await fetch('/api/character-generate-prompts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet, voice_feedback: target.feedback || '', language, llm_url: llm.url, model: llm.model, fields: ['voice_design_prompt'] }),
|
||||
});
|
||||
if (!promptResponse.ok) throw new Error('Prompt generation: ' + promptResponse.status + ' ' + promptResponse.statusText);
|
||||
const promptData = await promptResponse.json();
|
||||
const prompt = String(promptData.voice_design_prompt || '').trim();
|
||||
if (!prompt) throw new Error('Empty regenerated prompt');
|
||||
rec.sheet = rec.sheet || {};
|
||||
rec.sheet.voice_design_prompt = prompt;
|
||||
rec.updated = new Date();
|
||||
await window.clPut(rec);
|
||||
await window._charAutoDesignVoice(rec, true, prompt);
|
||||
return { name: rec.name, prompt };
|
||||
}, target);
|
||||
done += 1;
|
||||
console.log(`[${done}/${targets.length}] ${target.rating}★ ${target.book} — ${result.name}`);
|
||||
}
|
||||
console.log('Quality pass complete');
|
||||
await page.evaluate(async () => {
|
||||
const r = await fetch('/api/tts/restart', { method: 'POST' });
|
||||
if (!r.ok) throw new Error('TTS restart failed: ' + r.status);
|
||||
});
|
||||
console.log('TTS backend restarted; redesigned audio cache flushed');
|
||||
await browser.close();
|
||||
10
static/dist/main.min.js
vendored
10
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1616,7 +1616,6 @@ function _abScrollIntoView(el, opts) {
|
||||
function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||||
const panel = document.getElementById('reader-audiobook-panel');
|
||||
if (!panel) return;
|
||||
|
||||
if (typeof window.navReaderView === 'function') window.navReaderView('cast');
|
||||
else if (typeof window.showReaderView === 'function') window.showReaderView('cast');
|
||||
else {
|
||||
@ -1707,7 +1706,7 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||||
<button class="btn-secondary btn-sm" id="ab-cv-start-recast" style="display:none; margin-right:8px;" title="Scan the text for characters and build the cast list"><span class="mdi mdi-refresh"></span> Identify all characters</button>
|
||||
<button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button>
|
||||
</div>`;
|
||||
|
||||
|
||||
const AB_DEFAULT_PROMPT = `Du bist ein erfahrener Drehbuchautor und Hörbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente für Erzähler und Dialoge (wörtliche Rede) zu unterteilen.
|
||||
|
||||
WICHTIGE DEFINITION VON DIALOG:
|
||||
@ -4176,6 +4175,14 @@ ABSOLUTE REGELN:
|
||||
});
|
||||
};
|
||||
const runIdentifyAll = async () => {
|
||||
const bookTitle = window.readerState?.title || '';
|
||||
if (bookTitle && typeof window.openBookContextEditor === 'function') {
|
||||
const existingContext = typeof _getBookProfile === 'function' ? await _getBookProfile(bookTitle) : {};
|
||||
if (!existingContext.confirmed) {
|
||||
const profile = await window.openBookContextEditor(bookTitle);
|
||||
if (!profile) return;
|
||||
}
|
||||
}
|
||||
const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true });
|
||||
if (!ok) return;
|
||||
applyPromptAndRun(audiobookCast);
|
||||
@ -5712,6 +5719,23 @@ function audiobookLineVoice(l) {
|
||||
return { voice: rehState.narratorVoice, instruct: '' };
|
||||
}
|
||||
|
||||
// Long narrator paragraphs must be split before they reach the TTS service.
|
||||
// The service has to restart its decoder/model context for a very long
|
||||
// request, and its own emergency cut can land mid-sentence. Keep the split
|
||||
// sentence-aware and add only a short, intentional breath between generated
|
||||
// pieces; normal paragraph gaps are added later when chapters are merged.
|
||||
async function _abSynthesizeLongText(voice, text, instruct, backend) {
|
||||
const pieces = (typeof splitTextIntoChunks === 'function')
|
||||
? splitTextIntoChunks(text, 900)
|
||||
: [text];
|
||||
if (pieces.length <= 1) return fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend);
|
||||
const blobs = [];
|
||||
for (const piece of pieces) {
|
||||
blobs.push(await fetchTtsPreviewBlob(voice, piece, 'wav', instruct, backend));
|
||||
}
|
||||
return mergeWavBlobs(blobs, 120);
|
||||
}
|
||||
|
||||
async function audiobookExport() {
|
||||
if (_audiobook.running) return;
|
||||
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
|
||||
@ -5794,7 +5818,7 @@ async function audiobookExport() {
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
|
||||
blob = await _abSynthesizeLongText(voice, text, instruct, _ttsBackendForVoice(voice, rehState.backend));
|
||||
lastErr = null;
|
||||
break;
|
||||
} catch (e) {
|
||||
|
||||
@ -219,6 +219,8 @@ async function libraryRenderCharacters() {
|
||||
+ '</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>'
|
||||
+ '<select class="lib-chars-visual-style" title="Choose the visual style for the profile and character reference pair"><option value="cinematic">Cinematic / photorealistic</option><option value="concept">Concept art / illustrated</option></select>'
|
||||
+ '<button class="btn-primary btn-sm lib-chars-bulk-consistent-btn" data-book="' + escHtml(book) + '" title="Generate a matching cinematic profile portrait and reference sheet from one shared identity image" disabled><span class="mdi mdi-face-recognition"></span> Consistent profile + reference (<span class="lib-chars-bulk-count-consistent">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>'
|
||||
@ -315,6 +317,9 @@ async function libraryRenderCharacters() {
|
||||
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 consistentBtn = prod.querySelector('.lib-chars-bulk-consistent-btn');
|
||||
const consistentCount = prod.querySelector('.lib-chars-bulk-count-consistent');
|
||||
const visualStyleSel = prod.querySelector('.lib-chars-visual-style');
|
||||
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');
|
||||
@ -337,6 +342,8 @@ async function libraryRenderCharacters() {
|
||||
imageBtn.disabled = n === 0;
|
||||
conceptCount.textContent = n;
|
||||
conceptBtn.disabled = n === 0;
|
||||
if (consistentCount) consistentCount.textContent = n;
|
||||
if (consistentBtn) consistentBtn.disabled = n === 0;
|
||||
deleteCount.textContent = n;
|
||||
deleteBtn.disabled = n === 0;
|
||||
syncTblSelectAllCb();
|
||||
@ -426,6 +433,13 @@ async function libraryRenderCharacters() {
|
||||
const provider = imageProviderSel ? imageProviderSel.value : '';
|
||||
runBulk(conceptBtn, ids, 'Generating concept art', function (rec) { return _charAutoGenerateConceptArt(rec, provider); });
|
||||
});
|
||||
consistentBtn?.addEventListener('click', function () {
|
||||
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
|
||||
if (!ids.length) return;
|
||||
runBulk(consistentBtn, ids, 'Generating consistent images', function (rec) {
|
||||
return _charAutoGenerateConsistentImages(rec, visualStyleSel ? visualStyleSel.value : 'cinematic');
|
||||
});
|
||||
});
|
||||
const fixLangBtn = prod.querySelector('.lib-chars-fix-lang-btn');
|
||||
fixLangBtn?.addEventListener('click', function () {
|
||||
// No selection needed — scans every character in this production for
|
||||
@ -532,6 +546,14 @@ async function libraryRenderCharacters() {
|
||||
} else if (savedScrollTop != null) {
|
||||
mainEl.scrollTop = savedScrollTop;
|
||||
}
|
||||
|
||||
// Restore the exact profile that was open when Studio was refreshed.
|
||||
if (window._libResumeCharacterId) {
|
||||
const resumeId = window._libResumeCharacterId;
|
||||
window._libResumeCharacterId = null;
|
||||
const resumeRec = byId.get(resumeId);
|
||||
if (resumeRec) setTimeout(function () { _charDetailPage(resumeRec, all, { fromLibrary: false }); }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function _charHue(name) {
|
||||
@ -1073,13 +1095,16 @@ function _lcdSectionFull(icon, label, fields) {
|
||||
// convention the rest of the detail view uses, so edits autosave for free.
|
||||
function _lcdPromptBox(label, value, sheetKey) {
|
||||
const has = !!(value && String(value).trim());
|
||||
return '<details class="lcd-prompt-box">'
|
||||
+ '<summary>' + escHtml(label) + (has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
|
||||
const isVoicePrompt = sheetKey === 'voice_design_prompt';
|
||||
const saveAction = '<button type="button" class="btn-primary btn-sm lcd-save-prompt" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-save-outline"></span> Save / update prompt</button>';
|
||||
return '<details class="lcd-prompt-box' + (isVoicePrompt ? ' lcd-voice-prompt-box' : '') + '"' + (isVoicePrompt ? ' open' : '') + '>'
|
||||
+ '<summary>' + escHtml(label) + (isVoicePrompt ? ' <span class="lcd-prompt-hint">Edit the description to improve the voice</span>' : '') + (has || isVoicePrompt ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
|
||||
+ '<div class="lcd-prompt-body">'
|
||||
+ '<div class="lcd-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(value || '') + '</div>'
|
||||
+ '<div class="lcd-prompt-actions">'
|
||||
+ saveAction
|
||||
+ '<button type="button" class="btn-secondary btn-sm lcd-prompt-copy" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-copy"></span> Copy</button>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompt" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-creation"></span> ' + (has ? 'Regenerate' : 'Generate') + '</button>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompt" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-creation"></span> ' + (has ? (sheetKey === 'voice_design_prompt' ? 'Regenerate Prompt' : 'Regenerate') : 'Generate') + '</button>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</details>';
|
||||
@ -1143,6 +1168,7 @@ function _charLineCount(c) {
|
||||
}
|
||||
|
||||
async function _charDetailPage(rec, allChars, opts) {
|
||||
if (typeof window.studioRememberCharacter === 'function') window.studioRememberCharacter(rec.id);
|
||||
opts = opts || {};
|
||||
window._libDetailOpts = opts;
|
||||
// Defaults to the Library grid's own list container/back-navigation, but
|
||||
@ -1156,6 +1182,7 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
window._libDetailRec = rec;
|
||||
|
||||
const sh = rec.sheet || {};
|
||||
const bookProfile = typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book || '') : {};
|
||||
const hue = _charHue(rec.name);
|
||||
const hue2 = (hue + 40) % 360;
|
||||
const tier = String(sh.tier || '').toLowerCase();
|
||||
@ -1192,6 +1219,13 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
const conceptArtHtml = '<div class="lcd-section-full lcd-conceptart">'
|
||||
+ '<div class="lcd-conceptart-headrow">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-image-frame"></span> Konzeptbild</div>'
|
||||
+ '<select class="lcd-visual-style" title="Bildstil für Profilbild und Referenzbild auswählen">'
|
||||
+ '<option value="cinematic"' + ((_libStr(sh.visual_style) || 'cinematic') === 'cinematic' ? ' selected' : '') + '>Cinematic / photorealistic</option>'
|
||||
+ '<option value="concept"' + (_libStr(sh.visual_style) === 'concept' ? ' selected' : '') + '>Concept art / illustrated</option>'
|
||||
+ '</select>'
|
||||
+ '<button type="button" class="lcd-consistent-images-gen btn-primary btn-sm" title="Gemeinsames Gesicht für Profilbild und Referenzbild erzeugen">'
|
||||
+ '<span class="mdi mdi-face-recognition"></span> Profil + Referenz gemeinsam erzeugen'
|
||||
+ '</button>'
|
||||
+ '<button type="button" class="lcd-conceptart-gen btn-secondary btn-sm" title="Konzeptbild generieren">'
|
||||
+ '<span class="mdi mdi-creation"></span> ' + (sh.concept_art_image ? 'Neu generieren' : 'Generieren')
|
||||
+ '</button>'
|
||||
@ -1204,6 +1238,14 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
+ '</div>')
|
||||
+ '</div>';
|
||||
|
||||
const bookContextHtml = '<div class="lcd-book-context">'
|
||||
+ '<div><span class="mdi mdi-book-cog-outline"></span><strong> Book context</strong></div>'
|
||||
+ '<div class="lcd-book-context-text">'
|
||||
+ escHtml([bookProfile.genre, bookProfile.setting, bookProfile.era, bookProfile.language].filter(Boolean).join(' · ') || 'Not set')
|
||||
+ '</div>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm lcd-edit-book-context">Edit book context</button>'
|
||||
+ '</div>';
|
||||
|
||||
const alignHtml = pct != null ? (
|
||||
'<div class="lcd-align-section">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
|
||||
@ -1229,8 +1271,16 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
? '<div class="lcd-generated-voice-file">'
|
||||
+ '<button type="button" class="lcd-generated-voice-play" title="Stimme anhören" aria-label="Stimme anhören"><span class="mdi mdi-play"></span></button>'
|
||||
+ '<span class="lcd-generated-voice-file-label"><span class="mdi mdi-file-music-outline"></span> Voice file: <strong>' + escHtml(fileName) + '</strong></span>'
|
||||
+ '<button type="button" class="btn-primary btn-sm lcd-regenerate-voice" title="Generate directly from the edited prompt and replace the current voice"><span class="mdi mdi-refresh"></span> Regenerate Voice</button>'
|
||||
+ '</div>'
|
||||
: '';
|
||||
+ '<div class="lcd-voice-replace-confirm" hidden role="status" aria-live="polite">'
|
||||
+ '<span><span class="mdi mdi-alert-outline"></span> This will replace <strong>' + escHtml(rec.voice || fileName) + '</strong> with a new voice generated from the visible prompt.</span>'
|
||||
+ '<span class="lcd-voice-replace-actions">'
|
||||
+ '<button type="button" class="btn-secondary btn-sm lcd-voice-replace-cancel">Cancel</button>'
|
||||
+ '<button type="button" class="btn-primary btn-sm lcd-voice-replace-ok"><span class="mdi mdi-refresh"></span> Replace voice</button>'
|
||||
+ '</span>'
|
||||
+ '</div>'
|
||||
: '<div class="lcd-voice-actions"><button type="button" class="btn-primary lcd-regenerate-voice"><span class="mdi mdi-creation"></span> Generate Voice from this Prompt</button></div>';
|
||||
}())
|
||||
+ _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt')
|
||||
+ _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_prompt')
|
||||
@ -1291,13 +1341,30 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
// Prefer the actual live count from the currently-loaded script (exact),
|
||||
// falling back to the LLM's own line_count estimate from casting time
|
||||
// when no script is loaded here.
|
||||
const sidebarChars = (allChars || []).slice().sort(function (a, b) {
|
||||
// The shared cast store can contain several incremental records with the
|
||||
// same display name (for example Marcian was discovered again in later
|
||||
// passages/books). The detail navigator is book-local, so showing every
|
||||
// raw record here made the same person appear several times. Keep the
|
||||
// strongest record per name for this book, while leaving the underlying
|
||||
// records untouched for now.
|
||||
const sidebarByName = new Map();
|
||||
(allChars || []).filter(function (c) {
|
||||
return String(c.book || '').trim() === String(rec.book || '').trim();
|
||||
}).forEach(function (c) {
|
||||
const key = String(c.name || '').trim().toLocaleLowerCase();
|
||||
if (!key) return;
|
||||
const old = sidebarByName.get(key);
|
||||
if (!old || c.id === rec.id || _charLineCount(c) > _charLineCount(old)) sidebarByName.set(key, c);
|
||||
});
|
||||
if (!sidebarByName.has(String(rec.name || '').trim().toLocaleLowerCase())) sidebarByName.set(String(rec.name || '').trim().toLocaleLowerCase(), rec);
|
||||
const sidebarChars = [...sidebarByName.values()].sort(function (a, b) {
|
||||
return _charLineCount(b) - _charLineCount(a);
|
||||
});
|
||||
const sidebarHtml = sidebarChars.map(function (c) {
|
||||
const h = _charHue(c.name);
|
||||
const count = _charLineCount(c);
|
||||
return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">'
|
||||
const gender = _libStr(c.sheet && c.sheet.gender).toLocaleLowerCase();
|
||||
return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '" data-char-name="' + escHtml(c.name || '') + '" data-char-lines="' + count + '" data-char-gender="' + escHtml(gender) + '">'
|
||||
+ '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>'
|
||||
+ '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>'
|
||||
+ (count ? '<span class="lib-cpg-sidebar-count">' + count + '</span>' : '')
|
||||
@ -1333,6 +1400,7 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="lcd-body">'
|
||||
+ bookContextHtml
|
||||
+ conceptArtHtml
|
||||
+ '<div class="lcd-voice-top">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||||
@ -1394,11 +1462,41 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
+ '</div>'
|
||||
+ '<div class="lib-cpg-sidebar">'
|
||||
+ '<div class="lib-cpg-sidebar-title">Charaktere · ' + escHtml(rec.book || '') + '</div>'
|
||||
+ '<div class="lib-cpg-sidebar-tools">'
|
||||
+ '<input type="search" class="lib-cpg-sidebar-search" placeholder="Charakter suchen…" aria-label="Charakter suchen">'
|
||||
+ '<select class="lib-cpg-sidebar-sort" aria-label="Charaktere sortieren" title="Sortieren nach">'
|
||||
+ '<option value="lines">Zeilen</option><option value="name">Name</option><option value="gender">Geschlecht</option>'
|
||||
+ '</select>'
|
||||
+ '</div>'
|
||||
+ sidebarHtml
|
||||
+ '</div>';
|
||||
|
||||
container.appendChild(pg);
|
||||
|
||||
const sidebar = pg.querySelector('.lib-cpg-sidebar');
|
||||
const sidebarSearch = pg.querySelector('.lib-cpg-sidebar-search');
|
||||
const sidebarSort = pg.querySelector('.lib-cpg-sidebar-sort');
|
||||
function refreshCharacterSidebar() {
|
||||
const query = String(sidebarSearch?.value || '').trim().toLocaleLowerCase();
|
||||
const mode = sidebarSort?.value || 'lines';
|
||||
const items = Array.from(sidebar?.querySelectorAll('.lib-cpg-sidebar-item') || []);
|
||||
items.forEach(function (item) {
|
||||
item.hidden = !!query && !String(item.dataset.charName || '').toLocaleLowerCase().includes(query);
|
||||
});
|
||||
items.sort(function (a, b) {
|
||||
if (mode === 'name') return String(a.dataset.charName || '').localeCompare(String(b.dataset.charName || ''), undefined, { sensitivity: 'base' });
|
||||
if (mode === 'gender') {
|
||||
const genderOrder = String(a.dataset.charGender || '').localeCompare(String(b.dataset.charGender || ''), undefined, { sensitivity: 'base' });
|
||||
return genderOrder || String(a.dataset.charName || '').localeCompare(String(b.dataset.charName || ''), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return (parseInt(b.dataset.charLines || '0', 10) || 0) - (parseInt(a.dataset.charLines || '0', 10) || 0)
|
||||
|| String(a.dataset.charName || '').localeCompare(String(b.dataset.charName || ''), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
items.forEach(function (item) { sidebar.appendChild(item); });
|
||||
}
|
||||
sidebarSearch?.addEventListener('input', refreshCharacterSidebar);
|
||||
sidebarSort?.addEventListener('change', refreshCharacterSidebar);
|
||||
|
||||
pg.querySelector('.lib-cpg-back').addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@ -1407,6 +1505,9 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
toast('Character list could not be opened', 'error');
|
||||
});
|
||||
});
|
||||
pg.querySelector('.lcd-edit-book-context')?.addEventListener('click', function () {
|
||||
_editBookProfile(rec.book || '');
|
||||
});
|
||||
|
||||
// Both the initial letter and an existing portrait open the same clear
|
||||
// image workflow; clicking no longer launches an unexplained file picker.
|
||||
@ -1435,6 +1536,35 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
}
|
||||
});
|
||||
|
||||
pg.querySelector('.lcd-consistent-images-gen')?.addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
const btn = this;
|
||||
if (!_libStr(sh.image_prompt).trim() || !_libStr(sh.concept_art_prompt).trim()) {
|
||||
toast('Generate both the Character Image Prompt and Character Reference Prompt first', 'error');
|
||||
return;
|
||||
}
|
||||
const orig = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
|
||||
try {
|
||||
const style = pg.querySelector('.lcd-visual-style')?.value || 'cinematic';
|
||||
await _charAutoGenerateConsistentImages(rec, style);
|
||||
toast('Matching profile and reference images generated', 'success');
|
||||
_charDetailPage(rec, allChars, opts);
|
||||
} catch (err) {
|
||||
toast('Consistent image generation failed: ' + (err.message || err), 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = orig;
|
||||
}
|
||||
});
|
||||
|
||||
pg.querySelector('.lcd-visual-style')?.addEventListener('change', async function () {
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
rec.sheet.visual_style = this.value === 'concept' ? 'concept' : 'cinematic';
|
||||
rec.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(rec);
|
||||
});
|
||||
|
||||
pg.querySelector('.lcd-conceptart-img, .lcd-conceptart-empty')?.addEventListener('click', function () {
|
||||
_openConceptArtLightbox(rec, function () {
|
||||
_charDetailPage(rec, allChars, opts);
|
||||
@ -1494,6 +1624,90 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
toast('Prompt copied', 'success');
|
||||
});
|
||||
});
|
||||
pg.querySelectorAll('.lcd-save-prompt').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
const text = btn.closest('.lcd-prompt-body')?.querySelector('.lcd-prompt-text')?.textContent.trim() || '';
|
||||
if (!text) { toast('Prompt is empty', 'error'); return; }
|
||||
const sheetKey = btn.dataset.sheetKey || 'voice_design_prompt';
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Saving…';
|
||||
try {
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
rec.sheet[sheetKey] = text;
|
||||
rec.sheet[sheetKey + '_user_edited'] = true;
|
||||
rec.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(rec);
|
||||
// Voice prompts are also available as reusable design presets; the
|
||||
// other three prompt types belong only to this character record.
|
||||
if (sheetKey === 'voice_design_prompt') {
|
||||
const key = 'vcf-design-presets';
|
||||
const presets = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
const base = String(rec.name || 'Voice Design').trim() || 'Voice Design';
|
||||
let name = base;
|
||||
let n = 2;
|
||||
while (presets[name] && presets[name].description !== text) name = base + ' ' + n++;
|
||||
presets[name] = { description: text, sample_text: '', language: _charLang(rec) || 'Auto', gender: _libStr(rec.sheet?.gender) || 'N' };
|
||||
localStorage.setItem(key, JSON.stringify(presets));
|
||||
if (typeof syncDesignPresetsToServer === 'function') syncDesignPresetsToServer();
|
||||
}
|
||||
toast('Prompt saved and character updated', 'success');
|
||||
} catch (err) { toast('Could not save prompt: ' + (err.message || err), 'error'); }
|
||||
finally { btn.disabled = false; btn.innerHTML = original; }
|
||||
});
|
||||
});
|
||||
pg.querySelectorAll('.lcd-regenerate-voice').forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
const prompt = pg.querySelector('.lcd-prompt-box[data-sheet-key="voice_design_prompt"] .lcd-prompt-text')?.textContent.trim()
|
||||
|| pg.querySelector('.lcd-voice-prompt-box .lcd-prompt-text')?.textContent.trim() || '';
|
||||
if (!prompt) {
|
||||
toast('Voice design prompt is empty', 'error');
|
||||
return;
|
||||
}
|
||||
const generate = async function () {
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating Voice…';
|
||||
try {
|
||||
// The visible editor is authoritative: persist it first, then pass
|
||||
// exactly that same text to generation. No intermediate editor
|
||||
// dialog and no hidden prompt rewrite.
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
rec.sheet.voice_design_prompt = prompt;
|
||||
rec.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(rec);
|
||||
await _charAutoDesignVoice(rec, true, prompt);
|
||||
_schedulePendingTtsRestart();
|
||||
toast('New voice generated for ' + rec.name, 'success');
|
||||
_charDetailPage(rec, allChars, opts);
|
||||
} catch (err) {
|
||||
toast('Voice generation failed: ' + (err.message || err), 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
}
|
||||
};
|
||||
if (!rec.voice) {
|
||||
await generate();
|
||||
return;
|
||||
}
|
||||
const confirmRow = pg.querySelector('.lcd-voice-replace-confirm');
|
||||
if (!confirmRow) return;
|
||||
confirmRow.hidden = false;
|
||||
confirmRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
const cancel = confirmRow.querySelector('.lcd-voice-replace-cancel');
|
||||
const replace = confirmRow.querySelector('.lcd-voice-replace-ok');
|
||||
cancel.onclick = function () { confirmRow.hidden = true; };
|
||||
replace.onclick = async function () {
|
||||
confirmRow.hidden = true;
|
||||
replace.disabled = true;
|
||||
try {
|
||||
await generate();
|
||||
} finally {
|
||||
replace.disabled = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
// One Generate button per prompt box — the server accepts a `fields` filter
|
||||
// so clicking just one no longer burns tokens (re)generating all four.
|
||||
pg.querySelectorAll('.lcd-gen-prompt').forEach(function (btn) {
|
||||
@ -1510,13 +1724,14 @@ async function _charDetailPage(rec, allChars, opts) {
|
||||
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
|
||||
const r = await fetch('/api/character-generate-prompts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model, fields: [key] }),
|
||||
body: JSON.stringify({ name: rec.name, book: rec.book || '', book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}, sheet: sh2, visual_style: pg.querySelector('.lcd-visual-style')?.value || sh2.visual_style || 'cinematic', language, llm_url: target.url, model: target.model, fields: [key] }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
||||
const d = await r.json();
|
||||
if (!d[key]) throw new Error('Empty response — try again');
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
rec.sheet[key] = d[key];
|
||||
rec.sheet[key + '_user_edited'] = false;
|
||||
rec.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(rec);
|
||||
toast('Prompt generated', 'success');
|
||||
@ -2215,7 +2430,7 @@ async function _saveBookProfile(book, profile) {
|
||||
const key = String(book || '').trim();
|
||||
const r = await fetch('/api/book-profile', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(Object.assign({ book: key }, profile)),
|
||||
body: JSON.stringify(Object.assign({ book: key, confirmed: true }, profile)),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
@ -2385,7 +2600,7 @@ function _charSearchOnline(rec) {
|
||||
|
||||
// Small modal to set/edit a book's genre/setting/era/language once, instead
|
||||
// of every character's prompt guessing its own — see _getBookProfile.
|
||||
function _editBookProfile(book) {
|
||||
function _editBookProfile(book, afterSave) {
|
||||
_getBookProfile(book).then(function (profile) {
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'audiobook-overlay';
|
||||
@ -2404,25 +2619,28 @@ function _editBookProfile(book) {
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
document.body.appendChild(ov);
|
||||
const close = function () { ov.remove(); };
|
||||
const close = function (result) { ov.remove(); if (typeof afterSave === 'function') afterSave(result || null); };
|
||||
ov.querySelector('#bctx-cancel').addEventListener('click', close);
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
ov.querySelector('#bctx-save').addEventListener('click', async function () {
|
||||
const btn = this;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await _saveBookProfile(book, {
|
||||
const saved = await _saveBookProfile(book, {
|
||||
genre: ov.querySelector('#bctx-genre').value,
|
||||
setting: ov.querySelector('#bctx-setting').value,
|
||||
era: ov.querySelector('#bctx-era').value,
|
||||
language: ov.querySelector('#bctx-lang').value,
|
||||
});
|
||||
toast('Book context saved for ' + book, 'success');
|
||||
close();
|
||||
close(saved);
|
||||
} catch (e) { toast('Failed to save: ' + (e.message || e), 'error'); btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
}
|
||||
window.openBookContextEditor = function (book) {
|
||||
return new Promise(function (resolve) { _editBookProfile(book, resolve); });
|
||||
};
|
||||
|
||||
// Confirmation before silently reusing a series voice, for this DELIBERATE,
|
||||
// single-character "design a voice" action — confirmed live as a genuinely
|
||||
@ -2531,19 +2749,27 @@ async function _charDesignVoice(rec) {
|
||||
// a single Generate button that calls _charAutoDesignVoice with the edited
|
||||
// text as an override (skipping the cross-book reuse suggestion, since
|
||||
// editing the prompt already signals the user wants a specific new voice).
|
||||
function _charDesignVoiceInline(rec) {
|
||||
function _charDesignVoiceInline(rec, promptOverride) {
|
||||
const sh = rec.sheet || {};
|
||||
const lang = _charLang(rec);
|
||||
const savedPrompt = _libStr(sh.voice_design_prompt).trim();
|
||||
const instruct = _voiceIdentityPrompt(rec, savedPrompt || _buildVoicePrompt(rec, null, lang), lang);
|
||||
// Prefer text explicitly passed from the open editor so regeneration never
|
||||
// reopens an older saved prompt while an autosave is still pending.
|
||||
const savedPrompt = _libStr(promptOverride || sh.voice_design_prompt).trim();
|
||||
// Saved/manual prompts are already complete and must be shown verbatim.
|
||||
// Only synthesize the identity prefix when there is no saved prompt yet.
|
||||
const instruct = savedPrompt
|
||||
? savedPrompt
|
||||
: _voiceIdentityPrompt(rec, _buildVoicePrompt(rec, null, lang), lang);
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'audiobook-overlay';
|
||||
ov.innerHTML = '<div class="audiobook-box" style="max-width:560px;">'
|
||||
ov.innerHTML = '<div class="audiobook-box cdi-voice-dialog">'
|
||||
+ '<div class="audiobook-title"><span class="mdi mdi-creation"></span> Voice design prompt — ' + escHtml(rec.name) + '</div>'
|
||||
+ '<p class="card-subtitle" style="margin:8px 0 12px;">Edit the description, then generate a new voice from it. This replaces ' + (rec.voice ? 'the current voice' : 'this character’s voice') + '.</p>'
|
||||
+ '<textarea id="cdi-instruct" class="input" style="width:100%; min-height:160px; resize:vertical; font-family:inherit;">' + escHtml(instruct) + '</textarea>'
|
||||
+ '<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">'
|
||||
+ '<div class="cdi-voice-actions">'
|
||||
+ '<button type="button" class="btn-secondary btn-sm" id="cdi-cancel">Cancel</button>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm" id="cdi-save"><span class="mdi mdi-content-save-outline"></span> Save / update prompt</button>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm" id="cdi-rewrite"><span class="mdi mdi-creation"></span> Regenerate prompt</button>'
|
||||
+ '<button type="button" class="btn-primary btn-sm" id="cdi-generate"><span class="mdi mdi-creation"></span> Generate voice</button>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
@ -2551,14 +2777,79 @@ function _charDesignVoiceInline(rec) {
|
||||
const close = function () { ov.remove(); };
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
ov.querySelector('#cdi-cancel').addEventListener('click', close);
|
||||
let promptSaveTimer = null;
|
||||
const saveInlinePrompt = async function (text) {
|
||||
text = String(text || '').trim();
|
||||
if (!text) return;
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
rec.sheet.voice_design_prompt = text;
|
||||
rec.updated = new Date();
|
||||
if (typeof clPut === 'function') {
|
||||
const saved = await clPut(rec);
|
||||
if (saved && saved.sheet) rec.sheet = saved.sheet;
|
||||
}
|
||||
};
|
||||
const promptInput = ov.querySelector('#cdi-instruct');
|
||||
promptInput.addEventListener('input', function () {
|
||||
clearTimeout(promptSaveTimer);
|
||||
promptSaveTimer = setTimeout(function () {
|
||||
saveInlinePrompt(promptInput.value).catch(function (err) {
|
||||
console.warn('[character prompt] autosave failed', err);
|
||||
});
|
||||
}, 600);
|
||||
});
|
||||
ov.querySelector('#cdi-save').addEventListener('click', async function (e) {
|
||||
const btn = e.currentTarget;
|
||||
const original = btn.innerHTML;
|
||||
clearTimeout(promptSaveTimer);
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Saving…';
|
||||
try {
|
||||
await saveInlinePrompt(promptInput.value);
|
||||
toast('Voice design prompt saved', 'success');
|
||||
} catch (err) {
|
||||
toast('Prompt save failed: ' + (err.message || err), 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
}
|
||||
});
|
||||
ov.querySelector('#cdi-rewrite').addEventListener('click', async function (e) {
|
||||
const btn = e.currentTarget;
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Rewriting…';
|
||||
try {
|
||||
const target = typeof statusLlmTarget === 'function' ? statusLlmTarget() : { url: '', model: '' };
|
||||
const sample = [sh.physical, sh.backstory, sh.motivation].filter(Boolean).join(' ');
|
||||
const language = typeof detectLang === 'function' && sample ? (detectLang(sample) || '') : '';
|
||||
const r = await fetch('/api/character-generate-prompts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: rec.name, book: rec.book || '', book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {}, sheet: sh, language, llm_url: target.url, model: target.model, fields: ['voice_design_prompt'] }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
|
||||
const d = await r.json();
|
||||
if (!d.voice_design_prompt) throw new Error('The rewrite returned an empty prompt');
|
||||
ov.querySelector('#cdi-instruct').value = d.voice_design_prompt;
|
||||
await saveInlinePrompt(d.voice_design_prompt);
|
||||
toast('Voice design prompt rewritten — review it, then generate the voice', 'success');
|
||||
} catch (err) {
|
||||
toast('Prompt rewrite failed: ' + (err.message || err), 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
}
|
||||
});
|
||||
ov.querySelector('#cdi-generate').addEventListener('click', async function (e) {
|
||||
const btn = e.currentTarget;
|
||||
const text = ov.querySelector('#cdi-instruct').value.trim();
|
||||
if (!text) { toast('Prompt is empty', 'error'); return; }
|
||||
clearTimeout(promptSaveTimer);
|
||||
btn.disabled = true;
|
||||
const icon = btn.querySelector('.mdi');
|
||||
if (icon) icon.className = 'mdi mdi-loading mdi-spin';
|
||||
try {
|
||||
await saveInlinePrompt(text);
|
||||
await _charAutoDesignVoice(rec, true, text);
|
||||
_schedulePendingTtsRestart();
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
@ -2763,7 +3054,12 @@ async function _charAutoDesignVoice(rec, force, instructOverride) {
|
||||
}
|
||||
const sh = rec.sheet || {};
|
||||
const langName = (await _resolveBookLang(rec)) || 'English';
|
||||
const instruct = _voiceIdentityPrompt(rec, instructOverride || _libStr(sh.voice_design_prompt).trim() || _buildVoicePrompt(rec, await _getBookProfile(rec.book), langName), langName);
|
||||
// An explicit edited prompt is authoritative. Do not decorate/rewrite it,
|
||||
// otherwise the editor, confirmation dialog, and generated voice silently
|
||||
// use three different prompts.
|
||||
const instruct = instructOverride
|
||||
? _libStr(instructOverride).trim()
|
||||
: _voiceIdentityPrompt(rec, _libStr(sh.voice_design_prompt).trim() || _buildVoicePrompt(rec, await _getBookProfile(rec.book), langName), langName);
|
||||
if (!instruct.trim()) throw new Error('No character description to design a voice from yet');
|
||||
if (instructOverride) sh.voice_design_prompt = instruct;
|
||||
const langCode = (typeof DESIGN_LANG_CODE !== 'undefined' && DESIGN_LANG_CODE[langName]) || 'EN';
|
||||
@ -2946,6 +3242,48 @@ async function _charAutoGenerateImage(rec, provider) {
|
||||
return d.image;
|
||||
}
|
||||
|
||||
// Generate both visual assets through the shared-identity ComfyUI workflow.
|
||||
// The backend creates a clean identity anchor from structured book/character
|
||||
// facts. A legacy portrait is deliberately not reused because its hats,
|
||||
// clothing, props, or scenery would be inherited by the new image pair.
|
||||
async function _charAutoGenerateConsistentImages(rec, visualStyle) {
|
||||
const sh = rec.sheet || {};
|
||||
const profilePrompt = _libStr(sh.image_prompt).trim();
|
||||
const referencePrompt = _libStr(sh.concept_art_prompt).trim();
|
||||
if (!profilePrompt) throw new Error('No Character Image Prompt — generate it first');
|
||||
if (!referencePrompt) throw new Error('No Character Reference Prompt — generate it first');
|
||||
const r = await fetch('/api/character-generate-consistent-images', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile_prompt: profilePrompt,
|
||||
reference_prompt: referencePrompt,
|
||||
name: rec.name || '',
|
||||
book: rec.book || '',
|
||||
sheet: sh,
|
||||
visual_style: visualStyle || 'cinematic',
|
||||
book_profile: typeof _getBookProfile === 'function' ? await _getBookProfile(rec.book) : {},
|
||||
identity_image: '',
|
||||
reuse_identity: false,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(function () { return {}; }); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
if (!d.profile || !d.reference) throw new Error('ComfyUI returned an incomplete image pair');
|
||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, d.profile);
|
||||
const current = (typeof clGet === 'function') ? await clGet(rec.id).catch(function () { return null; }) : null;
|
||||
const target = current || rec;
|
||||
target.image = d.profile;
|
||||
target.sheet = Object.assign({}, target.sheet || {}, { concept_art_image: d.reference });
|
||||
target.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(target);
|
||||
rec.image = d.profile;
|
||||
rec.sheet = Object.assign({}, sh, { concept_art_image: d.reference });
|
||||
document.querySelectorAll('.lib-char-avatar[data-char-id="' + CSS.escape(rec.id) + '"]').forEach(function (av) {
|
||||
av.innerHTML = '<img src="' + d.profile + '" alt="' + escHtml(rec.name) + '">';
|
||||
});
|
||||
return d.profile;
|
||||
}
|
||||
|
||||
// Generate an actual concept-art REFERENCE SHEET image from the character's
|
||||
// own concept_art_prompt — this field previously had no image action at
|
||||
// all (only "Copy"/"Regenerate" for the text itself), so having a real,
|
||||
|
||||
@ -1699,6 +1699,7 @@ async function readerOpenLibraryDoc(id) {
|
||||
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
|
||||
readerState.savedId = id;
|
||||
readerState.sourceUploaded = true; // already on the server
|
||||
if (typeof window.studioRememberBook === 'function') window.studioRememberBook(id, rec.title);
|
||||
readerState.savedAudioIdx = new Set();
|
||||
// mark which units already have audio on the server (fetched lazily on play)
|
||||
if (rec.sentenceCount === readerState.sentences.length) {
|
||||
@ -1729,6 +1730,10 @@ async function readerOpenLibraryDoc(id) {
|
||||
else showReaderView('main');
|
||||
}
|
||||
|
||||
// Studio uses this explicit entry point to restore the selected book after a
|
||||
// browser refresh, when the in-memory reader state has been cleared.
|
||||
window.readerOpenLibraryDoc = readerOpenLibraryDoc;
|
||||
|
||||
function readerSetSelectValue(id, value) {
|
||||
const sel = $(id); if (!sel) return;
|
||||
if (![...sel.options].some(o => o.value === value)) {
|
||||
|
||||
@ -11,8 +11,53 @@
|
||||
// the same element ids would collide; physically relocating the existing
|
||||
// node (same id, same listeners) does not.
|
||||
|
||||
const STUDIO_RESUME_KEY = 'ttsvc_studio_resume';
|
||||
let _stuActive = 1;
|
||||
|
||||
function _stuReadResume() {
|
||||
try { return JSON.parse(localStorage.getItem(STUDIO_RESUME_KEY) || '{}') || {}; }
|
||||
catch (_) { return {}; }
|
||||
}
|
||||
|
||||
function _stuSaveResume(phase) {
|
||||
try {
|
||||
const previous = _stuReadResume();
|
||||
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
|
||||
phase: phase,
|
||||
bookId: window._audiobook?.bookId || window.readerState?.savedId || previous.bookId || '',
|
||||
bookTitle: window.readerState?.title || previous.bookTitle || '',
|
||||
characterId: previous.characterId || '',
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
window.studioRememberBook = function (bookId, title) {
|
||||
try {
|
||||
const previous = _stuReadResume();
|
||||
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
|
||||
phase: _stuActive || previous.phase || 1,
|
||||
bookId: bookId || previous.bookId || '',
|
||||
bookTitle: title || previous.bookTitle || '',
|
||||
characterId: previous.characterId || '',
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
window.studioRememberCharacter = function (characterId) {
|
||||
try {
|
||||
const previous = _stuReadResume();
|
||||
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
|
||||
phase: previous.phase || _stuActive || 3,
|
||||
bookId: previous.bookId || window._audiobook?.bookId || '',
|
||||
bookTitle: previous.bookTitle || window.readerState?.title || '',
|
||||
characterId: characterId || '',
|
||||
savedAt: Date.now(),
|
||||
}));
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
// id -> { parent, next } — where a borrowed node came from, so it can go home.
|
||||
const _stuHomes = new Map();
|
||||
|
||||
@ -316,6 +361,21 @@ document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(functio
|
||||
tab.addEventListener('click', function () { _stuShowCastView(tab.dataset.stuCastView); });
|
||||
});
|
||||
|
||||
// Keep book context editing in the compact Studio toolbar. The context is
|
||||
// deliberately available before identification, without consuming a full
|
||||
// banner row inside the casting panel.
|
||||
document.getElementById('stu-book-context-btn')?.addEventListener('click', async function () {
|
||||
const bookTitle = window.readerState?.title || '';
|
||||
if (!bookTitle || typeof window.openBookContextEditor !== 'function') return;
|
||||
await window.openBookContextEditor(bookTitle);
|
||||
});
|
||||
|
||||
document.getElementById('stu-change-book-btn')?.addEventListener('click', function () {
|
||||
// The Source phase contains the existing uploaded-book cards. Keep the
|
||||
// current production intact until the user explicitly chooses another.
|
||||
showStudioPhase(1);
|
||||
});
|
||||
|
||||
// The borrowed panel's own footer (#ab-cv-foot: "Identify Characters ⌄" /
|
||||
// "Cast Characters ⌄" / "Cast ⌄") is a richer, real action bar (Scan/Verify,
|
||||
// Cast all/Continue uncasted/Cast selected/recast, view/export) that Studio's
|
||||
@ -449,6 +509,7 @@ function _stuRestoreCastFoot() {
|
||||
|
||||
function showStudioPhase(n) {
|
||||
_stuActive = n;
|
||||
_stuSaveResume(n);
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const el = document.getElementById('stu-phase-' + i);
|
||||
if (el) el.hidden = (i !== n);
|
||||
@ -488,6 +549,23 @@ function studioOnShow() {
|
||||
// .is-active class has actually been applied by nav.js (which happens
|
||||
// AFTER this hook runs).
|
||||
_stuIsActive = true;
|
||||
const resume = _stuReadResume();
|
||||
if (resume.bookId && window._audiobook) window._audiobook.bookId = resume.bookId;
|
||||
if (resume.phase >= 1 && resume.phase <= 4) {
|
||||
_stuActive = resume.phase;
|
||||
if (resume.characterId) window._libResumeCharacterId = resume.characterId;
|
||||
}
|
||||
// A hard refresh clears the in-memory reader document. Reopen the book
|
||||
// remembered by Studio before entering Characters, otherwise the cast view
|
||||
// incorrectly reports “Import a document first”.
|
||||
const resumeBookId = resume.bookId;
|
||||
const hasCurrentBook = !!(window.readerState?.sentences?.length && window.readerState?.savedId);
|
||||
if (resumeBookId && !hasCurrentBook && typeof window.readerOpenLibraryDoc === 'function') {
|
||||
window.readerOpenLibraryDoc(resumeBookId).then(function () {
|
||||
showStudioPhase(_stuActive);
|
||||
});
|
||||
return;
|
||||
}
|
||||
showStudioPhase(_stuActive);
|
||||
}
|
||||
window.studioOnShow = studioOnShow;
|
||||
|
||||
1
static/samples/ab/stats.json
Normal file
1
static/samples/ab/stats.json
Normal file
@ -0,0 +1 @@
|
||||
{"cv_happy_run0": 158, "cv_happy_run1": 269, "cv_happy_run2": 178, "cv_sad_run0": 125, "cv_sad_run1": 116, "cv_sad_run2": 119, "vd_happy_run0": 163, "vd_happy_run1": 238, "vd_happy_run2": 178, "vd_sad_run0": 190, "vd_sad_run1": 130, "vd_sad_run2": 214}
|
||||
1
static/samples/canon/stats.json
Normal file
1
static/samples/canon/stats.json
Normal file
@ -0,0 +1 @@
|
||||
{"none": [177, 3.99, 0.108], "[whisper]": [185, 4.37, 0.1021], "[low volume]": [188, 4.04, 0.0982], "[low voice]": [188, 4.04, 0.0982], "[loud]": [180, 3.9, 0.1007], "[volume up]": [183, 4.18, 0.1063], "[screaming]": [195, 3.85, 0.1127], "[shouting]": [186, 3.81, 0.1067], "[sad]": [182, 4.32, 0.0956], "[angry]": [187, 4.04, 0.0974], "[delight]": [181, 4.13, 0.1041], "[shocked]": [182, 4.04, 0.1121]}
|
||||
1
static/samples/clone/stats.json
Normal file
1
static/samples/clone/stats.json
Normal file
@ -0,0 +1 @@
|
||||
[["whisper", 3.92, 0.062159258872270584, 241.72849223335226], ["sad", 4.08, 0.06933707743883133, 231.93486615774808], ["neutral", 3.92, 0.066058449447155, 222.10213617948165], ["scared", 3.92, 0.0758277103304863, 249.98229014031475], ["happy", 3.76, 0.06555065512657166, 233.54039635323997], ["angry", 3.92, 0.07043411582708359, 231.01072213430118]]
|
||||
1
static/samples/clone/stats_only.json
Normal file
1
static/samples/clone/stats_only.json
Normal file
@ -0,0 +1 @@
|
||||
[["whisper", 4.32, 0.07170327752828598, 205.90466589691226], ["sad", 4.16, 0.057622577995061874, 170.46017057067849], ["neutral", 3.84, 0.0707193985581398, 224.89668431811384], ["scared", 4.16, 0.07150360941886902, 238.6658619958772], ["happy", 3.84, 0.07040099054574966, 233.53034816554657], ["angry", 3.92, 0.07094117999076843, 233.24904356308747]]
|
||||
1
static/samples/cv/stats.json
Normal file
1
static/samples/cv/stats.json
Normal file
@ -0,0 +1 @@
|
||||
[["whisper", 5.76, 0.0365188904106617, 120.9247432976325], ["sad", 5.92, 0.04318904131650925, 187.38621022379402], ["neutral", 5.12, 0.02466031163930893, 134.47394115948856], ["happy", 4.88, 0.06555867195129395, 187.04688959379465], ["scared", 6.16, 0.05431271716952324, 163.42342261804015], ["angry", 4.08, 0.05320056155323982, 206.1807382039959]]
|
||||
1
static/samples/en5/stats.json
Normal file
1
static/samples/en5/stats.json
Normal file
@ -0,0 +1 @@
|
||||
{"simple_neutral": [187, 4.23, 0.0956, "I told you already, and now it is too late for excuses."], "simple_happy": [186, 3.95, 0.0806, "[happy] I told you already, and now it is too late for excuses."], "simple_sad": [186, 4.18, 0.0838, "[sad] I told you already, and now it is too late for excuses."], "simple_angry": [186, 4.32, 0.1009, "[angry] I told you already, and now it is too late for excuses."], "simple_scared": [197, 3.95, 0.1027, "[scared] I told you already, and now it is too late for excuses."], "rich_neutral": [187, 4.23, 0.0956, "I told you already, and now it is too late for excuses."], "rich_happy": [185, 4.04, 0.0898, "[bright and delighted, barely containing a grin] I told you already, and now it is too late for excuses."], "rich_sad": [188, 4.27, 0.0816, "[voice rough from crying, trying to sound normal] I told you already, and now it is too late for excuses."], "rich_angry": [196, 5.25, 0.0796, "[furious, teeth clenched, spitting the words out] I told you already, and now it is too late for excuses."], "rich_scared": [183, 3.95, 0.0946, "[breathless with fear, voice trembling] I told you already, and now it is too late for excuses."]}
|
||||
1
static/samples/fishmap/stats.json
Normal file
1
static/samples/fishmap/stats.json
Normal file
@ -0,0 +1 @@
|
||||
{"neutral": [160, 3.9, 0.047], "sad": [91, 4.46, 0.0205], "sobbing": [177, 4.6, 0.052], "whispering": [101, 3.95, 0.0211], "calm": [102, 3.81, 0.0244], "resigned": [115, 4.27, 0.0353], "disappointed": [105, 3.99, 0.0231], "tender": [168, 3.44, 0.0413], "curious": [104, 3.99, 0.0274], "confident": [194, 4.32, 0.0591], "sarcastic": [200, 4.04, 0.064], "happy": [203, 3.9, 0.0642], "excited": [230, 4.18, 0.0644], "angry": [226, 4.5, 0.0649], "scared": [223, 4.23, 0.0639], "shouting": [234, 4.04, 0.0649]}
|
||||
1
static/samples/fixed/stats.json
Normal file
1
static/samples/fixed/stats.json
Normal file
@ -0,0 +1 @@
|
||||
[["fish_angry.wav", 4.504671201814059, 0.06494688987731934, 225.75760285080554], ["fish_happy.wav", 3.900952380952381, 0.06421244889497757, 203.42632819142833], ["fish_neutral.wav", 3.900952380952381, 0.0469849593937397, 160.12848521981064], ["fish_sad.wav", 4.458231292517007, 0.02046436071395874, 91.45606740704154], ["fish_scared.wav", 4.2260317460317465, 0.06387874484062195, 223.45062208675228], ["fish_whisper.wav", 3.947392290249433, 0.021135646849870682, 100.54120279187988], ["qwen_angry.wav", 4.48, 0.07321401685476303, 268.64705517416706], ["qwen_happy.wav", 3.84, 0.06605295836925507, 225.1418578552891], ["qwen_neutral.wav", 3.92, 0.03878803923726082, 179.30881848496767], ["qwen_sad.wav", 5.76, 0.04751121252775192, 141.99477795203393], ["qwen_scared.wav", 3.2, 0.0778232142329216, 202.59172414436782], ["qwen_whisper.wav", 4.48, 0.048452772200107574, 128.1539575097085]]
|
||||
283
static/samples/index.html
Normal file
283
static/samples/index.html
Normal file
@ -0,0 +1,283 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>Emotion samples - book03</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;background:#1e1e2e;color:#cdd6f4;padding:24px;max-width:900px;margin:0 auto}
|
||||
h1{color:#f9e2af} h2{color:#f5c2e7;margin-top:40px;border-bottom:2px solid #45475a;padding-bottom:6px}
|
||||
h3{color:#89b4fa;margin-top:28px;border-bottom:1px solid #313244;padding-bottom:4px}
|
||||
table{width:100%;border-collapse:collapse;margin-top:8px}
|
||||
td,th{padding:8px;border-bottom:1px solid #313244;text-align:left;vertical-align:middle}
|
||||
th{color:#a6adc8;font-weight:600}
|
||||
audio{width:280px;height:32px}
|
||||
p.note{color:#a6adc8}
|
||||
</style></head><body>
|
||||
<h1>Emotion fix - sample comparison</h1>
|
||||
<p class="note">Same lines, same voices, run through both backends after the fix. Fish-Speech gets a translated English [tag] baked into the text; Qwen3 Voice Design gets the native German instruct sentence, unchanged.</p>
|
||||
|
||||
<h2>1. Book-realistic emotions (as the LLM actually assigns them, e.g. "befehlend", "resigniert")</h2>
|
||||
<h3>MARCIAN</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>befehlend</td><td><audio controls src="marcian_1_befehlend_fish.mp3"></audio></td><td><audio controls src="marcian_1_befehlend_qwen3design.mp3"></audio></td></tr><tr><td>kalt und zynisch</td><td><audio controls src="marcian_2_kalt_und_zynisch_fish.mp3"></audio></td><td><audio controls src="marcian_2_kalt_und_zynisch_qwen3design.mp3"></audio></td></tr><tr><td>fragend</td><td><audio controls src="marcian_3_fragend_fish.mp3"></audio></td><td><audio controls src="marcian_3_fragend_qwen3design.mp3"></audio></td></tr><tr><td>entschlossen</td><td><audio controls src="marcian_4_entschlossen_fish.mp3"></audio></td><td><audio controls src="marcian_4_entschlossen_qwen3design.mp3"></audio></td></tr><tr><td>ernst</td><td><audio controls src="marcian_5_ernst_fish.mp3"></audio></td><td><audio controls src="marcian_5_ernst_qwen3design.mp3"></audio></td></tr></table><h3>LYSANDRA</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>resigniert</td><td><audio controls src="lysandra_1_resigniert_fish.mp3"></audio></td><td><audio controls src="lysandra_1_resigniert_qwen3design.mp3"></audio></td></tr><tr><td>besorgt</td><td><audio controls src="lysandra_2_besorgt_fish.mp3"></audio></td><td><audio controls src="lysandra_2_besorgt_qwen3design.mp3"></audio></td></tr><tr><td>abweisend</td><td><audio controls src="lysandra_3_abweisend_fish.mp3"></audio></td><td><audio controls src="lysandra_3_abweisend_qwen3design.mp3"></audio></td></tr><tr><td>verächtlich</td><td><audio controls src="lysandra_4_verachtlich_fish.mp3"></audio></td><td><audio controls src="lysandra_4_verachtlich_qwen3design.mp3"></audio></td></tr><tr><td>schroff</td><td><audio controls src="lysandra_5_schroff_fish.mp3"></audio></td><td><audio controls src="lysandra_5_schroff_qwen3design.mp3"></audio></td></tr></table><h3>DARRAG</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>befehlend</td><td><audio controls src="darrag_1_befehlend_fish.mp3"></audio></td><td><audio controls src="darrag_1_befehlend_qwen3design.mp3"></audio></td></tr><tr><td>entschlossen</td><td><audio controls src="darrag_2_entschlossen_fish.mp3"></audio></td><td><audio controls src="darrag_2_entschlossen_qwen3design.mp3"></audio></td></tr><tr><td>dringend</td><td><audio controls src="darrag_3_dringend_fish.mp3"></audio></td><td><audio controls src="darrag_3_dringend_qwen3design.mp3"></audio></td></tr><tr><td>drohend</td><td><audio controls src="darrag_4_drohend_fish.mp3"></audio></td><td><audio controls src="darrag_4_drohend_qwen3design.mp3"></audio></td></tr><tr><td>überrascht</td><td><audio controls src="darrag_5_uberrascht_fish.mp3"></audio></td><td><audio controls src="darrag_5_uberrascht_qwen3design.mp3"></audio></td></tr></table>
|
||||
|
||||
<h2>2. Basic emotions (happy / sad / angry / scared / excited)</h2>
|
||||
<h3>MARCIAN</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>happy (glücklich)</td><td><audio controls src="basic_marcian_1_happy_fish.mp3"></audio></td><td><audio controls src="basic_marcian_1_happy_qwen3design.mp3"></audio></td></tr><tr><td>sad (traurig)</td><td><audio controls src="basic_marcian_2_sad_fish.mp3"></audio></td><td><audio controls src="basic_marcian_2_sad_qwen3design.mp3"></audio></td></tr><tr><td>angry (wütend)</td><td><audio controls src="basic_marcian_3_angry_fish.mp3"></audio></td><td><audio controls src="basic_marcian_3_angry_qwen3design.mp3"></audio></td></tr><tr><td>scared (ängstlich)</td><td><audio controls src="basic_marcian_4_scared_fish.mp3"></audio></td><td><audio controls src="basic_marcian_4_scared_qwen3design.mp3"></audio></td></tr><tr><td>excited (aufgeregt)</td><td><audio controls src="basic_marcian_5_excited_fish.mp3"></audio></td><td><audio controls src="basic_marcian_5_excited_qwen3design.mp3"></audio></td></tr></table><h3>LYSANDRA</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>happy (glücklich)</td><td><audio controls src="basic_lysandra_1_happy_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_1_happy_qwen3design.mp3"></audio></td></tr><tr><td>sad (traurig)</td><td><audio controls src="basic_lysandra_2_sad_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_2_sad_qwen3design.mp3"></audio></td></tr><tr><td>angry (wütend)</td><td><audio controls src="basic_lysandra_3_angry_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_3_angry_qwen3design.mp3"></audio></td></tr><tr><td>scared (ängstlich)</td><td><audio controls src="basic_lysandra_4_scared_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_4_scared_qwen3design.mp3"></audio></td></tr><tr><td>excited (aufgeregt)</td><td><audio controls src="basic_lysandra_5_excited_fish.mp3"></audio></td><td><audio controls src="basic_lysandra_5_excited_qwen3design.mp3"></audio></td></tr></table><h3>DARRAG</h3><table><tr><th>Emotion</th><th>Fish-Speech</th><th>Qwen3 Voice Design</th></tr><tr><td>happy (glücklich)</td><td><audio controls src="basic_darrag_1_happy_fish.mp3"></audio></td><td><audio controls src="basic_darrag_1_happy_qwen3design.mp3"></audio></td></tr><tr><td>sad (traurig)</td><td><audio controls src="basic_darrag_2_sad_fish.mp3"></audio></td><td><audio controls src="basic_darrag_2_sad_qwen3design.mp3"></audio></td></tr><tr><td>angry (wütend)</td><td><audio controls src="basic_darrag_3_angry_fish.mp3"></audio></td><td><audio controls src="basic_darrag_3_angry_qwen3design.mp3"></audio></td></tr><tr><td>scared (ängstlich)</td><td><audio controls src="basic_darrag_4_scared_fish.mp3"></audio></td><td><audio controls src="basic_darrag_4_scared_qwen3design.mp3"></audio></td></tr><tr><td>excited (aufgeregt)</td><td><audio controls src="basic_darrag_5_excited_fish.mp3"></audio></td><td><audio controls src="basic_darrag_5_excited_qwen3design.mp3"></audio></td></tr></table>
|
||||
|
||||
<h2>3. Controlled test — SAME exact line, only the tag/instruct changes</h2>
|
||||
<p class="note">This isolates the emotion variable cleanly (sections 1 & 2 above used different lines per emotion). Measured acoustics (pitch/loudness) are shown next to each — Fish-Speech shows a large, consistent shift (sad/whisper much lower & quieter, angry/happy higher & louder); Qwen3 shows a strong shift for "happy" but almost none for "angry".</p>
|
||||
<h3>Fish-Speech</h3>
|
||||
<table><tr><th>Version</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness (RMS)</th></tr>
|
||||
<tr><td>Neutral (no tag)</td><td><audio controls src="ctrl/neutral_notag.mp3"></audio></td><td>160</td><td>0.047</td></tr>
|
||||
<tr><td>[angry]</td><td><audio controls src="ctrl/angry_tag.mp3"></audio></td><td>226</td><td>0.065</td></tr>
|
||||
<tr><td>[happy]</td><td><audio controls src="ctrl/happy_tag.mp3"></audio></td><td>203</td><td>0.064</td></tr>
|
||||
<tr><td>[sad]</td><td><audio controls src="ctrl/sad_tag.mp3"></audio></td><td>92</td><td>0.021</td></tr>
|
||||
<tr><td>[whisper]</td><td><audio controls src="ctrl/whisper_tag.mp3"></audio></td><td>84</td><td>0.020</td></tr>
|
||||
</table>
|
||||
<h3>Qwen3 Voice Design</h3>
|
||||
<table><tr><th>Version</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness (RMS)</th></tr>
|
||||
<tr><td>Neutral</td><td><audio controls src="ctrl/neutral_qwen.mp3"></audio></td><td>158</td><td>0.057</td></tr>
|
||||
<tr><td>Angry</td><td><audio controls src="ctrl/angry_qwen.mp3"></audio></td><td>158</td><td>0.065</td></tr>
|
||||
<tr><td>Happy</td><td><audio controls src="ctrl/happy_qwen.mp3"></audio></td><td>310</td><td>0.072</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>4. English text/voice test (same character-role voice, English content)</h2>
|
||||
<p class="note">Testing whether emotion differentiation is stronger in English than German. Pitch numbers below are measured (not felt) — Fish-Speech barely moves at all in English (72-80Hz across every emotion); Qwen3 Voice Design shows a genuinely clean, coherent gradient: sad < angry < shocked < happy. This is the clearest sign yet that <strong>Qwen3 Voice Design's emotional differentiation is much stronger in English than German</strong> — Fish-Speech stays weak regardless of language.</p>
|
||||
<h3>Fish-Speech (English)</h3>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th></tr>
|
||||
<tr><td>Neutral</td><td><audio controls src="ctrl6/en_fish_neutral.mp3"></audio></td><td>76</td><td>0.080</td></tr>
|
||||
<tr><td>Whispering</td><td><audio controls src="ctrl6/en_fish_whispering.mp3"></audio></td><td>75</td><td>0.066</td></tr>
|
||||
<tr><td>Sad</td><td><audio controls src="ctrl6/en_fish_sad.mp3"></audio></td><td>80</td><td>0.073</td></tr>
|
||||
<tr><td>Angry</td><td><audio controls src="ctrl6/en_fish_angry.mp3"></audio></td><td>73</td><td>0.088</td></tr>
|
||||
<tr><td>Happy</td><td><audio controls src="ctrl6/en_fish_happy.mp3"></audio></td><td>72</td><td>0.082</td></tr>
|
||||
<tr><td>Shocked</td><td><audio controls src="ctrl6/en_fish_shocked.mp3"></audio></td><td>80</td><td>0.077</td></tr>
|
||||
</table>
|
||||
<h3>Qwen3 Voice Design (English)</h3>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th></tr>
|
||||
<tr><td>Neutral</td><td><audio controls src="ctrl6/en_qwen_neutral.mp3"></audio></td><td>109</td><td>0.066</td></tr>
|
||||
<tr><td>Whispering</td><td><audio controls src="ctrl6/en_qwen_whispering.mp3"></audio></td><td>(breathy, pitch untrackable)</td><td>0.040</td></tr>
|
||||
<tr><td>Sad</td><td><audio controls src="ctrl6/en_qwen_sad.mp3"></audio></td><td>274</td><td>0.049</td></tr>
|
||||
<tr><td>Angry</td><td><audio controls src="ctrl6/en_qwen_angry.mp3"></audio></td><td>297</td><td>0.090</td></tr>
|
||||
<tr><td>Shocked</td><td><audio controls src="ctrl6/en_qwen_shocked.mp3"></audio></td><td>348</td><td>0.093</td></tr>
|
||||
<tr><td>Happy</td><td><audio controls src="ctrl6/en_qwen_happy.mp3"></audio></td><td>398</td><td>0.091</td></tr>
|
||||
</table>
|
||||
|
||||
<h2>5. German text + English instruction (Qwen3 Voice Design, Marcian's cloned voice)</h2>
|
||||
<p class="note">Same German dialogue line, but the instruct sentence is written in English instead of German, to see if that captures some of section 4's improvement while keeping the actual book text in German. Angry stood out clearly (340Hz vs ~200Hz baseline); sad/happy/shocked stayed muddled together.</p>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th></tr>
|
||||
<tr><td>Neutral</td><td><audio controls src="ctrl7/mix_neutral.mp3"></audio></td><td>223</td><td>0.062</td></tr>
|
||||
<tr><td>Sad</td><td><audio controls src="ctrl7/mix_sad.mp3"></audio></td><td>201</td><td>0.072</td></tr>
|
||||
<tr><td>Angry</td><td><audio controls src="ctrl7/mix_angry.mp3"></audio></td><td>340</td><td>0.097</td></tr>
|
||||
<tr><td>Happy</td><td><audio controls src="ctrl7/mix_happy.mp3"></audio></td><td>222</td><td>0.074</td></tr>
|
||||
<tr><td>Shocked</td><td><audio controls src="ctrl7/mix_shocked.mp3"></audio></td><td>175</td><td>0.068</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#a6e3a1">6. ⚠ RE-TEST on the FIXED build (this is the one that matters)</h2>
|
||||
<p class="note"><strong>Everything in sections 1–5 above was generated while a JavaScript bug was silently breaking the app.</strong>
|
||||
A <code>typeof</code> check on a <code>const</code> in its temporal dead zone was throwing during startup, which stopped
|
||||
the whole bundle from initializing past that point. Those earlier samples were measuring a half-working app, so please
|
||||
disregard them. Below is the same controlled test (identical German line, identical voice, only the emotion varying)
|
||||
re-run after the fix — plus emotion instructions are now written in English, which Qwen3 follows far more reliably.</p>
|
||||
<h3>Qwen3 Voice Design — German text, English instruct</h3>
|
||||
<p class="note">Now a clean, correctly-ordered gradient, and note the <strong>durations</strong> vary sensibly too: sad drags (5.76s), scared rushes (3.20s). That's real prosody, not just volume.</p>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="fixed/qwen_whisper.mp3"></audio></td><td>128</td><td>0.048</td><td>4.48s</td></tr><tr><td>Sad</td><td><audio controls src="fixed/qwen_sad.mp3"></audio></td><td>142</td><td>0.048</td><td>5.76s</td></tr><tr><td>Neutral</td><td><audio controls src="fixed/qwen_neutral.mp3"></audio></td><td>179</td><td>0.039</td><td>3.92s</td></tr><tr><td>Scared</td><td><audio controls src="fixed/qwen_scared.mp3"></audio></td><td>203</td><td>0.078</td><td>3.20s</td></tr><tr><td>Happy</td><td><audio controls src="fixed/qwen_happy.mp3"></audio></td><td>225</td><td>0.066</td><td>3.84s</td></tr><tr><td>Angry</td><td><audio controls src="fixed/qwen_angry.mp3"></audio></td><td>269</td><td>0.073</td><td>4.48s</td></tr></table>
|
||||
<h3>Fish-Speech — inline [tag]</h3>
|
||||
<p class="note">Still essentially two registers (quiet/low: whisper+sad — loud/high: everything else). Scared/happy/angry remain hard to tell apart.</p>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch (Hz)</th><th>Loudness</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="fixed/fish_whisper.mp3"></audio></td><td>101</td><td>0.021</td><td>3.95s</td></tr><tr><td>Sad</td><td><audio controls src="fixed/fish_sad.mp3"></audio></td><td>91</td><td>0.020</td><td>4.46s</td></tr><tr><td>Neutral</td><td><audio controls src="fixed/fish_neutral.mp3"></audio></td><td>160</td><td>0.047</td><td>3.90s</td></tr><tr><td>Scared</td><td><audio controls src="fixed/fish_scared.mp3"></audio></td><td>223</td><td>0.064</td><td>4.23s</td></tr><tr><td>Happy</td><td><audio controls src="fixed/fish_happy.mp3"></audio></td><td>203</td><td>0.064</td><td>3.90s</td></tr><tr><td>Angry</td><td><audio controls src="fixed/fish_angry.mp3"></audio></td><td>226</td><td>0.065</td><td>4.50s</td></tr></table>
|
||||
|
||||
<h2 style="color:#f38ba8">7. ★ THE ACTUAL FIX — voice_clone backend (read this one)</h2>
|
||||
<p class="note"><strong>Why everything above was misleading:</strong> Voice Design re-rolls the voice on every single call.
|
||||
I generated the <em>identical</em> prompt 4 times and got 174, 232, 174 and 264 Hz — an 89 Hz spread with nothing
|
||||
changed. That is the same size as the "emotion differences" I was reporting, so those numbers were mostly random noise.
|
||||
It also explains what you heard: the male→female jumps, and "sad sounds happy". Voice Design designs a <em>new</em>
|
||||
voice from the prompt; it is not a stable speaker, so it is the wrong tool for an audiobook.</p>
|
||||
<p class="note"><strong>voice_clone is deterministic</strong> — the same input produced bit-identical audio twice over.
|
||||
So on this backend, any difference you hear is genuinely the emotion, not a re-roll. Character identity holds
|
||||
(measured timbre drift ≤ 0.011).</p>
|
||||
<h3>A. Emotion instruct alone — <span style="color:#a6e3a1">recommended</span></h3>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th><th>Loudness</th></tr><tr><td>Whisper</td><td><audio controls src="clone/only_whisper.mp3"></audio></td><td>206</td><td>4.32s</td><td>0.072</td></tr><tr><td>Sad</td><td><audio controls src="clone/only_sad.mp3"></audio></td><td>170</td><td>4.16s</td><td>0.058</td></tr><tr><td>Neutral</td><td><audio controls src="clone/only_neutral.mp3"></audio></td><td>225</td><td>3.84s</td><td>0.071</td></tr><tr><td>Scared</td><td><audio controls src="clone/only_scared.mp3"></audio></td><td>239</td><td>4.16s</td><td>0.072</td></tr><tr><td>Happy</td><td><audio controls src="clone/only_happy.mp3"></audio></td><td>234</td><td>3.84s</td><td>0.070</td></tr><tr><td>Angry</td><td><audio controls src="clone/only_angry.mp3"></audio></td><td>233</td><td>3.92s</td><td>0.071</td></tr></table>
|
||||
<h3>B. Same, but with the accent clause appended — <span style="color:#f38ba8">emotion collapses</span></h3>
|
||||
<p class="note">Two style directives in one instruct compete and the trailing one wins: the range squashes to
|
||||
222–250 Hz and sad all but disappears. I've now dropped the accent clause whenever a line carries an
|
||||
emotion — a cloned voice already gets its accent from the reference WAV.</p>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th><th>Loudness</th></tr><tr><td>Whisper</td><td><audio controls src="clone/em_whisper.mp3"></audio></td><td>242</td><td>3.92s</td><td>0.062</td></tr><tr><td>Sad</td><td><audio controls src="clone/em_sad.mp3"></audio></td><td>232</td><td>4.08s</td><td>0.069</td></tr><tr><td>Neutral</td><td><audio controls src="clone/em_neutral.mp3"></audio></td><td>222</td><td>3.92s</td><td>0.066</td></tr><tr><td>Scared</td><td><audio controls src="clone/em_scared.mp3"></audio></td><td>250</td><td>3.92s</td><td>0.076</td></tr><tr><td>Happy</td><td><audio controls src="clone/em_happy.mp3"></audio></td><td>234</td><td>3.76s</td><td>0.066</td></tr><tr><td>Angry</td><td><audio controls src="clone/em_angry.mp3"></audio></td><td>231</td><td>3.92s</td><td>0.070</td></tr></table>
|
||||
|
||||
<h2 style="color:#fab387">8. Different characters — you were right about the shouting</h2>
|
||||
<p class="note"><strong>Marcian's reference clip is the culprit for the shouting:</strong> it is only 3.1 seconds long and sits at
|
||||
<strong>253 Hz</strong>, which is shouting range for a male voice, with the narrowest dynamic range of any voice checked.
|
||||
Cloning reproduces that faithfully, so every Marcian line shouts no matter what emotion is requested.</p>
|
||||
<p class="note"><strong>But swapping to calm voices exposes the deeper problem.</strong> On these two the emotional pitch range is
|
||||
only 19–22 Hz — barely audible. Marcian's wider 69 Hz spread was mostly just his shouty baseline having room
|
||||
to fall, not better emotional control. So voice_clone keeps identity rock-steady but delivers only weak emotion.</p>
|
||||
<h3>DE_M_Darrag — calm 110 Hz reference</h3>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="voices/darrag_whisper.mp3"></audio></td><td>112</td><td>4.56s</td></tr><tr><td>Sad</td><td><audio controls src="voices/darrag_sad.mp3"></audio></td><td>109</td><td>4.32s</td></tr><tr><td>Neutral</td><td><audio controls src="voices/darrag_neutral.mp3"></audio></td><td>111</td><td>4.72s</td></tr><tr><td>Happy</td><td><audio controls src="voices/darrag_happy.mp3"></audio></td><td>110</td><td>4.40s</td></tr><tr><td>Angry</td><td><audio controls src="voices/darrag_angry.mp3"></audio></td><td>131</td><td>4.48s</td></tr></table>
|
||||
<h3>Ernst Meinecke — professional narrator, 21s reference</h3>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th></tr><tr><td>Whisper</td><td><audio controls src="voices/ernst_whisper.mp3"></audio></td><td>145</td><td>5.68s</td></tr><tr><td>Sad</td><td><audio controls src="voices/ernst_sad.mp3"></audio></td><td>135</td><td>4.88s</td></tr><tr><td>Neutral</td><td><audio controls src="voices/ernst_neutral.mp3"></audio></td><td>154</td><td>5.12s</td></tr><tr><td>Happy</td><td><audio controls src="voices/ernst_happy.mp3"></audio></td><td>149</td><td>6.08s</td></tr><tr><td>Angry</td><td><audio controls src="voices/ernst_angry.mp3"></audio></td><td>151</td><td>5.28s</td></tr></table>
|
||||
<p class="note" style="border-left:3px solid #fab387;padding-left:10px"><strong>Where this leaves us:</strong>
|
||||
Fish-Speech → two registers only, everything shouts. Voice Design → strong changes but re-rolls the voice every
|
||||
call, so identity drifts and results are random. voice_clone → perfectly stable identity but only ~20 Hz of emotion.
|
||||
None of the three running backends does <em>both</em>. <strong>CustomVoice (port 8022) is down</strong> — per the Qwen3-TTS
|
||||
docs that is the variant built specifically for timbre control plus emotional style, and it is the one option not yet tested.</p>
|
||||
|
||||
<h2 style="color:#89b4fa">9. You were right — it's the model, not the settings</h2>
|
||||
<p class="note">I checked what each container actually loads, and your hypothesis was exactly correct:</p>
|
||||
<table>
|
||||
<tr><th>Backend</th><th>Model loaded</th><th>Instruction / emotion control?</th></tr>
|
||||
<tr><td>voice_clone (8020)</td><td>Qwen3-TTS-12Hz-1.7B-<strong>Base</strong></td><td style="color:#f38ba8">No</td></tr>
|
||||
<tr><td>streaming (8023)</td><td>same <strong>Base</strong> model</td><td style="color:#f38ba8">No</td></tr>
|
||||
<tr><td>VoiceDesign (8021)</td><td>...-VoiceDesign</td><td style="color:#a6e3a1">Yes</td></tr>
|
||||
<tr><td>CustomVoice (8022)</td><td>...-CustomVoice</td><td style="color:#a6e3a1">Yes</td></tr>
|
||||
</table>
|
||||
<p class="note">So my earlier "voice_clone responds to emotion" claim was wrong — that was a <em>Base</em> model being mildly
|
||||
perturbed by instruction text it does not understand, which is why the range was only 19–22 Hz. I have also
|
||||
confirmed on HuggingFace that <strong>no "Instruct" variant of Qwen3-TTS exists</strong> — the family is exactly these three.</p>
|
||||
<h3>CustomVoice, started and tested (speaker Ryan, German text)</h3>
|
||||
<p class="note">This is the strongest real emotion control measured so far: <strong>85 Hz range</strong>, with speaker identity
|
||||
holding (timbre drift ≤0.008) and sensible pacing (angry fast 4.08s, scared slow 6.16s). Repeat runs confirm the effect is
|
||||
genuine, not noise — sad lands at 111–136 Hz and angry at 159–210 Hz, ranges that never overlap.</p>
|
||||
<table><tr><th>Emotion</th><th>Audio</th><th>Pitch</th><th>Duration</th><th>Loudness</th></tr><tr><td>Whisper</td><td><audio controls src="cv/ryan_whisper.mp3"></audio></td><td>121</td><td>5.76s</td><td>0.037</td></tr><tr><td>Sad</td><td><audio controls src="cv/ryan_sad.mp3"></audio></td><td>187</td><td>5.92s</td><td>0.043</td></tr><tr><td>Neutral</td><td><audio controls src="cv/ryan_neutral.mp3"></audio></td><td>134</td><td>5.12s</td><td>0.025</td></tr><tr><td>Happy</td><td><audio controls src="cv/ryan_happy.mp3"></audio></td><td>187</td><td>4.88s</td><td>0.066</td></tr><tr><td>Scared</td><td><audio controls src="cv/ryan_scared.mp3"></audio></td><td>163</td><td>6.16s</td><td>0.054</td></tr><tr><td>Angry</td><td><audio controls src="cv/ryan_angry.mp3"></audio></td><td>206</td><td>4.08s</td><td>0.053</td></tr></table>
|
||||
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px"><strong>The catch:</strong> CustomVoice exposes only
|
||||
<strong>9 fixed speakers</strong> (Ryan, Aiden, Vivian, Serena, Uncle_Fu, Dylan, Eric…) and its API has <em>no reference-audio
|
||||
field at all</em> — it cannot clone. So it cannot voice the book's 57 cloned characters. Qwen3-TTS splits cloning and emotion
|
||||
across two models that cannot be combined: Base clones but ignores instructions, CustomVoice obeys instructions but cannot clone,
|
||||
VoiceDesign obeys instructions but invents a new voice on every call.</p>
|
||||
|
||||
<h2 style="color:#cba6f7">10. VoiceDesign vs CustomVoice — the consistency problem</h2>
|
||||
<p class="note">Your plan (narrator on voice_clone, characters on VoiceDesign) is sound in structure —
|
||||
<strong>the narrator on voice_clone is exactly right</strong>: identity is rock-steady and he needs no emotion.
|
||||
But before committing 50 hours to VoiceDesign for the characters, listen to this.</p>
|
||||
<p class="note">Each block below is the <strong>same character, same line, same emotion, called three times</strong>.
|
||||
Nothing changes between runs. VoiceDesign has no seed parameter, so it cannot be pinned.</p>
|
||||
<h3 style="color:#f38ba8">VoiceDesign — "happy" ×3 (identical requests)</h3>
|
||||
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/vd_happy_run0.mp3"></audio></td><td>163 Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/vd_happy_run1.mp3"></audio></td><td>238 Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/vd_happy_run2.mp3"></audio></td><td>178 Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>75 Hz</strong></td></tr></table>
|
||||
<h3 style="color:#f38ba8">VoiceDesign — "sad" ×3 (identical requests)</h3>
|
||||
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/vd_sad_run0.mp3"></audio></td><td>190 Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/vd_sad_run1.mp3"></audio></td><td>130 Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/vd_sad_run2.mp3"></audio></td><td>214 Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>84 Hz</strong></td></tr></table>
|
||||
<h3 style="color:#a6e3a1">CustomVoice — "sad" ×3 (identical requests)</h3>
|
||||
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/cv_sad_run0.mp3"></audio></td><td>125 Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/cv_sad_run1.mp3"></audio></td><td>116 Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/cv_sad_run2.mp3"></audio></td><td>119 Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>9 Hz</strong></td></tr></table>
|
||||
<h3>CustomVoice — "happy" ×3 (identical requests)</h3>
|
||||
<table><tr><th>#</th><th>Audio</th><th>Pitch</th></tr><tr><td>Run 1</td><td><audio controls src="ab/cv_happy_run0.mp3"></audio></td><td>158 Hz</td></tr><tr><td>Run 2</td><td><audio controls src="ab/cv_happy_run1.mp3"></audio></td><td>269 Hz</td></tr><tr><td>Run 3</td><td><audio controls src="ab/cv_happy_run2.mp3"></audio></td><td>178 Hz</td></tr><tr><td colspan="2" style="text-align:right"><em>spread across identical calls</em></td><td><strong>111 Hz</strong></td></tr></table>
|
||||
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px">
|
||||
<strong>Averaged over 3 runs each, VoiceDesign's emotions do not separate reliably:</strong> neutral 136 Hz,
|
||||
sad 158, happy 231, angry 237 — but "happy" alone ranged 122–311 Hz across identical calls. The variation
|
||||
<em>within</em> one emotion is larger than the difference <em>between</em> emotions, and sad landed above neutral
|
||||
(backwards). Over ~2000 lines that means a character whose pitch wanders line to line.<br><br>
|
||||
CustomVoice's "sad" by contrast is tight (116–125 Hz, 9 Hz spread) — genuinely reliable. Its weakness is
|
||||
coverage: 9 fixed speakers, no cloning.</p>
|
||||
|
||||
<h2 style="color:#a6e3a1">11. Fish-Speech — I was wrong, and it may be the best option</h2>
|
||||
<p class="note"><strong>Correction:</strong> Fish-Speech is <em>perfectly deterministic</em>. The same request twice produced
|
||||
byte-identical audio (same MD5, 0 Hz spread) because the app pins a per-voice seed. That changes the picture completely:</p>
|
||||
<table>
|
||||
<tr><th>Engine</th><th>sad→angry separation</th><th>Run-to-run noise</th><th>Verdict</th></tr>
|
||||
<tr><td><strong>Fish-Speech</strong></td><td><strong>135 Hz</strong></td><td><strong>0 Hz</strong></td><td style="color:#a6e3a1">signal, no noise</td></tr>
|
||||
<tr><td>CustomVoice</td><td>51 Hz</td><td>26–51 Hz</td><td style="color:#f9e2af">signal ≈ noise</td></tr>
|
||||
<tr><td>VoiceDesign</td><td>101 Hz</td><td>up to 189 Hz</td><td style="color:#f38ba8">noise > signal</td></tr>
|
||||
</table>
|
||||
<p class="note">And it is <strong>three</strong> registers, not two — I under-sold it earlier. Because output is deterministic,
|
||||
this map is exact rather than a sample. Emotion tags reliably land in one of three intensity levels:</p>
|
||||
<h3 style="color:#89b4fa">Subdued — quiet, low</h3><table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>sad</td><td><audio controls src="fishmap/sad.mp3"></audio></td><td>91 Hz</td><td>4.46s</td><td>0.0205</td></tr><tr><td>whispering</td><td><audio controls src="fishmap/whispering.mp3"></audio></td><td>101 Hz</td><td>3.95s</td><td>0.0211</td></tr><tr><td>calm</td><td><audio controls src="fishmap/calm.mp3"></audio></td><td>102 Hz</td><td>3.81s</td><td>0.0244</td></tr><tr><td>disappointed</td><td><audio controls src="fishmap/disappointed.mp3"></audio></td><td>105 Hz</td><td>3.99s</td><td>0.0231</td></tr><tr><td>curious</td><td><audio controls src="fishmap/curious.mp3"></audio></td><td>104 Hz</td><td>3.99s</td><td>0.0274</td></tr><tr><td>resigned</td><td><audio controls src="fishmap/resigned.mp3"></audio></td><td>115 Hz</td><td>4.27s</td><td>0.0353</td></tr></table><h3 style="color:#a6e3a1">Neutral / warm — mid</h3><table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>neutral</td><td><audio controls src="fishmap/neutral.mp3"></audio></td><td>160 Hz</td><td>3.9s</td><td>0.047</td></tr><tr><td>tender</td><td><audio controls src="fishmap/tender.mp3"></audio></td><td>168 Hz</td><td>3.44s</td><td>0.0413</td></tr><tr><td>sobbing</td><td><audio controls src="fishmap/sobbing.mp3"></audio></td><td>177 Hz</td><td>4.6s</td><td>0.052</td></tr></table><h3 style="color:#f38ba8">Energized — loud, high</h3><table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>confident</td><td><audio controls src="fishmap/confident.mp3"></audio></td><td>194 Hz</td><td>4.32s</td><td>0.0591</td></tr><tr><td>sarcastic</td><td><audio controls src="fishmap/sarcastic.mp3"></audio></td><td>200 Hz</td><td>4.04s</td><td>0.064</td></tr><tr><td>happy</td><td><audio controls src="fishmap/happy.mp3"></audio></td><td>203 Hz</td><td>3.9s</td><td>0.0642</td></tr><tr><td>scared</td><td><audio controls src="fishmap/scared.mp3"></audio></td><td>223 Hz</td><td>4.23s</td><td>0.0639</td></tr><tr><td>angry</td><td><audio controls src="fishmap/angry.mp3"></audio></td><td>226 Hz</td><td>4.5s</td><td>0.0649</td></tr><tr><td>excited</td><td><audio controls src="fishmap/excited.mp3"></audio></td><td>230 Hz</td><td>4.18s</td><td>0.0644</td></tr><tr><td>shouting</td><td><audio controls src="fishmap/shouting.mp3"></audio></td><td>234 Hz</td><td>4.04s</td><td>0.0649</td></tr></table>
|
||||
<p class="note" style="border-left:3px solid #a6e3a1;padding-left:10px">
|
||||
<strong>What this means for the book:</strong> Fish clones all 57 characters, holds every identity perfectly stable, and
|
||||
gives three dependable intensity levels — subdued for sad/whispered/resigned lines, neutral for ordinary narration,
|
||||
energized for angry/excited/frightened ones. What it will <em>not</em> do is distinguish angry from happy: both sit in the
|
||||
energized band (226 vs 203 Hz). The emotional contour of a scene comes through; the specific colour does not.<br><br>
|
||||
Determinism has a practical bonus too: a re-run reproduces identical audio, so an interrupted export resumes cleanly
|
||||
instead of drifting.</p>
|
||||
|
||||
<h2 style="color:#f9e2af">12. English voice + English bracket tags (as requested) — and an important correction</h2>
|
||||
<p class="note">Voice: <strong>EN_F_Melina</strong> (best dynamic range of the English references, 10.4s clip).
|
||||
English text, English tags in square brackets before the line, per the S2 word-level-control article.
|
||||
I tested both plain single-word tags and the article's richer free-form style (e.g. its own example
|
||||
<code>[voice rough from crying, trying to sound normal]</code>, and pairing a physical reaction with an emotion).</p>
|
||||
<h3>Simple single-word tags</h3>
|
||||
<table><tr><th>Emotion</th><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th></tr><tr><td>neutral</td><td style="font-size:12px;color:#a6adc8"><em>(no tag)</em></td><td><audio controls src="en5/simple_neutral.mp3"></audio></td><td>187 Hz</td><td>4.23s</td></tr><tr><td>happy</td><td style="font-size:12px;color:#a6adc8">[happy]</td><td><audio controls src="en5/simple_happy.mp3"></audio></td><td>186 Hz</td><td>3.95s</td></tr><tr><td>sad</td><td style="font-size:12px;color:#a6adc8">[sad]</td><td><audio controls src="en5/simple_sad.mp3"></audio></td><td>186 Hz</td><td>4.18s</td></tr><tr><td>angry</td><td style="font-size:12px;color:#a6adc8">[angry]</td><td><audio controls src="en5/simple_angry.mp3"></audio></td><td>186 Hz</td><td>4.32s</td></tr><tr><td>scared</td><td style="font-size:12px;color:#a6adc8">[scared]</td><td><audio controls src="en5/simple_scared.mp3"></audio></td><td>197 Hz</td><td>3.95s</td></tr></table>
|
||||
<h3>Rich descriptive tags (article's recommended style)</h3>
|
||||
<table><tr><th>Emotion</th><th>Tag</th><th>Audio</th><th>Pitch</th><th>Dur</th></tr><tr><td>neutral</td><td style="font-size:12px;color:#a6adc8"><em>(no tag)</em></td><td><audio controls src="en5/rich_neutral.mp3"></audio></td><td>187 Hz</td><td>4.23s</td></tr><tr><td>happy</td><td style="font-size:12px;color:#a6adc8">[bright and delighted, barely containing a grin]</td><td><audio controls src="en5/rich_happy.mp3"></audio></td><td>185 Hz</td><td>4.04s</td></tr><tr><td>sad</td><td style="font-size:12px;color:#a6adc8">[voice rough from crying, trying to sound normal]</td><td><audio controls src="en5/rich_sad.mp3"></audio></td><td>188 Hz</td><td>4.27s</td></tr><tr><td>angry</td><td style="font-size:12px;color:#a6adc8">[furious, teeth clenched, spitting the words out]</td><td><audio controls src="en5/rich_angry.mp3"></audio></td><td>196 Hz</td><td>5.25s</td></tr><tr><td>scared</td><td style="font-size:12px;color:#a6adc8">[breathless with fear, voice trembling]</td><td><audio controls src="en5/rich_scared.mp3"></audio></td><td>183 Hz</td><td>3.95s</td></tr></table>
|
||||
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px">
|
||||
<strong>Correction to section 11 — I over-claimed.</strong> Both tag styles above land within
|
||||
<strong>183–197 Hz</strong>, i.e. essentially no emotional differentiation, and the richer tags were no better
|
||||
than the simple ones. Checking further, <em>every</em> other voice behaves the same way:<br><br>
|
||||
<table>
|
||||
<tr><th>Voice</th><th>sad</th><th>angry</th><th>range</th></tr>
|
||||
<tr><td><strong>DE_M_Marcian</strong></td><td>91 Hz</td><td>226 Hz</td><td style="color:#a6e3a1"><strong>135 Hz</strong></td></tr>
|
||||
<tr><td>DE_M_Darrag</td><td>86</td><td>94</td><td style="color:#f38ba8">8 Hz</td></tr>
|
||||
<tr><td>DE_F_Lysandra</td><td>186</td><td>176</td><td style="color:#f38ba8">10 Hz</td></tr>
|
||||
<tr><td>Ernst Meinecke (narrator)</td><td>109</td><td>111</td><td style="color:#f38ba8">2 Hz</td></tr>
|
||||
<tr><td>EN_M_Alan_Rickman</td><td>72</td><td>80</td><td style="color:#f38ba8">8 Hz</td></tr>
|
||||
<tr><td>EN_M_Albus_Dumbledore</td><td>189</td><td>184</td><td style="color:#f38ba8">5 Hz</td></tr>
|
||||
</table><br>
|
||||
<strong>Marcian is the single outlier</strong> — and section 11's whole "three registers" map was built on him alone.
|
||||
His reference clip is an anomalous 3.1-second shout at 253 Hz, so a <code>[sad]</code> tag has enormous room to pull him
|
||||
down. Normal, well-recorded references barely move at all. I also tested whether reference <em>length</em> was the cause by
|
||||
truncating two voices to 3 seconds — it made no difference, so it is the unusual character of that particular clip,
|
||||
not its duration.</p>
|
||||
|
||||
<h2 style="color:#a6e3a1">13. ★ How Fish-Speech S2 emotion ACTUALLY works</h2>
|
||||
<p class="note">I investigated the mechanism instead of guessing. The finding explains every confusing result above:</p>
|
||||
<p class="note" style="border-left:3px solid #a6e3a1;padding-left:10px;font-size:15px">
|
||||
<strong>Fish-Speech emotion is driven by the REFERENCE CLIP, not by the text tags.</strong><br>
|
||||
When you supply reference audio, S2 does in-context prosody imitation — it copies the reference's delivery, and that
|
||||
overrides bracket tags almost entirely. Tags only work when there is no reference competing with them.</p>
|
||||
<h3>Evidence A — the same tags, with and without a reference</h3>
|
||||
<table><tr><th>Tag</th><th>No reference</th><th>With reference (cloning)</th></tr>
|
||||
<tr><td>[sad]</td><td style="color:#a6e3a1">loudness 0.051 (−40%), 21% slower</td><td style="color:#f38ba8">0.096 (−11%)</td></tr>
|
||||
<tr><td>[angry]</td><td style="color:#a6e3a1">0.094, dynamics 0.324, darker timbre</td><td style="color:#f38ba8">0.097, dynamics 0.245</td></tr>
|
||||
<tr><td>[whispering]</td><td style="color:#a6e3a1">0.054 (−36%)</td><td style="color:#f38ba8">0.091 (−16%)</td></tr>
|
||||
</table>
|
||||
<h3>Evidence B — emotion transfers through the reference with NO tag at all</h3>
|
||||
<p class="note">Identical plain sentence, no bracket tags anywhere. Only the reference clip changed:</p>
|
||||
<table><tr><th>Reference used</th><th>Pitch</th><th>Loudness</th><th>Brightness</th></tr>
|
||||
<tr><td>calm / neutral</td><td>183 Hz</td><td>0.075</td><td>2699</td></tr>
|
||||
<tr><td><strong>scared</strong> reference</td><td>106 Hz</td><td><strong>0.022</strong> (5× quieter)</td><td>2380</td></tr>
|
||||
<tr><td><strong>angry</strong> reference</td><td>121 Hz</td><td><strong>0.108</strong> (loud)</td><td>1778 (dark)</td></tr>
|
||||
<tr><td><strong>happy</strong> reference</td><td>210 Hz</td><td>0.106</td><td>2177</td></tr>
|
||||
</table>
|
||||
<h3>The 5 samples you asked for — tags with no competing reference</h3>
|
||||
<p class="note">These finally differentiate: <strong>10× loudness spread</strong>, pitch 110–222 Hz, and genuine
|
||||
intonation differences (happy pstd 45 vs scared pstd 5). Caveat: with no reference the model also picks its own voice,
|
||||
so identity is not controlled here (speaker distance 0.027 vs 0.011 for a true clone).</p>
|
||||
<table><tr><th>Emotion</th><th>Tag used</th><th>Audio</th><th>Pitch</th><th>Dur</th><th>Loudness</th></tr><tr><td>neutral</td><td style="font-size:12px;color:#a6adc8"><em>(no tag)</em></td><td><audio controls src="works/neutral.mp3"></audio></td><td>182 Hz</td><td>3.58s</td><td>0.084</td></tr><tr><td>happy</td><td style="font-size:12px;color:#a6adc8">[cheerful and bright, smiling as she speaks]</td><td><audio controls src="works/happy.mp3"></audio></td><td>222 Hz</td><td>4.27s</td><td>0.0997</td></tr><tr><td>sad</td><td style="font-size:12px;color:#a6adc8">[voice rough from crying, trying to sound normal]</td><td><audio controls src="works/sad.mp3"></audio></td><td>155 Hz</td><td>3.2s</td><td>0.2098</td></tr><tr><td>angry</td><td style="font-size:12px;color:#a6adc8">[furious, teeth clenched, spitting the words out]</td><td><audio controls src="works/angry.mp3"></audio></td><td>129 Hz</td><td>3.72s</td><td>0.1037</td></tr><tr><td>scared</td><td style="font-size:12px;color:#a6adc8">[breathless with fear, voice trembling]</td><td><audio controls src="works/scared.mp3"></audio></td><td>110 Hz</td><td>4.13s</td><td>0.0208</td></tr></table>
|
||||
<p class="note" style="border-left:3px solid #89b4fa;padding-left:10px">
|
||||
<strong>What this means — and the way forward.</strong> The trade-off is inherent: a reference gives you the character's
|
||||
identity but suppresses tag-based emotion; no reference gives you emotion but an uncontrolled voice. <em>But Evidence B points
|
||||
at a real solution:</em> give each character <strong>several reference clips, one per emotional register</strong> (neutral,
|
||||
subdued, intense) and pick the matching one per line. That uses the mechanism instead of fighting it — delivering
|
||||
cloned identity <em>and</em> emotion, both fully deterministic.</p>
|
||||
|
||||
<h2 style="color:#f38ba8">14. ★ THE PROOF — same text, same tags, cloning on vs off</h2>
|
||||
<p class="note">Your five-line example, generated twice through Fish Audio S2 Pro. Identical text, identical tags.
|
||||
The <em>only</em> difference is whether a cloned reference voice was supplied.</p>
|
||||
<h3 style="color:#f38ba8">A. WITH a cloned voice (DE_F_Einfaches_Maedchen) — what you heard</h3>
|
||||
<audio controls src="multi/multi_tag.mp3" style="width:100%;max-width:520px"></audio>
|
||||
<p class="note">Pitch across all five emotions: 241–268 Hz — a <strong>27 Hz</strong> spread. Every line lands in the
|
||||
same place, which is exactly what you reported: "no difference between the lines".</p>
|
||||
<h3 style="color:#a6e3a1">B. WITHOUT a reference voice — same text, same tags</h3>
|
||||
<audio controls src="multi/no_ref.mp3" style="width:100%;max-width:520px"></audio>
|
||||
<table><tr><th>Line</th><th>Pitch</th><th>Loudness</th></tr>
|
||||
<tr><td>[happy]</td><td>293 Hz</td><td>0.194 (loudest)</td></tr>
|
||||
<tr><td>[uncertain]</td><td>237 Hz</td><td>0.148</td></tr>
|
||||
<tr><td>[sad]</td><td>184 Hz</td><td>0.125</td></tr>
|
||||
<tr><td>[hopeful]</td><td>207 Hz</td><td>0.084</td></tr>
|
||||
<tr><td>[determined]</td><td>170 Hz</td><td>0.061 (quietest)</td></tr></table>
|
||||
<p class="note"><strong>123 Hz spread and 3.2× loudness range</strong> — four to five times the differentiation, in a
|
||||
sensible order (happy highest and loudest, determined lowest and quietest). Because this is one single generation, the
|
||||
voice stays consistent throughout; the earlier "5 different voices" problem only happened when each emotion was generated
|
||||
as its own separate call.</p>
|
||||
<p class="note" style="border-left:3px solid #f38ba8;padding-left:10px">
|
||||
This is the trade-off in one page: <strong>Fish gives you the cloned character voice, or the emotion tags — never both.</strong>
|
||||
The reference clip is prepended as an in-context example and the model imitates its delivery, which overrides the tags.</p>
|
||||
|
||||
<h2 style="color:#89b4fa">15. Controlling the voice WITHOUT a reference clip</h2>
|
||||
<p class="note">If no reference clip is used, the voice can still be steered — by <strong>describing it in a tag</strong>.
|
||||
S2-Pro accepts free-form descriptions, so gender, age and character are controllable directly:</p>
|
||||
<table><tr><th>Description tag</th><th>Audio</th><th>Pitch</th></tr><tr><td><code>(no tag)</code></td><td><audio controls src="voicectl/no_tag.mp3"></audio></td><td>182 Hz</td></tr><tr><td><code>[deep male voice]</code></td><td><audio controls src="voicectl/deep_male.mp3"></audio></td><td>91 Hz</td></tr><tr><td><code>[young woman's voice]</code></td><td><audio controls src="voicectl/young_woman.mp3"></audio></td><td>191 Hz</td></tr><tr><td><code>[elderly man, frail and raspy]</code></td><td><audio controls src="voicectl/elderly_man.mp3"></audio></td><td>93 Hz</td></tr><tr><td><code>[small child, high voice]</code></td><td><audio controls src="voicectl/little_girl.mp3"></audio></td><td>202 Hz</td></tr><tr><td><code>[gruff old warrior, deep and gravelly]</code></td><td><audio controls src="voicectl/gruff_old_warrior.mp3"></audio></td><td>72 Hz</td></tr></table>
|
||||
<p class="note">That is a 72–202 Hz range — clearly distinguishable men, women and children, all without any reference clip.</p>
|
||||
<h3 style="color:#a6e3a1">Reproducible: same description + same seed = identical audio</h3>
|
||||
<p class="note">Three runs of the same description and seed produced <strong>byte-identical files</strong> (matching MD5).
|
||||
So a character can be pinned as "description + seed" and will sound the same every time — this fixes the
|
||||
"5 different voices" problem from section 13.</p>
|
||||
<h3 style="color:#f9e2af">The catch: emotion tags also shift the voice</h3>
|
||||
<p class="note">Same character description throughout, only the emotion tag changes. Emotion clearly works
|
||||
(174 Hz range, 6.4× loudness) — but the character drifts as well. "Identity drift" below compares timbre
|
||||
against the neutral take; a true cloned voice stays at 0.001–0.011, and roughly 0.03+ starts to read as a different person.</p>
|
||||
<table><tr><th>Tag</th><th>Audio</th><th>Pitch</th><th>Loudness</th><th>Identity drift</th></tr><tr><td><code>(no emotion tag)</code></td><td><audio controls src="chardesc/neutral.mp3"></audio></td><td>72 Hz</td><td>0.033</td><td>—</td></tr><tr><td><code>[happy]</code></td><td><audio controls src="chardesc/happy.mp3"></audio></td><td>174 Hz</td><td>0.113</td><td>0.033</td></tr><tr><td><code>[sad]</code></td><td><audio controls src="chardesc/sad.mp3"></audio></td><td>—</td><td>0.209</td><td>0.212</td></tr><tr><td><code>[angry]</code></td><td><audio controls src="chardesc/angry.mp3"></audio></td><td>123 Hz</td><td>0.201</td><td>0.044</td></tr><tr><td><code>[whisper]</code></td><td><audio controls src="chardesc/whisper.mp3"></audio></td><td>81 Hz</td><td>0.100</td><td>0.069</td></tr></table>
|
||||
<p class="note" style="border-left:3px solid #89b4fa;padding-left:10px">
|
||||
<strong>Please judge this by ear</strong> — do these five still sound like the same person, or like different speakers?
|
||||
If they hold together well enough, this is a workable path for the audiobook: define each character as a description plus a
|
||||
fixed seed, and emotion tags will actually function. If they do not, then cloned voices with neutral delivery remains the
|
||||
honest option.</p>
|
||||
</body></html>
|
||||
272
static/samples/manifest.json
Normal file
272
static/samples/manifest.json
Normal file
@ -0,0 +1,272 @@
|
||||
[
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "befehlend",
|
||||
"backend": "fishspeech",
|
||||
"file": "marcian_1_befehlend_fish.mp3",
|
||||
"bytes": 38470,
|
||||
"secs": 17.4,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "befehlend",
|
||||
"backend": "voice_design",
|
||||
"file": "marcian_1_befehlend_qwen3design.mp3",
|
||||
"bytes": 20013,
|
||||
"secs": 4.0,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "kalt und zynisch",
|
||||
"backend": "fishspeech",
|
||||
"file": "marcian_2_kalt_und_zynisch_fish.mp3",
|
||||
"bytes": 23214,
|
||||
"secs": 10.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "kalt und zynisch",
|
||||
"backend": "voice_design",
|
||||
"file": "marcian_2_kalt_und_zynisch_qwen3design.mp3",
|
||||
"bytes": 17709,
|
||||
"secs": 3.6,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "fragend",
|
||||
"backend": "fishspeech",
|
||||
"file": "marcian_3_fragend_fish.mp3",
|
||||
"bytes": 39097,
|
||||
"secs": 18.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "fragend",
|
||||
"backend": "voice_design",
|
||||
"file": "marcian_3_fragend_qwen3design.mp3",
|
||||
"bytes": 19629,
|
||||
"secs": 4.5,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "entschlossen",
|
||||
"backend": "fishspeech",
|
||||
"file": "marcian_4_entschlossen_fish.mp3",
|
||||
"bytes": 57069,
|
||||
"secs": 25.7,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "entschlossen",
|
||||
"backend": "voice_design",
|
||||
"file": "marcian_4_entschlossen_qwen3design.mp3",
|
||||
"bytes": 26349,
|
||||
"secs": 4.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "ernst",
|
||||
"backend": "fishspeech",
|
||||
"file": "marcian_5_ernst_fish.mp3",
|
||||
"bytes": 32200,
|
||||
"secs": 13.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion": "ernst",
|
||||
"backend": "voice_design",
|
||||
"file": "marcian_5_ernst_qwen3design.mp3",
|
||||
"bytes": 15501,
|
||||
"secs": 2.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "resigniert",
|
||||
"backend": "fishspeech",
|
||||
"file": "lysandra_1_resigniert_fish.mp3",
|
||||
"bytes": 39515,
|
||||
"secs": 15.6,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "resigniert",
|
||||
"backend": "voice_design",
|
||||
"file": "lysandra_1_resigniert_qwen3design.mp3",
|
||||
"bytes": 25389,
|
||||
"secs": 4.6,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "besorgt",
|
||||
"backend": "fishspeech",
|
||||
"file": "lysandra_2_besorgt_fish.mp3",
|
||||
"bytes": 20289,
|
||||
"secs": 8.1,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "besorgt",
|
||||
"backend": "voice_design",
|
||||
"file": "lysandra_2_besorgt_qwen3design.mp3",
|
||||
"bytes": 14829,
|
||||
"secs": 2.5,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "abweisend",
|
||||
"backend": "fishspeech",
|
||||
"file": "lysandra_3_abweisend_fish.mp3",
|
||||
"bytes": 19871,
|
||||
"secs": 7.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "abweisend",
|
||||
"backend": "voice_design",
|
||||
"file": "lysandra_3_abweisend_qwen3design.mp3",
|
||||
"bytes": 10413,
|
||||
"secs": 1.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "verächtlich",
|
||||
"backend": "fishspeech",
|
||||
"file": "lysandra_4_verachtlich_fish.mp3",
|
||||
"bytes": 48919,
|
||||
"secs": 18.6,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "verächtlich",
|
||||
"backend": "voice_design",
|
||||
"file": "lysandra_4_verachtlich_qwen3design.mp3",
|
||||
"bytes": 20973,
|
||||
"secs": 3.7,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "schroff",
|
||||
"backend": "fishspeech",
|
||||
"file": "lysandra_5_schroff_fish.mp3",
|
||||
"bytes": 35544,
|
||||
"secs": 13.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion": "schroff",
|
||||
"backend": "voice_design",
|
||||
"file": "lysandra_5_schroff_qwen3design.mp3",
|
||||
"bytes": 17709,
|
||||
"secs": 3.1,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "befehlend",
|
||||
"backend": "fishspeech",
|
||||
"file": "darrag_1_befehlend_fish.mp3",
|
||||
"bytes": 47038,
|
||||
"secs": 18.4,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "befehlend",
|
||||
"backend": "voice_design",
|
||||
"file": "darrag_1_befehlend_qwen3design.mp3",
|
||||
"bytes": 22893,
|
||||
"secs": 4.0,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "entschlossen",
|
||||
"backend": "fishspeech",
|
||||
"file": "darrag_2_entschlossen_fish.mp3",
|
||||
"bytes": 19453,
|
||||
"secs": 7.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "entschlossen",
|
||||
"backend": "voice_design",
|
||||
"file": "darrag_2_entschlossen_qwen3design.mp3",
|
||||
"bytes": 13293,
|
||||
"secs": 2.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "dringend",
|
||||
"backend": "fishspeech",
|
||||
"file": "darrag_3_dringend_fish.mp3",
|
||||
"bytes": 16945,
|
||||
"secs": 7.0,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "dringend",
|
||||
"backend": "voice_design",
|
||||
"file": "darrag_3_dringend_qwen3design.mp3",
|
||||
"bytes": 9069,
|
||||
"secs": 1.7,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "drohend",
|
||||
"backend": "fishspeech",
|
||||
"file": "darrag_4_drohend_fish.mp3",
|
||||
"bytes": 26140,
|
||||
"secs": 10.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "drohend",
|
||||
"backend": "voice_design",
|
||||
"file": "darrag_4_drohend_qwen3design.mp3",
|
||||
"bytes": 13869,
|
||||
"secs": 2.5,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "überrascht",
|
||||
"backend": "fishspeech",
|
||||
"file": "darrag_5_uberrascht_fish.mp3",
|
||||
"bytes": 18408,
|
||||
"secs": 7.6,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion": "überrascht",
|
||||
"backend": "voice_design",
|
||||
"file": "darrag_5_uberrascht_qwen3design.mp3",
|
||||
"bytes": 7533,
|
||||
"secs": 1.4,
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
302
static/samples/manifest_basic.json
Normal file
302
static/samples/manifest_basic.json
Normal file
@ -0,0 +1,302 @@
|
||||
[
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "glücklich",
|
||||
"emotion_en": "happy",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_marcian_1_happy_fish.mp3",
|
||||
"bytes": 40351,
|
||||
"secs": 15.2,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "glücklich",
|
||||
"emotion_en": "happy",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_marcian_1_happy_qwen3design.mp3",
|
||||
"bytes": 19341,
|
||||
"secs": 3.2,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "traurig",
|
||||
"emotion_en": "sad",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_marcian_2_sad_fish.mp3",
|
||||
"bytes": 23841,
|
||||
"secs": 8.8,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "traurig",
|
||||
"emotion_en": "sad",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_marcian_2_sad_qwen3design.mp3",
|
||||
"bytes": 13293,
|
||||
"secs": 2.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "wütend",
|
||||
"emotion_en": "angry",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_marcian_3_angry_fish.mp3",
|
||||
"bytes": 37634,
|
||||
"secs": 13.8,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "wütend",
|
||||
"emotion_en": "angry",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_marcian_3_angry_qwen3design.mp3",
|
||||
"bytes": 20589,
|
||||
"secs": 3.5,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "ängstlich",
|
||||
"emotion_en": "scared",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_marcian_4_scared_fish.mp3",
|
||||
"bytes": 25095,
|
||||
"secs": 9.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "ängstlich",
|
||||
"emotion_en": "scared",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_marcian_4_scared_qwen3design.mp3",
|
||||
"bytes": 11373,
|
||||
"secs": 1.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "aufgeregt",
|
||||
"emotion_en": "excited",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_marcian_5_excited_fish.mp3",
|
||||
"bytes": 10884,
|
||||
"secs": 4.4,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "MARCIAN",
|
||||
"emotion_de": "aufgeregt",
|
||||
"emotion_en": "excited",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_marcian_5_excited_qwen3design.mp3",
|
||||
"bytes": 6861,
|
||||
"secs": 1.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "glücklich",
|
||||
"emotion_en": "happy",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_lysandra_1_happy_fish.mp3",
|
||||
"bytes": 31364,
|
||||
"secs": 12.6,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "glücklich",
|
||||
"emotion_en": "happy",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_lysandra_1_happy_qwen3design.mp3",
|
||||
"bytes": 16173,
|
||||
"secs": 2.8,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "traurig",
|
||||
"emotion_en": "sad",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_lysandra_2_sad_fish.mp3",
|
||||
"bytes": 12347,
|
||||
"secs": 5.5,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "traurig",
|
||||
"emotion_en": "sad",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_lysandra_2_sad_qwen3design.mp3",
|
||||
"bytes": 6573,
|
||||
"secs": 1.1,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "wütend",
|
||||
"emotion_en": "angry",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_lysandra_3_angry_fish.mp3",
|
||||
"bytes": 20915,
|
||||
"secs": 8.1,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "wütend",
|
||||
"emotion_en": "angry",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_lysandra_3_angry_qwen3design.mp3",
|
||||
"bytes": 10029,
|
||||
"secs": 1.7,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "ängstlich",
|
||||
"emotion_en": "scared",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_lysandra_4_scared_fish.mp3",
|
||||
"bytes": 23841,
|
||||
"secs": 8.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "ängstlich",
|
||||
"emotion_en": "scared",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_lysandra_4_scared_qwen3design.mp3",
|
||||
"bytes": 12909,
|
||||
"secs": 2.2,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "aufgeregt",
|
||||
"emotion_en": "excited",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_lysandra_5_excited_fish.mp3",
|
||||
"bytes": 46620,
|
||||
"secs": 16.6,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "LYSANDRA",
|
||||
"emotion_de": "aufgeregt",
|
||||
"emotion_en": "excited",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_lysandra_5_excited_qwen3design.mp3",
|
||||
"bytes": 24813,
|
||||
"secs": 4.0,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "glücklich",
|
||||
"emotion_en": "happy",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_darrag_1_happy_fish.mp3",
|
||||
"bytes": 48083,
|
||||
"secs": 17.2,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "glücklich",
|
||||
"emotion_en": "happy",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_darrag_1_happy_qwen3design.mp3",
|
||||
"bytes": 21933,
|
||||
"secs": 9.2,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "traurig",
|
||||
"emotion_en": "sad",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_darrag_2_sad_fish.mp3",
|
||||
"bytes": 18826,
|
||||
"secs": 6.9,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "traurig",
|
||||
"emotion_en": "sad",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_darrag_2_sad_qwen3design.mp3",
|
||||
"bytes": 10413,
|
||||
"secs": 1.7,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "wütend",
|
||||
"emotion_en": "angry",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_darrag_3_angry_fish.mp3",
|
||||
"bytes": 14228,
|
||||
"secs": 5.4,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "wütend",
|
||||
"emotion_en": "angry",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_darrag_3_angry_qwen3design.mp3",
|
||||
"bytes": 7821,
|
||||
"secs": 1.3,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "ängstlich",
|
||||
"emotion_en": "scared",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_darrag_4_scared_fish.mp3",
|
||||
"bytes": 24259,
|
||||
"secs": 8.7,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "ängstlich",
|
||||
"emotion_en": "scared",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_darrag_4_scared_qwen3design.mp3",
|
||||
"bytes": 10989,
|
||||
"secs": 1.8,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "aufgeregt",
|
||||
"emotion_en": "excited",
|
||||
"backend": "fishspeech",
|
||||
"file": "basic_darrag_5_excited_fish.mp3",
|
||||
"bytes": 17990,
|
||||
"secs": 6.7,
|
||||
"ok": true
|
||||
},
|
||||
{
|
||||
"char": "DARRAG",
|
||||
"emotion_de": "aufgeregt",
|
||||
"emotion_en": "excited",
|
||||
"backend": "voice_design",
|
||||
"file": "basic_darrag_5_excited_qwen3design.mp3",
|
||||
"bytes": 8781,
|
||||
"secs": 1.5,
|
||||
"ok": true
|
||||
}
|
||||
]
|
||||
1
static/samples/voicectl/stats.json
Normal file
1
static/samples/voicectl/stats.json
Normal file
@ -0,0 +1 @@
|
||||
{"no_tag": 182, "deep_male": 91, "young_woman": 191, "elderly_man": 93, "little_girl": 202, "gruff_old_warrior": 72}
|
||||
1
static/samples/works/stats.json
Normal file
1
static/samples/works/stats.json
Normal file
@ -0,0 +1 @@
|
||||
{"neutral": {"pitch": 182, "pstd": 13, "dur": 3.58, "rms": 0.084, "dyn": 0.189, "bright": 4028, "tag": ""}, "happy": {"pitch": 222, "pstd": 45, "dur": 4.27, "rms": 0.0997, "dyn": 0.237, "bright": 2998, "tag": "[cheerful and bright, smiling as she speaks]"}, "sad": {"pitch": 155, "pstd": 26, "dur": 3.2, "rms": 0.2098, "dyn": 0.483, "bright": 3302, "tag": "[voice rough from crying, trying to sound normal]"}, "angry": {"pitch": 129, "pstd": 22, "dur": 3.72, "rms": 0.1037, "dyn": 0.315, "bright": 2972, "tag": "[furious, teeth clenched, spitting the words out]"}, "scared": {"pitch": 110, "pstd": 5, "dur": 4.13, "rms": 0.0208, "dyn": 0.085, "bright": 3658, "tag": "[breathless with fear, voice trembling]"}}
|
||||
@ -46,6 +46,8 @@
|
||||
<!-- ── Phase 2: Characters ─────────────────────────────────────── -->
|
||||
<div class="stu-phase" id="stu-phase-2" hidden>
|
||||
<div class="stu-inner-tabs" id="stu-cast-inner-tabs">
|
||||
<button type="button" class="stu-book-context-btn" id="stu-change-book-btn" title="Choose a different uploaded book"><span class="mdi mdi-book-open-page-variant-outline"></span> Change book</button>
|
||||
<button type="button" class="stu-book-context-btn" id="stu-book-context-btn" title="Edit genre, setting, era and language before identifying characters"><span class="mdi mdi-book-cog-outline"></span> Edit book context</button>
|
||||
<button class="stu-inner-tab active" data-stu-cast-view="identify">Identify Characters</button>
|
||||
<button class="stu-inner-tab" data-stu-cast-view="sheets">Cast Characters <small style="opacity:.6;font-weight:400">(optional)</small></button>
|
||||
</div>
|
||||
|
||||
@ -789,6 +789,13 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
font-size: 11px; border-radius: 5px; padding: 0 6px; height: 26px; font-family: inherit;
|
||||
}
|
||||
.lib-chars-prod-actions .lib-chars-image-provider option { color: #111; }
|
||||
.lib-chars-prod-actions .lib-chars-visual-style,
|
||||
.lcd-visual-style {
|
||||
background: var(--panel, #fff); border: 1px solid var(--border); color: var(--text, #222);
|
||||
font-size: 11px; border-radius: 5px; padding: 0 7px; height: 28px; font-family: inherit;
|
||||
}
|
||||
.lib-chars-prod-actions .lib-chars-visual-style option,
|
||||
.lcd-visual-style option { color: #111; }
|
||||
.lib-chars-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
@ -1195,6 +1202,7 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
.lcd-prompt-box summary::before { content: '›'; font-size: 15px; color: var(--subtext); transition: transform .15s; }
|
||||
.lcd-prompt-box[open] summary::before { transform: rotate(90deg); }
|
||||
.lcd-prompt-box summary:hover { background: var(--panel); }
|
||||
.lcd-prompt-hint { margin-left: auto; font-size: 11px; font-weight: 400; color: var(--subtext); }
|
||||
.lcd-prompt-empty { font-weight: 400; font-size: 11px; color: var(--subtext); font-style: italic; }
|
||||
.lcd-prompt-body { padding: 0 12px 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.cs-concept-art-preview { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
|
||||
@ -1206,6 +1214,18 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
}
|
||||
.lcd-prompt-text:focus { border-color: var(--accent); }
|
||||
.lcd-prompt-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||||
.lcd-prompt-actions .lcd-save-prompt { margin-right: auto; }
|
||||
.lcd-generated-voice-file .lcd-regenerate-voice { margin-left: auto; flex: 0 0 auto; }
|
||||
.lcd-voice-prompt-box { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); box-shadow: 0 2px 8px rgba(37,99,235,.08); }
|
||||
.lcd-voice-prompt-box .lcd-prompt-actions { padding-top: 2px; }
|
||||
.lcd-voice-actions { display: flex; justify-content: flex-end; margin: -1px 0 10px; }
|
||||
@media (max-width: 560px) {
|
||||
.lcd-prompt-hint { display: none; }
|
||||
.lcd-generated-voice-file { flex-wrap: wrap; }
|
||||
.lcd-generated-voice-file .lcd-regenerate-voice { margin-left: auto; }
|
||||
.lcd-voice-actions { justify-content: stretch; }
|
||||
.lcd-voice-actions button { width: 100%; }
|
||||
}
|
||||
.lcd-generated-voice-file {
|
||||
display: flex; align-items: center; gap: 8px; margin: -2px 0 8px; padding: 7px 10px;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
|
||||
@ -1213,6 +1233,20 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
}
|
||||
.lcd-generated-voice-file-label { min-width: 0; overflow-wrap: anywhere; }
|
||||
.lcd-generated-voice-file-label strong { color: var(--text); font-weight: 600; }
|
||||
.lcd-voice-replace-confirm {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
margin: -2px 0 10px; padding: 10px 12px; border: 1px solid #f59e0b;
|
||||
border-radius: 8px; background: color-mix(in srgb, #f59e0b 10%, var(--surface));
|
||||
color: var(--text); font-size: 12px; line-height: 1.4;
|
||||
}
|
||||
.lcd-voice-replace-confirm[hidden] { display: none; }
|
||||
.lcd-voice-replace-confirm > span:first-child { display: flex; align-items: flex-start; gap: 7px; }
|
||||
.lcd-voice-replace-confirm .mdi-alert-outline { color: #d97706; font-size: 17px; }
|
||||
.lcd-voice-replace-actions { display: flex; gap: 7px; flex: 0 0 auto; }
|
||||
@media (max-width: 640px) {
|
||||
.lcd-voice-replace-confirm { align-items: stretch; flex-direction: column; }
|
||||
.lcd-voice-replace-actions { justify-content: flex-end; }
|
||||
}
|
||||
.lcd-generated-voice-play {
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
|
||||
width: 28px; height: 28px; padding: 0; border: 1px solid var(--accent);
|
||||
@ -1371,6 +1405,14 @@ tr.lib-char-card.lib-chars-tbl-row:hover { transform: none; box-shadow: none; ba
|
||||
padding:12px 14px 8px; font-size:10px; font-weight:700;
|
||||
letter-spacing:.07em; text-transform:uppercase; opacity:.45; border-bottom:1px solid var(--border);
|
||||
}
|
||||
.lib-cpg-sidebar-tools { display:flex; gap:5px; padding:8px; border-bottom:1px solid var(--border); }
|
||||
.lib-cpg-sidebar-search, .lib-cpg-sidebar-sort {
|
||||
min-width:0; padding:5px 6px; border:1px solid var(--border); border-radius:5px;
|
||||
background:var(--surface); color:var(--text); font:inherit; font-size:11px; outline:none;
|
||||
}
|
||||
.lib-cpg-sidebar-search { flex:1; }
|
||||
.lib-cpg-sidebar-sort { width:76px; cursor:pointer; }
|
||||
.lib-cpg-sidebar-search:focus, .lib-cpg-sidebar-sort:focus { border-color:var(--accent); }
|
||||
.lib-cpg-sidebar-item {
|
||||
display:flex; align-items:center; gap:9px; padding:8px 12px;
|
||||
cursor:pointer; transition:background .12s; border-bottom:1px solid rgba(255,255,255,.03);
|
||||
@ -5234,6 +5276,13 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
|
||||
/* Nested sub-tabs inside Studio's Characters/Perform phases (smaller, inline) */
|
||||
.stu-inner-tabs { display: flex; gap: 4px; margin: 0 0 12px; }
|
||||
.stu-book-context-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px; margin-right: 4px;
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px;
|
||||
cursor: pointer; background: var(--panel); color: var(--subtext);
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
}
|
||||
.stu-book-context-btn:hover { color: var(--accent); border-color: var(--accent); background: var(--surface); }
|
||||
.stu-inner-tab {
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; cursor: pointer;
|
||||
background: var(--panel); color: var(--subtext); font-size: 12.5px; font-weight: 600;
|
||||
@ -6543,6 +6592,23 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.audiobook-box .reader-synth-track { width: 100%; }
|
||||
.audiobook-box label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; font-weight: 600; color: var(--subtext); }
|
||||
.audiobook-box label input[type="text"] { font: inherit; font-weight: 400; color: var(--text); padding: 7px 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
|
||||
.audiobook-box.cdi-voice-dialog {
|
||||
box-sizing: border-box; width: min(600px, calc(100vw - 32px)); max-width: none;
|
||||
max-height: calc(100dvh - 32px); overflow-y: auto;
|
||||
}
|
||||
.cdi-voice-actions {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px; margin-top: 14px;
|
||||
}
|
||||
.cdi-voice-actions button {
|
||||
box-sizing: border-box; min-width: 0; width: 100%; height: auto;
|
||||
min-height: 58px; padding: 8px 7px; white-space: normal; line-height: 1.2;
|
||||
overflow-wrap: anywhere; justify-content: center;
|
||||
}
|
||||
@media (max-width: 540px) {
|
||||
.audiobook-box.cdi-voice-dialog { padding: 18px 16px; }
|
||||
.cdi-voice-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.audiobook-actions { margin-top: 14px; text-align: right; display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.conceptart-editor-box .conceptart-editor-preview { width: 100%; }
|
||||
.conceptart-editor-box .conceptart-editor-preview .conceptart-lightbox-img { max-width: 100%; width: auto; }
|
||||
@ -7418,3 +7484,28 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.ab-cv-quote-toggle.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
/* Quotation marks are wrapped in .ab-q so the toggle is a pure CSS flip */
|
||||
body.ab-hide-quotes .ab-q { display: none; }
|
||||
.lcd-book-context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #d9e2f0;
|
||||
border-left: 4px solid #2563eb;
|
||||
border-radius: 8px;
|
||||
background: #f7faff;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
}
|
||||
.lcd-book-context-text { flex: 1; line-height: 1.45; }
|
||||
.ab-book-context-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--primary) 7%, var(--surface));
|
||||
}
|
||||
.ab-book-context-copy { display: flex; align-items: baseline; gap: 8px; flex: 1; flex-wrap: wrap; font-size: 12px; }
|
||||
.ab-book-context-copy > span:last-child { color: var(--subtext); line-height: 1.4; }
|
||||
@media (max-width: 700px) { .ab-book-context-banner { align-items: flex-start; flex-direction: column; } }
|
||||
|
||||
652
workflows/comfyui-character-consistent-images.json
Normal file
652
workflows/comfyui-character-consistent-images.json
Normal file
@ -0,0 +1,652 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_edit_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "voice_creator_darrag_identity_v3.png"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "ImageCrop",
|
||||
"inputs": {
|
||||
"image": ["4", 0],
|
||||
"width": 768,
|
||||
"height": 768,
|
||||
"x": 128,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": ["5", 0],
|
||||
"upscale_method": "lanczos",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"profile_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["7", 0],
|
||||
"filename_prefix": "voice-creator-v3/profile"
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "FluxKontextImageScale",
|
||||
"inputs": {
|
||||
"image": ["4", 0]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"vae": ["3", 0],
|
||||
"image1": ["6", 0],
|
||||
"image2": ["61", 0],
|
||||
"prompt": ""
|
||||
}
|
||||
},
|
||||
"11": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"12": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"seed": 0,
|
||||
"steps": 20,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["10", 0],
|
||||
"negative": ["10", 0],
|
||||
"latent_image": ["11", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["12", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"14": {
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"vae": ["3", 0],
|
||||
"image1": ["6", 0],
|
||||
"image2": ["63", 0],
|
||||
"prompt": ""
|
||||
}
|
||||
},
|
||||
"15": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"16": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"seed": 0,
|
||||
"steps": 20,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["14", 0],
|
||||
"negative": ["14", 0],
|
||||
"latent_image": ["15", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"17": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["16", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"18": {
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"vae": ["3", 0],
|
||||
"image1": ["6", 0],
|
||||
"image2": ["65", 0],
|
||||
"prompt": ""
|
||||
}
|
||||
},
|
||||
"19": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"40": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"seed": 0,
|
||||
"steps": 20,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["18", 0],
|
||||
"negative": ["18", 0],
|
||||
"latent_image": ["19", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"41": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["40", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen-image-2512-fp8.safetensors",
|
||||
"weight_dtype": "default"
|
||||
}
|
||||
},
|
||||
"21": {
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"inputs": {
|
||||
"model": ["20", 0],
|
||||
"lora_name": "Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"strength_model": 1.0
|
||||
}
|
||||
},
|
||||
"22": {
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"inputs": {
|
||||
"model": ["21", 0],
|
||||
"shift": 3.1
|
||||
}
|
||||
},
|
||||
"23": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"24": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": "low quality, malformed object, duplicate object, fragment, extra item, person, hand, modern object, text, label, logo, watermark"
|
||||
}
|
||||
},
|
||||
"25": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"26": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["22", 0],
|
||||
"seed": 0,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["23", 0],
|
||||
"negative": ["24", 0],
|
||||
"latent_image": ["25", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"27": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["26", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"28": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": ["13", 0],
|
||||
"upscale_method": "lanczos",
|
||||
"width": 512,
|
||||
"height": 1024,
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"29": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": ["17", 0],
|
||||
"upscale_method": "lanczos",
|
||||
"width": 512,
|
||||
"height": 1024,
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"30": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": ["41", 0],
|
||||
"upscale_method": "lanczos",
|
||||
"width": 512,
|
||||
"height": 1024,
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"31": {
|
||||
"class_type": "ImageStitch",
|
||||
"inputs": {
|
||||
"image1": ["28", 0],
|
||||
"image2": ["29", 0],
|
||||
"direction": "right",
|
||||
"match_image_size": true,
|
||||
"spacing_width": 0,
|
||||
"spacing_color": "white"
|
||||
}
|
||||
},
|
||||
"32": {
|
||||
"class_type": "ImageStitch",
|
||||
"inputs": {
|
||||
"image1": ["31", 0],
|
||||
"image2": ["30", 0],
|
||||
"direction": "right",
|
||||
"match_image_size": true,
|
||||
"spacing_width": 0,
|
||||
"spacing_color": "white"
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"class_type": "ImageCrop",
|
||||
"inputs": {
|
||||
"image": ["4", 0],
|
||||
"width": 768,
|
||||
"height": 512,
|
||||
"x": 128,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
"34": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": ["33", 0],
|
||||
"upscale_method": "lanczos",
|
||||
"width": 768,
|
||||
"height": 512,
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"35": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": ["27", 0],
|
||||
"upscale_method": "lanczos",
|
||||
"width": 768,
|
||||
"height": 512,
|
||||
"crop": "disabled"
|
||||
}
|
||||
},
|
||||
"36": {
|
||||
"class_type": "ImageStitch",
|
||||
"inputs": {
|
||||
"image1": ["34", 0],
|
||||
"image2": ["35", 0],
|
||||
"direction": "right",
|
||||
"match_image_size": true,
|
||||
"spacing_width": 0,
|
||||
"spacing_color": "white"
|
||||
}
|
||||
},
|
||||
"37": {
|
||||
"class_type": "ImageStitch",
|
||||
"inputs": {
|
||||
"image1": ["32", 0],
|
||||
"image2": ["36", 0],
|
||||
"direction": "down",
|
||||
"match_image_size": false,
|
||||
"spacing_width": 0,
|
||||
"spacing_color": "white"
|
||||
}
|
||||
},
|
||||
"front_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["13", 0],
|
||||
"filename_prefix": "voice-creator-v4/front"
|
||||
}
|
||||
},
|
||||
"side_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["17", 0],
|
||||
"filename_prefix": "voice-creator-v4/side"
|
||||
}
|
||||
},
|
||||
"back_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["41", 0],
|
||||
"filename_prefix": "voice-creator-v4/back"
|
||||
}
|
||||
},
|
||||
"42": {
|
||||
"class_type": "TextEncodeQwenImageEditPlus",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"vae": ["3", 0],
|
||||
"image1": ["6", 0],
|
||||
"prompt": ""
|
||||
}
|
||||
},
|
||||
"43": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 768,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"44": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"seed": 0,
|
||||
"steps": 20,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["42", 0],
|
||||
"negative": ["42", 0],
|
||||
"latent_image": ["43", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"45": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["44", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"scene_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["45", 0],
|
||||
"filename_prefix": "voice-creator-v4/scene"
|
||||
}
|
||||
},
|
||||
"prop1_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["27", 0],
|
||||
"filename_prefix": "voice-creator-v4/prop-1"
|
||||
}
|
||||
},
|
||||
"50": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"51": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"52": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["22", 0],
|
||||
"seed": 0,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["50", 0],
|
||||
"negative": ["24", 0],
|
||||
"latent_image": ["51", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"53": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["52", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"prop2_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["53", 0],
|
||||
"filename_prefix": "voice-creator-v4/prop-2"
|
||||
}
|
||||
},
|
||||
"54": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"55": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"56": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["22", 0],
|
||||
"seed": 0,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["54", 0],
|
||||
"negative": ["24", 0],
|
||||
"latent_image": ["55", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"57": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["56", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"prop3_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["57", 0],
|
||||
"filename_prefix": "voice-creator-v4/prop-3"
|
||||
}
|
||||
},
|
||||
"60": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "voice_creator_pose_front.png"
|
||||
}
|
||||
},
|
||||
"61": {
|
||||
"class_type": "FluxKontextImageScale",
|
||||
"inputs": {
|
||||
"image": ["60", 0]
|
||||
}
|
||||
},
|
||||
"62": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "voice_creator_pose_side.png"
|
||||
}
|
||||
},
|
||||
"63": {
|
||||
"class_type": "FluxKontextImageScale",
|
||||
"inputs": {
|
||||
"image": ["62", 0]
|
||||
}
|
||||
},
|
||||
"64": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "voice_creator_pose_back.png"
|
||||
}
|
||||
},
|
||||
"65": {
|
||||
"class_type": "FluxKontextImageScale",
|
||||
"inputs": {
|
||||
"image": ["64", 0]
|
||||
}
|
||||
},
|
||||
"66": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"67": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": "low quality, illustration, painting, cartoon, anime, glossy 3D render, modern clothing, hat, headwear, repeated front view, three-quarter view, cropped head, cropped feet, duplicate figure, extra figure, extra limbs, prop, text, label, logo, watermark"
|
||||
}
|
||||
},
|
||||
"68": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["22", 0],
|
||||
"seed": 0,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["66", 0],
|
||||
"negative": ["67", 0],
|
||||
"latent_image": ["69", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"69": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 1536,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"turnaround_decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["68", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"turnaround_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["turnaround_decode", 0],
|
||||
"filename_prefix": "voice-creator-v5/turnaround"
|
||||
}
|
||||
},
|
||||
"70": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"71": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"text": "low quality, catalog pose, studio portrait, character sheet, modern clothing, hat, headwear, cropped head, cropped hands, duplicate main character, extra limbs, repeated prop, text, label, logo, watermark"
|
||||
}
|
||||
},
|
||||
"72": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["22", 0],
|
||||
"seed": 0,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["70", 0],
|
||||
"negative": ["71", 0],
|
||||
"latent_image": ["73", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"73": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 768,
|
||||
"height": 512,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"scene_t2i_decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["72", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"scene_t2i_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["scene_t2i_decode", 0],
|
||||
"filename_prefix": "voice-creator-v5/scene"
|
||||
}
|
||||
}
|
||||
}
|
||||
89
workflows/comfyui-character-identity-v3.json
Normal file
89
workflows/comfyui-character-identity-v3.json
Normal file
@ -0,0 +1,89 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen-image-2512-fp8.safetensors",
|
||||
"weight_dtype": "default"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"lora_name": "Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
|
||||
"strength_model": 1.0
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"inputs": {
|
||||
"model": ["2", 0],
|
||||
"shift": 3.1
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["4", 0],
|
||||
"text": ""
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["4", 0],
|
||||
"text": "low resolution, low quality, deformed anatomy, malformed hands, extra people, duplicate person, cropped feet, modern clothing, modern objects, hat, text, logo, watermark, illustration, anime, cartoon, glossy CGI"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 1024,
|
||||
"height": 1536,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["3", 0],
|
||||
"seed": 0,
|
||||
"steps": 4,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["5", 0],
|
||||
"negative": ["6", 0],
|
||||
"latent_image": ["7", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["8", 0],
|
||||
"vae": ["9", 0]
|
||||
}
|
||||
},
|
||||
"identity_save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["10", 0],
|
||||
"filename_prefix": "voice-creator-v3/identity"
|
||||
}
|
||||
}
|
||||
}
|
||||
81
workflows/comfyui-character-turnaround-v3.json
Normal file
81
workflows/comfyui-character-turnaround-v3.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"1": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "qwen_image_edit_fp8_e4m3fn.safetensors",
|
||||
"weight_dtype": "fp8_e4m3fn"
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
|
||||
"type": "qwen_image",
|
||||
"device": "default"
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {
|
||||
"vae_name": "qwen_image_vae.safetensors"
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "voice_creator_darrag_identity_v3.png"
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "FluxKontextImageScale",
|
||||
"inputs": {
|
||||
"image": ["4", 0]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "TextEncodeQwenImageEdit",
|
||||
"inputs": {
|
||||
"clip": ["2", 0],
|
||||
"vae": ["3", 0],
|
||||
"image": ["5", 0],
|
||||
"prompt": "Create a clean full-body turnaround sheet of this exact character. Show exactly three full-body views on a plain warm-gray studio background: front view, exact left side profile, and exact back view, evenly spaced in one horizontal row. Keep the same single character, face, beard, hair, age, broad body, leather blacksmith apron, olive-gray linen shirt, wool trousers, period leather boots, colors, materials, proportions, lighting, and photorealistic style in all three views. Correct the shirt to a collarless 1420-1500 linen work shirt with ties and no zipper. Arms relaxed, neutral pose, complete head-to-boots silhouette, identical scale and baseline. No hat, no helmet, no modern clothing, no modern boots, no tools, no props, no scenery, no workshop, no labels, no text, no portrait crop, no three-quarter view, no repeated front view, no extra person."
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"width": 2048,
|
||||
"height": 1024,
|
||||
"batch_size": 1
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["1", 0],
|
||||
"seed": 4251714201,
|
||||
"steps": 20,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"positive": ["6", 0],
|
||||
"negative": ["6", 0],
|
||||
"latent_image": ["7", 0],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["8", 0],
|
||||
"vae": ["3", 0]
|
||||
}
|
||||
},
|
||||
"10": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["9", 0],
|
||||
"filename_prefix": "voice-creator-v3/darrag_turnaround"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user