First in space
Browse files- README copy.md +31 -0
- app.py +240 -0
- backend/__init__.py +1 -0
- backend/__pycache__/__init__.cpython-313.pyc +0 -0
- backend/__pycache__/config.cpython-313.pyc +0 -0
- backend/__pycache__/epub.cpython-313.pyc +0 -0
- backend/__pycache__/export.cpython-313.pyc +0 -0
- backend/__pycache__/input_files.cpython-313.pyc +0 -0
- backend/__pycache__/omnivoice_adapter.cpython-313.pyc +0 -0
- backend/__pycache__/render_pipeline.cpython-313.pyc +0 -0
- backend/__pycache__/session_store.cpython-313.pyc +0 -0
- backend/__pycache__/types.cpython-313.pyc +0 -0
- backend/config.py +11 -0
- backend/epub.py +159 -0
- backend/export.py +179 -0
- backend/input_files.py +35 -0
- backend/omnivoice_adapter.py +128 -0
- backend/render_pipeline.py +113 -0
- backend/session_store.py +67 -0
- backend/types.py +66 -0
- frontend/app.js +985 -0
- frontend/index.html +20 -0
- frontend/styles.css +1056 -0
- pytest.ini +2 -0
- requirements-dev.txt +6 -0
- requirements.txt +10 -0
- tests/test_epub.py +74 -0
- tests/test_input_files.py +45 -0
- tests/test_render_pipeline.py +63 -0
- tests/test_session_store.py +28 -0
README copy.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Scriptorium
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: gradio
|
| 7 |
+
app_file: app.py
|
| 8 |
+
python_version: 3.10
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Scriptorium
|
| 14 |
+
|
| 15 |
+
Public Hugging Face Space for turning EPUBs into audiobooks with OmniVoice TTS.
|
| 16 |
+
|
| 17 |
+
## Runtime notes
|
| 18 |
+
|
| 19 |
+
- Designed for a GPU-backed Gradio Space.
|
| 20 |
+
- Public demo safeguards cap EPUB size, estimated runtime, preview length, and clone sample duration.
|
| 21 |
+
- Outputs are session-scoped temporary files only.
|
| 22 |
+
|
| 23 |
+
## Local development
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
python3 -m venv .venv
|
| 27 |
+
source .venv/bin/activate
|
| 28 |
+
pip install -r requirements.txt
|
| 29 |
+
pytest -q
|
| 30 |
+
python app.py
|
| 31 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
import socket
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
| 8 |
+
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from gradio import Server
|
| 10 |
+
|
| 11 |
+
from backend.config import (
|
| 12 |
+
APP_TITLE,
|
| 13 |
+
MAX_PREVIEW_CHARACTERS,
|
| 14 |
+
MAX_REFERENCE_AUDIO_SECONDS,
|
| 15 |
+
SESSION_TTL_SECONDS,
|
| 16 |
+
TEMP_ROOT,
|
| 17 |
+
)
|
| 18 |
+
from backend.epub import EpubConfig, parse_epub
|
| 19 |
+
from backend.export import export_audiobook
|
| 20 |
+
from backend.input_files import resolve_uploaded_name, resolve_uploaded_path
|
| 21 |
+
from backend.omnivoice_adapter import OmniVoiceAdapter
|
| 22 |
+
from backend.render_pipeline import RenderPipeline
|
| 23 |
+
from backend.session_store import SessionStore
|
| 24 |
+
from backend.types import VoiceConfig
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).parent
|
| 28 |
+
FRONTEND_DIR = ROOT / "frontend"
|
| 29 |
+
TEMP_ROOT.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
app = Server()
|
| 32 |
+
app.title = APP_TITLE
|
| 33 |
+
|
| 34 |
+
store = SessionStore(root=TEMP_ROOT, ttl_seconds=SESSION_TTL_SECONDS)
|
| 35 |
+
synthesizer = OmniVoiceAdapter()
|
| 36 |
+
pipeline = RenderPipeline(session_root=TEMP_ROOT, synthesizer=synthesizer)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _session_root(session_id: str) -> Path:
|
| 40 |
+
return store.ensure_session(session_id).root
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _book_payload(session_id: str) -> Dict[str, Any]:
|
| 44 |
+
return store.load_json(session_id, "book.json")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _selected_book(session_id: str, selected_ids: List[str]) -> Dict[str, Any]:
|
| 48 |
+
book = _book_payload(session_id)
|
| 49 |
+
selected = set(selected_ids)
|
| 50 |
+
chapters = []
|
| 51 |
+
for chapter in book["chapters"]:
|
| 52 |
+
chapter = dict(chapter)
|
| 53 |
+
chapter["included"] = chapter["id"] in selected
|
| 54 |
+
chapters.append(chapter)
|
| 55 |
+
book["chapters"] = chapters
|
| 56 |
+
return book
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _voice_config_for_backend(voice_config: Dict[str, Any], session_id: str) -> Dict[str, Any]:
|
| 60 |
+
config = dict(voice_config)
|
| 61 |
+
sample_path = config.get("samplePath") or config.get("sample_path")
|
| 62 |
+
if sample_path:
|
| 63 |
+
sample_file = resolve_uploaded_path(sample_path)
|
| 64 |
+
target = _session_root(session_id) / "uploads" / sample_file.name
|
| 65 |
+
shutil.copyfile(sample_file, target)
|
| 66 |
+
config["samplePath"] = str(target)
|
| 67 |
+
return config
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@app.api(name="parse_epub")
|
| 71 |
+
def parse_epub_api(session_id: str, epub_file: str) -> Dict[str, Any]:
|
| 72 |
+
store.cleanup_expired()
|
| 73 |
+
session = store.ensure_session(session_id)
|
| 74 |
+
source = resolve_uploaded_path(epub_file)
|
| 75 |
+
upload_name = resolve_uploaded_name(epub_file)
|
| 76 |
+
if not source.exists():
|
| 77 |
+
raise ValueError("Uploaded EPUB file was not found")
|
| 78 |
+
target = session.root / "uploads" / upload_name
|
| 79 |
+
shutil.copyfile(source, target)
|
| 80 |
+
payload = parse_epub(target, config=EpubConfig())
|
| 81 |
+
store.save_json(session_id, "book.json", payload)
|
| 82 |
+
return payload
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@app.api(name="generate_preview")
|
| 86 |
+
def generate_preview_api(
|
| 87 |
+
session_id: str,
|
| 88 |
+
chapter_id: str,
|
| 89 |
+
voice_config: Dict[str, Any],
|
| 90 |
+
diffusion_steps: int = 32,
|
| 91 |
+
speed: float = 1.0,
|
| 92 |
+
) -> Dict[str, Any]:
|
| 93 |
+
book = _book_payload(session_id)
|
| 94 |
+
chapter = next((item for item in book["chapters"] if item["id"] == chapter_id), None)
|
| 95 |
+
if chapter is None:
|
| 96 |
+
raise ValueError("Chapter not found for preview")
|
| 97 |
+
text = str(chapter["text"])[:MAX_PREVIEW_CHARACTERS]
|
| 98 |
+
preview_path = _session_root(session_id) / "previews" / f"{chapter_id}.wav"
|
| 99 |
+
voice = _voice_config_for_backend(voice_config, session_id)
|
| 100 |
+
result = synthesizer.synthesize(
|
| 101 |
+
text=text,
|
| 102 |
+
output_path=preview_path,
|
| 103 |
+
voice_config=VoiceConfig.from_dict(voice),
|
| 104 |
+
diffusion_steps=diffusion_steps,
|
| 105 |
+
speed=speed,
|
| 106 |
+
)
|
| 107 |
+
return {
|
| 108 |
+
"url": f"/files/{session_id}/previews/{preview_path.name}",
|
| 109 |
+
"duration_seconds": result["duration_seconds"],
|
| 110 |
+
"backend": result["backend"],
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@app.api(name="start_render", concurrency_limit=1)
|
| 115 |
+
def start_render_api(
|
| 116 |
+
session_id: str,
|
| 117 |
+
selected_chapter_ids: List[str],
|
| 118 |
+
voice_config: Dict[str, Any],
|
| 119 |
+
diffusion_steps: int = 32,
|
| 120 |
+
speed: float = 1.0,
|
| 121 |
+
):
|
| 122 |
+
job = pipeline.get_job(session_id)
|
| 123 |
+
if job.status == "running":
|
| 124 |
+
raise ValueError("A render is already active for this session")
|
| 125 |
+
|
| 126 |
+
book = _selected_book(session_id, selected_chapter_ids)
|
| 127 |
+
voice = _voice_config_for_backend(voice_config, session_id)
|
| 128 |
+
captured: List[Dict[str, Any]] = []
|
| 129 |
+
for event in pipeline.render(
|
| 130 |
+
session_id=session_id,
|
| 131 |
+
book=book,
|
| 132 |
+
chapters=book["chapters"],
|
| 133 |
+
voice_config=voice,
|
| 134 |
+
diffusion_steps=diffusion_steps,
|
| 135 |
+
speed=speed,
|
| 136 |
+
):
|
| 137 |
+
captured.append(event)
|
| 138 |
+
yield event
|
| 139 |
+
|
| 140 |
+
if captured and captured[-1]["type"] == "completed":
|
| 141 |
+
durations = {
|
| 142 |
+
event["chapter_id"]: event["duration_seconds"]
|
| 143 |
+
for event in captured
|
| 144 |
+
if event["type"] == "chapter_done"
|
| 145 |
+
}
|
| 146 |
+
manifest = {
|
| 147 |
+
"book": book,
|
| 148 |
+
"chapters": [
|
| 149 |
+
{
|
| 150 |
+
**chapter,
|
| 151 |
+
"duration_seconds": durations.get(chapter["id"], chapter.get("duration_seconds", 0)),
|
| 152 |
+
}
|
| 153 |
+
for chapter in book["chapters"]
|
| 154 |
+
if chapter.get("included")
|
| 155 |
+
],
|
| 156 |
+
"render_result": captured[-1],
|
| 157 |
+
}
|
| 158 |
+
store.save_json(session_id, "render_manifest.json", manifest)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@app.api(name="pause_render")
|
| 162 |
+
def pause_render_api(session_id: str) -> Dict[str, Any]:
|
| 163 |
+
return pipeline.pause(session_id)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@app.api(name="resume_render")
|
| 167 |
+
def resume_render_api(session_id: str) -> Dict[str, Any]:
|
| 168 |
+
return pipeline.resume(session_id)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@app.api(name="cancel_render")
|
| 172 |
+
def cancel_render_api(session_id: str) -> Dict[str, Any]:
|
| 173 |
+
pipeline.cancel(session_id)
|
| 174 |
+
return {"type": "cancelled", "session_id": session_id}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@app.api(name="export_audiobook")
|
| 178 |
+
def export_audiobook_api(
|
| 179 |
+
session_id: str,
|
| 180 |
+
format: str = "m4a",
|
| 181 |
+
metadata: Dict[str, str] | None = None,
|
| 182 |
+
embed_markers: bool = True,
|
| 183 |
+
) -> Dict[str, Any]:
|
| 184 |
+
manifest = store.load_json(session_id, "render_manifest.json")
|
| 185 |
+
export = export_audiobook(
|
| 186 |
+
session_root=_session_root(session_id),
|
| 187 |
+
chapters=manifest["chapters"],
|
| 188 |
+
output_format=format,
|
| 189 |
+
metadata=metadata or {},
|
| 190 |
+
embed_markers=embed_markers,
|
| 191 |
+
cover_path=None,
|
| 192 |
+
)
|
| 193 |
+
export["url"] = f"/files/{session_id}/exports/{Path(export['file']).name}"
|
| 194 |
+
return export
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@app.get("/", response_class=HTMLResponse)
|
| 198 |
+
async def homepage() -> HTMLResponse:
|
| 199 |
+
return HTMLResponse((FRONTEND_DIR / "index.html").read_text(encoding="utf-8"))
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@app.get("/health")
|
| 203 |
+
async def health() -> Dict[str, str]:
|
| 204 |
+
return {"status": "ok"}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@app.get("/files/{session_id}/{kind}/{filename}")
|
| 208 |
+
async def serve_generated_file(session_id: str, kind: str, filename: str):
|
| 209 |
+
path = _session_root(session_id) / kind / filename
|
| 210 |
+
if not path.exists():
|
| 211 |
+
return JSONResponse({"error": "file not found"}, status_code=404)
|
| 212 |
+
return FileResponse(path)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="frontend-static")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _port_is_available(port: int, host: str = "127.0.0.1") -> bool:
|
| 219 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 220 |
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 221 |
+
return sock.connect_ex((host, port)) != 0
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def _choose_server_port(preferred: int = 7860, attempts: int = 10) -> int:
|
| 225 |
+
env_port = os.getenv("GRADIO_SERVER_PORT")
|
| 226 |
+
if env_port:
|
| 227 |
+
return int(env_port)
|
| 228 |
+
for port in range(preferred, preferred + attempts):
|
| 229 |
+
if _port_is_available(port):
|
| 230 |
+
return port
|
| 231 |
+
raise OSError(
|
| 232 |
+
f"Cannot find empty port in range: {preferred}-{preferred + attempts - 1}. "
|
| 233 |
+
"Set GRADIO_SERVER_PORT to override."
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
if __name__ == "__main__":
|
| 238 |
+
server_port = _choose_server_port()
|
| 239 |
+
print(f"Starting {APP_TITLE} on port {server_port}")
|
| 240 |
+
app.launch(server_name="0.0.0.0", server_port=server_port)
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Scriptorium backend package."""
|
backend/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (159 Bytes). View file
|
|
|
backend/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (464 Bytes). View file
|
|
|
backend/__pycache__/epub.cpython-313.pyc
ADDED
|
Binary file (8.59 kB). View file
|
|
|
backend/__pycache__/export.cpython-313.pyc
ADDED
|
Binary file (8.31 kB). View file
|
|
|
backend/__pycache__/input_files.cpython-313.pyc
ADDED
|
Binary file (2.02 kB). View file
|
|
|
backend/__pycache__/omnivoice_adapter.cpython-313.pyc
ADDED
|
Binary file (5.73 kB). View file
|
|
|
backend/__pycache__/render_pipeline.cpython-313.pyc
ADDED
|
Binary file (5.32 kB). View file
|
|
|
backend/__pycache__/session_store.cpython-313.pyc
ADDED
|
Binary file (4.21 kB). View file
|
|
|
backend/__pycache__/types.cpython-313.pyc
ADDED
|
Binary file (3.47 kB). View file
|
|
|
backend/config.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
APP_TITLE = "Scriptorium"
|
| 5 |
+
TEMP_ROOT = Path("/tmp/scriptorium")
|
| 6 |
+
MAX_EPUB_BYTES = 25 * 1024 * 1024
|
| 7 |
+
MAX_REFERENCE_AUDIO_SECONDS = 30
|
| 8 |
+
MAX_PREVIEW_CHARACTERS = 1800
|
| 9 |
+
MAX_ESTIMATED_MINUTES = 12 * 60
|
| 10 |
+
SESSION_TTL_SECONDS = 60 * 60
|
| 11 |
+
CHARS_PER_MINUTE = 900
|
backend/epub.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Dict, Iterable, List, Optional, Union
|
| 4 |
+
import re
|
| 5 |
+
import zipfile
|
| 6 |
+
|
| 7 |
+
from bs4 import BeautifulSoup
|
| 8 |
+
from ebooklib import ITEM_DOCUMENT, epub
|
| 9 |
+
|
| 10 |
+
from backend.config import CHARS_PER_MINUTE, MAX_EPUB_BYTES, MAX_ESTIMATED_MINUTES
|
| 11 |
+
from backend.types import Chapter
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class EpubConfig:
|
| 16 |
+
max_file_bytes: int = MAX_EPUB_BYTES
|
| 17 |
+
chars_per_minute: int = CHARS_PER_MINUTE
|
| 18 |
+
max_estimated_minutes: int = MAX_ESTIMATED_MINUTES
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _clean_text(raw_html: str) -> str:
|
| 22 |
+
soup = BeautifulSoup(raw_html, "html.parser")
|
| 23 |
+
text = soup.get_text("\n", strip=True)
|
| 24 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 25 |
+
return text.strip()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _chapter_title(item: epub.EpubItem, text: str, index: int) -> str:
|
| 29 |
+
soup = BeautifulSoup(item.get_content(), "html.parser")
|
| 30 |
+
heading = soup.find(["h1", "h2", "title"])
|
| 31 |
+
if heading and heading.get_text(strip=True):
|
| 32 |
+
return heading.get_text(strip=True)
|
| 33 |
+
if item.title:
|
| 34 |
+
return str(item.title)
|
| 35 |
+
first_line = text.splitlines()[0].strip() if text.splitlines() else ""
|
| 36 |
+
return first_line[:80] or f"Chapter {index + 1}"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _iter_spine_documents(book: epub.EpubBook) -> Iterable[epub.EpubItem]:
|
| 40 |
+
seen = set()
|
| 41 |
+
for spine_item in book.spine:
|
| 42 |
+
item_id = spine_item[0] if isinstance(spine_item, tuple) else spine_item
|
| 43 |
+
if item_id == "nav":
|
| 44 |
+
continue
|
| 45 |
+
item = book.get_item_with_id(item_id)
|
| 46 |
+
if item is None:
|
| 47 |
+
continue
|
| 48 |
+
seen.add(item.get_id())
|
| 49 |
+
yield item
|
| 50 |
+
for item in book.get_items_of_type(ITEM_DOCUMENT):
|
| 51 |
+
if item.get_id() in seen:
|
| 52 |
+
continue
|
| 53 |
+
if item.get_id() == "nav" or getattr(item, "file_name", "") == "nav.xhtml":
|
| 54 |
+
continue
|
| 55 |
+
yield item
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _metadata_first(book: epub.EpubBook, name: str) -> Optional[str]:
|
| 59 |
+
values = book.get_metadata("DC", name)
|
| 60 |
+
if not values:
|
| 61 |
+
return None
|
| 62 |
+
value = values[0][0]
|
| 63 |
+
return str(value).strip() if value else None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _validate_epub_container(path: Path) -> None:
|
| 67 |
+
if not zipfile.is_zipfile(path):
|
| 68 |
+
raise ValueError("Invalid EPUB: uploaded file is not a valid zip container")
|
| 69 |
+
try:
|
| 70 |
+
with zipfile.ZipFile(path) as archive:
|
| 71 |
+
names = set(archive.namelist())
|
| 72 |
+
mimetype = None
|
| 73 |
+
if "mimetype" in names:
|
| 74 |
+
mimetype = archive.read("mimetype").decode("utf-8", errors="ignore").strip()
|
| 75 |
+
has_container = "META-INF/container.xml" in names
|
| 76 |
+
except zipfile.BadZipFile as exc:
|
| 77 |
+
raise ValueError("Invalid EPUB: could not read zip container") from exc
|
| 78 |
+
|
| 79 |
+
if mimetype == "application/epub+zip" or has_container:
|
| 80 |
+
return
|
| 81 |
+
raise ValueError("Invalid EPUB: missing EPUB container metadata")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def parse_epub(epub_path: Union[Path, str], config: Optional[EpubConfig] = None) -> Dict[str, object]:
|
| 85 |
+
config = config or EpubConfig()
|
| 86 |
+
path = Path(epub_path)
|
| 87 |
+
if not path.exists():
|
| 88 |
+
raise ValueError("Invalid EPUB: file does not exist")
|
| 89 |
+
if path.stat().st_size > config.max_file_bytes:
|
| 90 |
+
raise ValueError("EPUB upload too large")
|
| 91 |
+
_validate_epub_container(path)
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
book = epub.read_epub(str(path))
|
| 95 |
+
except Exception as exc: # pragma: no cover - exercised by malformed input
|
| 96 |
+
raise ValueError("Invalid EPUB: could not parse file") from exc
|
| 97 |
+
|
| 98 |
+
chapters: List[Chapter] = []
|
| 99 |
+
for index, item in enumerate(_iter_spine_documents(book)):
|
| 100 |
+
text = _clean_text(item.get_content())
|
| 101 |
+
if not text:
|
| 102 |
+
continue
|
| 103 |
+
chars = len(text)
|
| 104 |
+
est_minutes = max(1, round(chars / config.chars_per_minute))
|
| 105 |
+
chapters.append(
|
| 106 |
+
Chapter(
|
| 107 |
+
id=item.get_id() or f"chapter-{index + 1}",
|
| 108 |
+
label=_roman(index + 1),
|
| 109 |
+
title=_chapter_title(item, text, index),
|
| 110 |
+
text=text,
|
| 111 |
+
chars=chars,
|
| 112 |
+
est_minutes=est_minutes,
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
if not chapters:
|
| 117 |
+
raise ValueError("Invalid EPUB: no readable chapters found")
|
| 118 |
+
|
| 119 |
+
total_minutes = sum(ch.est_minutes for ch in chapters)
|
| 120 |
+
if total_minutes > config.max_estimated_minutes:
|
| 121 |
+
raise ValueError("EPUB exceeds maximum supported runtime")
|
| 122 |
+
|
| 123 |
+
title = _metadata_first(book, "title") or path.stem.replace("-", " ").title()
|
| 124 |
+
author = _metadata_first(book, "creator") or "Unknown author"
|
| 125 |
+
language = _metadata_first(book, "language") or "Unknown language"
|
| 126 |
+
rights = _metadata_first(book, "rights") or "Rights unknown"
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"title": title,
|
| 130 |
+
"author": author,
|
| 131 |
+
"meta": f"{language.upper()} \u00b7 {rights}",
|
| 132 |
+
"cover_url": None,
|
| 133 |
+
"chapters": [chapter.to_dict() for chapter in chapters],
|
| 134 |
+
"estimated_minutes": total_minutes,
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _roman(number: int) -> str:
|
| 139 |
+
numerals = (
|
| 140 |
+
(1000, "m"),
|
| 141 |
+
(900, "cm"),
|
| 142 |
+
(500, "d"),
|
| 143 |
+
(400, "cd"),
|
| 144 |
+
(100, "c"),
|
| 145 |
+
(90, "xc"),
|
| 146 |
+
(50, "l"),
|
| 147 |
+
(40, "xl"),
|
| 148 |
+
(10, "x"),
|
| 149 |
+
(9, "ix"),
|
| 150 |
+
(5, "v"),
|
| 151 |
+
(4, "iv"),
|
| 152 |
+
(1, "i"),
|
| 153 |
+
)
|
| 154 |
+
result = []
|
| 155 |
+
remainder = number
|
| 156 |
+
for value, numeral in numerals:
|
| 157 |
+
count, remainder = divmod(remainder, value)
|
| 158 |
+
result.append(numeral * count)
|
| 159 |
+
return "".join(result)
|
backend/export.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import shutil
|
| 3 |
+
import subprocess
|
| 4 |
+
import wave
|
| 5 |
+
import zipfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Dict, Iterable, List, Tuple
|
| 8 |
+
|
| 9 |
+
from mutagen.easyid3 import EasyID3
|
| 10 |
+
from mutagen.id3 import ID3, APIC, TIT2, TPE1
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def export_audiobook(
|
| 14 |
+
*,
|
| 15 |
+
session_root: Path,
|
| 16 |
+
chapters: List[Dict[str, object]],
|
| 17 |
+
output_format: str,
|
| 18 |
+
metadata: Dict[str, str],
|
| 19 |
+
embed_markers: bool,
|
| 20 |
+
cover_path: Path | None = None,
|
| 21 |
+
) -> Dict[str, object]:
|
| 22 |
+
exports_dir = session_root / "exports"
|
| 23 |
+
exports_dir.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
rendered = _rendered_files(session_root)
|
| 25 |
+
if not rendered:
|
| 26 |
+
raise ValueError("No rendered chapters available for export")
|
| 27 |
+
|
| 28 |
+
if output_format == "zip":
|
| 29 |
+
archive = exports_dir / "reading-room-chapters.zip"
|
| 30 |
+
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
| 31 |
+
for source in rendered:
|
| 32 |
+
zf.write(source, arcname=source.name)
|
| 33 |
+
return {"file": str(archive), "format": "zip", "size_bytes": archive.stat().st_size}
|
| 34 |
+
|
| 35 |
+
base_wav = exports_dir / "reading-room.wav"
|
| 36 |
+
total_seconds = _concat_wav(rendered, base_wav)
|
| 37 |
+
chapter_offsets = _chapter_offsets(chapters)
|
| 38 |
+
|
| 39 |
+
if output_format == "m4a":
|
| 40 |
+
output = exports_dir / "reading-room.m4a"
|
| 41 |
+
_ffmpeg_transcode(
|
| 42 |
+
source=base_wav,
|
| 43 |
+
target=output,
|
| 44 |
+
codec_args=["-c:a", "aac", "-b:a", "96k"],
|
| 45 |
+
metadata=metadata,
|
| 46 |
+
chapter_offsets=chapter_offsets if embed_markers else None,
|
| 47 |
+
)
|
| 48 |
+
elif output_format == "mp3":
|
| 49 |
+
output = exports_dir / "reading-room.mp3"
|
| 50 |
+
_ffmpeg_transcode(
|
| 51 |
+
source=base_wav,
|
| 52 |
+
target=output,
|
| 53 |
+
codec_args=["-c:a", "libmp3lame", "-b:a", "128k"],
|
| 54 |
+
metadata=metadata,
|
| 55 |
+
chapter_offsets=None,
|
| 56 |
+
)
|
| 57 |
+
_tag_mp3(output, metadata, cover_path)
|
| 58 |
+
else:
|
| 59 |
+
raise ValueError("Unsupported export format")
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
"file": str(output),
|
| 63 |
+
"format": output_format,
|
| 64 |
+
"runtime_seconds": total_seconds,
|
| 65 |
+
"size_bytes": output.stat().st_size,
|
| 66 |
+
"chapter_count": len(chapters),
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _rendered_files(session_root: Path) -> List[Path]:
|
| 71 |
+
render_dir = session_root / "renders"
|
| 72 |
+
return sorted(render_dir.glob("*.wav"))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _concat_wav(inputs: Iterable[Path], output: Path) -> int:
|
| 76 |
+
inputs = list(inputs)
|
| 77 |
+
params = None
|
| 78 |
+
total_frames = 0
|
| 79 |
+
frames: List[bytes] = []
|
| 80 |
+
for wav_path in inputs:
|
| 81 |
+
with wave.open(str(wav_path), "rb") as handle:
|
| 82 |
+
if params is None:
|
| 83 |
+
params = handle.getparams()
|
| 84 |
+
elif handle.getframerate() != params.framerate:
|
| 85 |
+
raise ValueError("Mismatched sample rates across rendered chapters")
|
| 86 |
+
chunk = handle.readframes(handle.getnframes())
|
| 87 |
+
frames.append(chunk)
|
| 88 |
+
total_frames += handle.getnframes()
|
| 89 |
+
|
| 90 |
+
if params is None:
|
| 91 |
+
raise ValueError("No WAV inputs to concatenate")
|
| 92 |
+
|
| 93 |
+
with wave.open(str(output), "wb") as handle:
|
| 94 |
+
handle.setparams(params)
|
| 95 |
+
for chunk in frames:
|
| 96 |
+
handle.writeframes(chunk)
|
| 97 |
+
|
| 98 |
+
return int(total_frames / params.framerate)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _ffmpeg_transcode(
|
| 102 |
+
*,
|
| 103 |
+
source: Path,
|
| 104 |
+
target: Path,
|
| 105 |
+
codec_args: List[str],
|
| 106 |
+
metadata: Dict[str, str],
|
| 107 |
+
chapter_offsets: List[Tuple[str, int, int]] | None,
|
| 108 |
+
) -> None:
|
| 109 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
ffmetadata_path = None
|
| 111 |
+
cmd = ["ffmpeg", "-y", "-i", str(source)]
|
| 112 |
+
if chapter_offsets:
|
| 113 |
+
ffmetadata_path = target.with_suffix(".ffmeta")
|
| 114 |
+
ffmetadata_path.write_text(_ffmetadata(metadata, chapter_offsets), encoding="utf-8")
|
| 115 |
+
cmd.extend(["-i", str(ffmetadata_path), "-map_metadata", "1"])
|
| 116 |
+
else:
|
| 117 |
+
for key, value in metadata.items():
|
| 118 |
+
cmd.extend(["-metadata", f"{key}={value}"])
|
| 119 |
+
cmd.extend(codec_args)
|
| 120 |
+
cmd.append(str(target))
|
| 121 |
+
subprocess.run(cmd, check=True, capture_output=True)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _ffmetadata(metadata: Dict[str, str], chapter_offsets: List[Tuple[str, int, int]]) -> str:
|
| 125 |
+
lines = [";FFMETADATA1"]
|
| 126 |
+
for key, value in metadata.items():
|
| 127 |
+
lines.append(f"{key}={value}")
|
| 128 |
+
for title, start, end in chapter_offsets:
|
| 129 |
+
lines.extend(
|
| 130 |
+
[
|
| 131 |
+
"[CHAPTER]",
|
| 132 |
+
"TIMEBASE=1/1000",
|
| 133 |
+
f"START={start}",
|
| 134 |
+
f"END={end}",
|
| 135 |
+
f"title={title}",
|
| 136 |
+
]
|
| 137 |
+
)
|
| 138 |
+
return "\n".join(lines)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _chapter_offsets(chapters: List[Dict[str, object]]) -> List[Tuple[str, int, int]]:
|
| 142 |
+
offsets = []
|
| 143 |
+
start_ms = 0
|
| 144 |
+
for chapter in chapters:
|
| 145 |
+
duration_ms = int(chapter.get("duration_seconds", 0) * 1000)
|
| 146 |
+
offsets.append((str(chapter["title"]), start_ms, start_ms + duration_ms))
|
| 147 |
+
start_ms += duration_ms
|
| 148 |
+
return offsets
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _tag_mp3(path: Path, metadata: Dict[str, str], cover_path: Path | None) -> None:
|
| 152 |
+
try:
|
| 153 |
+
tags = EasyID3(str(path))
|
| 154 |
+
except Exception:
|
| 155 |
+
tags = EasyID3()
|
| 156 |
+
tags.save(str(path))
|
| 157 |
+
tags = EasyID3(str(path))
|
| 158 |
+
if metadata.get("title"):
|
| 159 |
+
tags["title"] = [metadata["title"]]
|
| 160 |
+
if metadata.get("artist"):
|
| 161 |
+
tags["artist"] = [metadata["artist"]]
|
| 162 |
+
tags.save()
|
| 163 |
+
|
| 164 |
+
if cover_path and cover_path.exists():
|
| 165 |
+
audio = ID3(str(path))
|
| 166 |
+
audio.add(
|
| 167 |
+
APIC(
|
| 168 |
+
encoding=3,
|
| 169 |
+
mime="image/jpeg",
|
| 170 |
+
type=3,
|
| 171 |
+
desc="Cover",
|
| 172 |
+
data=cover_path.read_bytes(),
|
| 173 |
+
)
|
| 174 |
+
)
|
| 175 |
+
if metadata.get("title"):
|
| 176 |
+
audio.add(TIT2(encoding=3, text=metadata["title"]))
|
| 177 |
+
if metadata.get("artist"):
|
| 178 |
+
audio.add(TPE1(encoding=3, text=metadata["artist"]))
|
| 179 |
+
audio.save(str(path))
|
backend/input_files.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def resolve_uploaded_path(uploaded: Any) -> Path:
|
| 6 |
+
if isinstance(uploaded, Path):
|
| 7 |
+
return uploaded
|
| 8 |
+
if isinstance(uploaded, str):
|
| 9 |
+
return Path(uploaded)
|
| 10 |
+
if isinstance(uploaded, dict):
|
| 11 |
+
for key in ("path", "orig_name", "name"):
|
| 12 |
+
value = uploaded.get(key)
|
| 13 |
+
if isinstance(value, str) and value:
|
| 14 |
+
return Path(value)
|
| 15 |
+
raise TypeError(
|
| 16 |
+
f"Unsupported uploaded file payload: expected path-like value or Gradio file dict, got {type(uploaded).__name__}"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def resolve_uploaded_name(uploaded: Any) -> str:
|
| 21 |
+
if isinstance(uploaded, Path):
|
| 22 |
+
return uploaded.name
|
| 23 |
+
if isinstance(uploaded, str):
|
| 24 |
+
return Path(uploaded).name
|
| 25 |
+
if isinstance(uploaded, dict):
|
| 26 |
+
for key in ("orig_name", "name"):
|
| 27 |
+
value = uploaded.get(key)
|
| 28 |
+
if isinstance(value, str) and value:
|
| 29 |
+
return Path(value).name
|
| 30 |
+
path_value = uploaded.get("path")
|
| 31 |
+
if isinstance(path_value, str) and path_value:
|
| 32 |
+
return Path(path_value).name
|
| 33 |
+
raise TypeError(
|
| 34 |
+
f"Unsupported uploaded file payload: expected path-like value or Gradio file dict, got {type(uploaded).__name__}"
|
| 35 |
+
)
|
backend/omnivoice_adapter.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Dict, Optional
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import soundfile as sf
|
| 7 |
+
|
| 8 |
+
from backend.types import VoiceConfig
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
NARRATOR_PRESETS = {
|
| 12 |
+
"brother-anselm": "male, warm baritone, measured, british accent",
|
| 13 |
+
"dame-eglantine": "female, bright contralto, lyrical, british accent",
|
| 14 |
+
"the-reeve": "male, gravelly bass, wry, british accent",
|
| 15 |
+
"sister-cecilia": "female, clear soprano, gentle, british accent",
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class OmniVoiceAdapter:
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
model_id: str = "k2-fsa/OmniVoice",
|
| 23 |
+
device_map: str = "cuda:0",
|
| 24 |
+
dtype_name: str = "float16",
|
| 25 |
+
) -> None:
|
| 26 |
+
self.model_id = model_id
|
| 27 |
+
self.device_map = device_map
|
| 28 |
+
self.dtype_name = dtype_name
|
| 29 |
+
self._model = None
|
| 30 |
+
self._backend = "fallback"
|
| 31 |
+
|
| 32 |
+
def _load_model(self):
|
| 33 |
+
if self._model is not None:
|
| 34 |
+
return self._model
|
| 35 |
+
try:
|
| 36 |
+
import torch
|
| 37 |
+
from omnivoice import OmniVoice
|
| 38 |
+
except Exception:
|
| 39 |
+
self._backend = "fallback"
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
dtype = getattr(torch, self.dtype_name)
|
| 43 |
+
self._model = OmniVoice.from_pretrained(
|
| 44 |
+
self.model_id,
|
| 45 |
+
device_map=self.device_map,
|
| 46 |
+
dtype=dtype,
|
| 47 |
+
)
|
| 48 |
+
self._backend = "omnivoice"
|
| 49 |
+
return self._model
|
| 50 |
+
|
| 51 |
+
def synthesize(
|
| 52 |
+
self,
|
| 53 |
+
*,
|
| 54 |
+
text: str,
|
| 55 |
+
output_path: Path,
|
| 56 |
+
voice_config: VoiceConfig,
|
| 57 |
+
diffusion_steps: int,
|
| 58 |
+
speed: float,
|
| 59 |
+
language: Optional[str] = None,
|
| 60 |
+
) -> Dict[str, object]:
|
| 61 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
model = self._load_model()
|
| 63 |
+
if model is None:
|
| 64 |
+
return self._synthesize_fallback(
|
| 65 |
+
text=text,
|
| 66 |
+
output_path=output_path,
|
| 67 |
+
voice_config=voice_config,
|
| 68 |
+
speed=speed,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
kwargs = {
|
| 72 |
+
"text": text,
|
| 73 |
+
"num_step": diffusion_steps,
|
| 74 |
+
"speed": speed,
|
| 75 |
+
}
|
| 76 |
+
if language:
|
| 77 |
+
kwargs["language"] = language
|
| 78 |
+
|
| 79 |
+
if voice_config.mode == "clone":
|
| 80 |
+
kwargs["ref_audio"] = voice_config.sample_path
|
| 81 |
+
if voice_config.reference_text:
|
| 82 |
+
kwargs["ref_text"] = voice_config.reference_text
|
| 83 |
+
elif voice_config.mode == "design":
|
| 84 |
+
kwargs["instruct"] = voice_config.design_prompt
|
| 85 |
+
elif voice_config.narrator_id:
|
| 86 |
+
kwargs["instruct"] = NARRATOR_PRESETS.get(voice_config.narrator_id)
|
| 87 |
+
|
| 88 |
+
audio = model.generate(**kwargs)
|
| 89 |
+
waveform = np.asarray(audio[0], dtype=np.float32)
|
| 90 |
+
sample_rate = 24000
|
| 91 |
+
sf.write(str(output_path), waveform, sample_rate)
|
| 92 |
+
duration_seconds = int(round(len(waveform) / sample_rate))
|
| 93 |
+
return {
|
| 94 |
+
"duration_seconds": max(1, duration_seconds),
|
| 95 |
+
"sample_rate": sample_rate,
|
| 96 |
+
"backend": self._backend,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
def _synthesize_fallback(
|
| 100 |
+
self,
|
| 101 |
+
*,
|
| 102 |
+
text: str,
|
| 103 |
+
output_path: Path,
|
| 104 |
+
voice_config: VoiceConfig,
|
| 105 |
+
speed: float,
|
| 106 |
+
) -> Dict[str, object]:
|
| 107 |
+
sample_rate = 24000
|
| 108 |
+
duration_seconds = max(1.0, min(20.0, len(text.split()) / max(speed, 0.5) * 0.45))
|
| 109 |
+
total_samples = int(sample_rate * duration_seconds)
|
| 110 |
+
base_freq = 180.0
|
| 111 |
+
if voice_config.mode == "design":
|
| 112 |
+
base_freq = 220.0
|
| 113 |
+
elif voice_config.mode == "clone":
|
| 114 |
+
base_freq = 140.0
|
| 115 |
+
elif voice_config.narrator_id:
|
| 116 |
+
base_freq = 160.0 + (list(NARRATOR_PRESETS.keys()).index(voice_config.narrator_id) * 20)
|
| 117 |
+
|
| 118 |
+
timeline = np.linspace(0, duration_seconds, total_samples, endpoint=False)
|
| 119 |
+
waveform = (
|
| 120 |
+
0.15 * np.sin(2 * math.pi * base_freq * timeline)
|
| 121 |
+
+ 0.05 * np.sin(2 * math.pi * (base_freq / 2.0) * timeline)
|
| 122 |
+
).astype(np.float32)
|
| 123 |
+
sf.write(str(output_path), waveform, sample_rate)
|
| 124 |
+
return {
|
| 125 |
+
"duration_seconds": int(round(duration_seconds)),
|
| 126 |
+
"sample_rate": sample_rate,
|
| 127 |
+
"backend": self._backend,
|
| 128 |
+
}
|
backend/render_pipeline.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Dict, Iterable, List, Optional
|
| 3 |
+
|
| 4 |
+
from backend.types import RenderArtifact, RenderJob, VoiceConfig
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class RenderPipeline:
|
| 8 |
+
def __init__(self, session_root: Path, synthesizer) -> None:
|
| 9 |
+
self.session_root = Path(session_root)
|
| 10 |
+
self.session_root.mkdir(parents=True, exist_ok=True)
|
| 11 |
+
self.synthesizer = synthesizer
|
| 12 |
+
self._jobs: Dict[str, RenderJob] = {}
|
| 13 |
+
self._cancelled: set[str] = set()
|
| 14 |
+
self._paused: set[str] = set()
|
| 15 |
+
|
| 16 |
+
def get_job(self, session_id: str) -> RenderJob:
|
| 17 |
+
return self._jobs.setdefault(session_id, RenderJob(session_id=session_id))
|
| 18 |
+
|
| 19 |
+
def cancel(self, session_id: str) -> None:
|
| 20 |
+
self._cancelled.add(session_id)
|
| 21 |
+
self.get_job(session_id).status = "cancelled"
|
| 22 |
+
|
| 23 |
+
def pause(self, session_id: str) -> Dict[str, object]:
|
| 24 |
+
self._paused.add(session_id)
|
| 25 |
+
self.get_job(session_id).status = "paused"
|
| 26 |
+
return {"type": "paused", "session_id": session_id}
|
| 27 |
+
|
| 28 |
+
def resume(self, session_id: str) -> Dict[str, object]:
|
| 29 |
+
self._paused.discard(session_id)
|
| 30 |
+
self.get_job(session_id).status = "running"
|
| 31 |
+
return {"type": "resumed", "session_id": session_id}
|
| 32 |
+
|
| 33 |
+
def render(
|
| 34 |
+
self,
|
| 35 |
+
*,
|
| 36 |
+
session_id: str,
|
| 37 |
+
book: Dict[str, object],
|
| 38 |
+
chapters: List[Dict[str, object]],
|
| 39 |
+
voice_config: Dict[str, object],
|
| 40 |
+
diffusion_steps: int,
|
| 41 |
+
speed: float,
|
| 42 |
+
) -> Iterable[Dict[str, object]]:
|
| 43 |
+
selected = [chapter for chapter in chapters if chapter.get("included", True)]
|
| 44 |
+
job = self.get_job(session_id)
|
| 45 |
+
job.status = "running"
|
| 46 |
+
job.outputs.clear()
|
| 47 |
+
|
| 48 |
+
yield {
|
| 49 |
+
"type": "started",
|
| 50 |
+
"session_id": session_id,
|
| 51 |
+
"total_chapters": len(selected),
|
| 52 |
+
"book_title": book.get("title"),
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
voice = VoiceConfig.from_dict(voice_config)
|
| 56 |
+
render_dir = self.session_root / session_id / "renders"
|
| 57 |
+
render_dir.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
|
| 59 |
+
for index, chapter in enumerate(selected):
|
| 60 |
+
if session_id in self._cancelled:
|
| 61 |
+
yield {"type": "cancelled", "session_id": session_id}
|
| 62 |
+
self._cancelled.discard(session_id)
|
| 63 |
+
job.status = "cancelled"
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
chapter_id = str(chapter["id"])
|
| 67 |
+
job.current_chapter_id = chapter_id
|
| 68 |
+
yield {
|
| 69 |
+
"type": "chapter_started",
|
| 70 |
+
"session_id": session_id,
|
| 71 |
+
"chapter_id": chapter_id,
|
| 72 |
+
"chapter_title": chapter["title"],
|
| 73 |
+
"chapter_index": index,
|
| 74 |
+
"overall_progress": index / max(1, len(selected)),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
output_path = render_dir / f"{index + 1:03d}-{chapter_id}.wav"
|
| 78 |
+
result = self.synthesizer.synthesize(
|
| 79 |
+
text=str(chapter["text"]),
|
| 80 |
+
output_path=output_path,
|
| 81 |
+
voice_config=voice,
|
| 82 |
+
diffusion_steps=diffusion_steps,
|
| 83 |
+
speed=speed,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
yield {
|
| 87 |
+
"type": "chapter_progress",
|
| 88 |
+
"session_id": session_id,
|
| 89 |
+
"chapter_id": chapter_id,
|
| 90 |
+
"percent": 100,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
artifact = RenderArtifact(
|
| 94 |
+
path=output_path,
|
| 95 |
+
duration_seconds=int(result.get("duration_seconds", 0)),
|
| 96 |
+
chapter_id=chapter_id,
|
| 97 |
+
)
|
| 98 |
+
job.outputs.append(artifact)
|
| 99 |
+
yield {
|
| 100 |
+
"type": "chapter_done",
|
| 101 |
+
"session_id": session_id,
|
| 102 |
+
"chapter_id": chapter_id,
|
| 103 |
+
"duration_seconds": artifact.duration_seconds,
|
| 104 |
+
"overall_progress": (index + 1) / max(1, len(selected)),
|
| 105 |
+
"output_path": str(output_path),
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
job.status = "completed"
|
| 109 |
+
yield {
|
| 110 |
+
"type": "completed",
|
| 111 |
+
"session_id": session_id,
|
| 112 |
+
"outputs": [str(artifact.path) for artifact in job.outputs],
|
| 113 |
+
}
|
backend/session_store.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import shutil
|
| 2 |
+
import time
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any, Dict, List, Optional
|
| 6 |
+
|
| 7 |
+
from backend.types import SessionRecord
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SessionStore:
|
| 11 |
+
def __init__(self, root: Path, ttl_seconds: int) -> None:
|
| 12 |
+
self.root = Path(root)
|
| 13 |
+
self.ttl_seconds = ttl_seconds
|
| 14 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 15 |
+
self._sessions: Dict[str, SessionRecord] = {}
|
| 16 |
+
|
| 17 |
+
def _now(self) -> float:
|
| 18 |
+
return time.time()
|
| 19 |
+
|
| 20 |
+
def ensure_session(self, session_id: str) -> SessionRecord:
|
| 21 |
+
now = self._now()
|
| 22 |
+
session_root = self.root / session_id
|
| 23 |
+
session_root.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
for child in ("uploads", "previews", "renders", "exports"):
|
| 25 |
+
(session_root / child).mkdir(exist_ok=True)
|
| 26 |
+
existing = self._sessions.get(session_id)
|
| 27 |
+
if existing is None:
|
| 28 |
+
existing = SessionRecord(
|
| 29 |
+
session_id=session_id,
|
| 30 |
+
root=session_root,
|
| 31 |
+
created_at=now,
|
| 32 |
+
touched_at=now,
|
| 33 |
+
)
|
| 34 |
+
self._sessions[session_id] = existing
|
| 35 |
+
else:
|
| 36 |
+
existing.touched_at = now
|
| 37 |
+
return existing
|
| 38 |
+
|
| 39 |
+
def touch(self, session_id: str) -> SessionRecord:
|
| 40 |
+
session = self.ensure_session(session_id)
|
| 41 |
+
session.touched_at = self._now()
|
| 42 |
+
return session
|
| 43 |
+
|
| 44 |
+
def save_json(self, session_id: str, name: str, payload: Dict[str, Any]) -> Path:
|
| 45 |
+
session = self.touch(session_id)
|
| 46 |
+
path = session.root / name
|
| 47 |
+
path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8")
|
| 48 |
+
return path
|
| 49 |
+
|
| 50 |
+
def load_json(self, session_id: str, name: str) -> Dict[str, Any]:
|
| 51 |
+
session = self.touch(session_id)
|
| 52 |
+
path = session.root / name
|
| 53 |
+
if not path.exists():
|
| 54 |
+
raise FileNotFoundError(f"Session asset not found: {name}")
|
| 55 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 56 |
+
|
| 57 |
+
def cleanup_expired(self, now: Optional[float] = None) -> List[str]:
|
| 58 |
+
current = self._now() if now is None else now
|
| 59 |
+
removed: List[str] = []
|
| 60 |
+
for session_id, record in list(self._sessions.items()):
|
| 61 |
+
if current - record.touched_at <= self.ttl_seconds:
|
| 62 |
+
continue
|
| 63 |
+
if record.root.exists():
|
| 64 |
+
shutil.rmtree(record.root)
|
| 65 |
+
removed.append(session_id)
|
| 66 |
+
del self._sessions[session_id]
|
| 67 |
+
return removed
|
backend/types.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import asdict, dataclass, field
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
JsonDict = Dict[str, Any]
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class Chapter:
|
| 11 |
+
id: str
|
| 12 |
+
label: str
|
| 13 |
+
title: str
|
| 14 |
+
text: str
|
| 15 |
+
chars: int
|
| 16 |
+
est_minutes: int
|
| 17 |
+
included: bool = True
|
| 18 |
+
start_seconds: int = 0
|
| 19 |
+
|
| 20 |
+
def to_dict(self) -> JsonDict:
|
| 21 |
+
data = asdict(self)
|
| 22 |
+
data["n"] = data["label"]
|
| 23 |
+
return data
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class SessionRecord:
|
| 28 |
+
session_id: str
|
| 29 |
+
root: Path
|
| 30 |
+
created_at: float
|
| 31 |
+
touched_at: float
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class VoiceConfig:
|
| 36 |
+
mode: str
|
| 37 |
+
narrator_id: Optional[str] = None
|
| 38 |
+
sample_path: Optional[str] = None
|
| 39 |
+
reference_text: Optional[str] = None
|
| 40 |
+
design_prompt: Optional[str] = None
|
| 41 |
+
|
| 42 |
+
@classmethod
|
| 43 |
+
def from_dict(cls, data: JsonDict) -> "VoiceConfig":
|
| 44 |
+
return cls(
|
| 45 |
+
mode=data.get("mode", "auto"),
|
| 46 |
+
narrator_id=data.get("narratorId") or data.get("narrator_id"),
|
| 47 |
+
sample_path=data.get("samplePath") or data.get("sample_path"),
|
| 48 |
+
reference_text=data.get("referenceText") or data.get("reference_text"),
|
| 49 |
+
design_prompt=data.get("designPrompt") or data.get("design_prompt"),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class RenderArtifact:
|
| 55 |
+
path: Path
|
| 56 |
+
duration_seconds: int
|
| 57 |
+
chapter_id: str
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class RenderJob:
|
| 62 |
+
session_id: str
|
| 63 |
+
status: str = "idle"
|
| 64 |
+
current_chapter_id: Optional[str] = None
|
| 65 |
+
outputs: List[RenderArtifact] = field(default_factory=list)
|
| 66 |
+
|
frontend/app.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Client, handle_file } from "https://esm.sh/@gradio/client";
|
| 2 |
+
|
| 3 |
+
const NARRATORS = [
|
| 4 |
+
{ id: "brother-anselm", name: "The Archivist", desc: "OmniVoice preset · warm baritone · measured", initial: "A" },
|
| 5 |
+
{ id: "dame-eglantine", name: "Lady Meridian", desc: "OmniVoice preset · bright contralto · lyrical", initial: "M" },
|
| 6 |
+
{ id: "the-reeve", name: "North Bell", desc: "OmniVoice preset · gravelled bass · wry", initial: "N" },
|
| 7 |
+
{ id: "sister-cecilia", name: "Cecilia of the Lamp", desc: "OmniVoice preset · clear soprano · gentle", initial: "C" },
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
const VOICE_WARNING =
|
| 11 |
+
"Use clone mode only with audio you have the right to use. Unauthorized voice cloning, impersonation, fraud, or scams are prohibited.";
|
| 12 |
+
|
| 13 |
+
const state = {
|
| 14 |
+
client: null,
|
| 15 |
+
sessionId: crypto.randomUUID ? crypto.randomUUID() : `session-${Date.now()}`,
|
| 16 |
+
step: "configure",
|
| 17 |
+
book: null,
|
| 18 |
+
chapters: [],
|
| 19 |
+
currentChapterId: null,
|
| 20 |
+
voiceMode: "auto",
|
| 21 |
+
narratorId: "brother-anselm",
|
| 22 |
+
cloneConsent: false,
|
| 23 |
+
cloneSampleFile: null,
|
| 24 |
+
cloneReferenceText: "",
|
| 25 |
+
designPrompt: "",
|
| 26 |
+
diffusionSteps: 32,
|
| 27 |
+
speed: 1,
|
| 28 |
+
statusMessage: "",
|
| 29 |
+
errorMessage: "",
|
| 30 |
+
renderStatus: null,
|
| 31 |
+
renderEvents: [],
|
| 32 |
+
renderResult: null,
|
| 33 |
+
renderSubmission: null,
|
| 34 |
+
previewUrl: "",
|
| 35 |
+
exportFormat: "m4a",
|
| 36 |
+
embedMarkers: true,
|
| 37 |
+
exportFile: null,
|
| 38 |
+
exportMetadata: {
|
| 39 |
+
title: "",
|
| 40 |
+
author: "",
|
| 41 |
+
narrator: "Brother Anselm (OmniVoice)",
|
| 42 |
+
genre: "Audiobook",
|
| 43 |
+
},
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const appRoot = document.getElementById("app");
|
| 47 |
+
|
| 48 |
+
function main() {
|
| 49 |
+
bindGlobalEvents();
|
| 50 |
+
connectClient()
|
| 51 |
+
.catch((error) => {
|
| 52 |
+
state.errorMessage = error.message;
|
| 53 |
+
})
|
| 54 |
+
.finally(() => {
|
| 55 |
+
render();
|
| 56 |
+
});
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async function connectClient() {
|
| 60 |
+
state.statusMessage = "Connecting to the Gradio backend…";
|
| 61 |
+
render();
|
| 62 |
+
state.client = await Client.connect(window.location.origin, {
|
| 63 |
+
events: ["data", "status"],
|
| 64 |
+
});
|
| 65 |
+
state.statusMessage = "Connected. Upload an EPUB to begin.";
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function bindGlobalEvents() {
|
| 69 |
+
window.addEventListener("click", handleClick);
|
| 70 |
+
window.addEventListener("change", handleChange);
|
| 71 |
+
window.addEventListener("input", handleInput);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function handleClick(event) {
|
| 75 |
+
const target = event.target.closest("[data-action]");
|
| 76 |
+
if (!target) return;
|
| 77 |
+
const action = target.dataset.action;
|
| 78 |
+
void actionHandlers[action]?.(target, event);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function handleChange(event) {
|
| 82 |
+
const target = event.target;
|
| 83 |
+
if (target.matches("[data-chapter-toggle]")) {
|
| 84 |
+
const id = target.dataset.chapterToggle;
|
| 85 |
+
const chapter = state.chapters.find((item) => item.id === id);
|
| 86 |
+
if (chapter) {
|
| 87 |
+
chapter.included = target.checked;
|
| 88 |
+
render();
|
| 89 |
+
}
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
if (target.matches("#epub-input")) {
|
| 93 |
+
void uploadEpub(target.files?.[0]);
|
| 94 |
+
return;
|
| 95 |
+
}
|
| 96 |
+
if (target.matches("#clone-audio")) {
|
| 97 |
+
state.cloneSampleFile = target.files?.[0] || null;
|
| 98 |
+
render();
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
if (target.matches("[data-format]")) {
|
| 102 |
+
state.exportFormat = target.dataset.format;
|
| 103 |
+
render();
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
if (target.matches("#embed-markers")) {
|
| 107 |
+
state.embedMarkers = target.checked;
|
| 108 |
+
render();
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function handleInput(event) {
|
| 113 |
+
const target = event.target;
|
| 114 |
+
if (target.matches("#diffusion-steps")) {
|
| 115 |
+
state.diffusionSteps = Number(target.value);
|
| 116 |
+
render();
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
if (target.matches("#reading-speed")) {
|
| 120 |
+
state.speed = Number(target.value);
|
| 121 |
+
render();
|
| 122 |
+
return;
|
| 123 |
+
}
|
| 124 |
+
if (target.matches("#clone-ref-text")) {
|
| 125 |
+
state.cloneReferenceText = target.value;
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
if (target.matches("#design-prompt")) {
|
| 129 |
+
state.designPrompt = target.value;
|
| 130 |
+
return;
|
| 131 |
+
}
|
| 132 |
+
if (target.matches("#clone-consent")) {
|
| 133 |
+
state.cloneConsent = target.checked;
|
| 134 |
+
render();
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
if (target.matches("[data-meta]")) {
|
| 138 |
+
state.exportMetadata[target.dataset.meta] = target.value;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const actionHandlers = {
|
| 143 |
+
async pickNarrator(target) {
|
| 144 |
+
state.narratorId = target.dataset.id;
|
| 145 |
+
syncNarratorMetadata();
|
| 146 |
+
render();
|
| 147 |
+
},
|
| 148 |
+
setVoiceMode(target) {
|
| 149 |
+
state.voiceMode = target.dataset.mode;
|
| 150 |
+
if (state.voiceMode === "auto") {
|
| 151 |
+
syncNarratorMetadata();
|
| 152 |
+
} else if (state.voiceMode === "clone") {
|
| 153 |
+
state.exportMetadata.narrator = "Cloned Narrator (OmniVoice)";
|
| 154 |
+
} else {
|
| 155 |
+
state.exportMetadata.narrator = "Designed Narrator (OmniVoice)";
|
| 156 |
+
}
|
| 157 |
+
render();
|
| 158 |
+
},
|
| 159 |
+
focusChapter(target) {
|
| 160 |
+
state.currentChapterId = target.dataset.id;
|
| 161 |
+
render();
|
| 162 |
+
},
|
| 163 |
+
async previewChapter(target) {
|
| 164 |
+
const chapterId = target.dataset.id;
|
| 165 |
+
await requestPreview(chapterId);
|
| 166 |
+
},
|
| 167 |
+
async startRender() {
|
| 168 |
+
await submitRender();
|
| 169 |
+
},
|
| 170 |
+
async pauseRender() {
|
| 171 |
+
await callPredict("/pause_render", { session_id: state.sessionId });
|
| 172 |
+
state.statusMessage = "Render paused. Resume when ready.";
|
| 173 |
+
render();
|
| 174 |
+
},
|
| 175 |
+
async resumeRender() {
|
| 176 |
+
await callPredict("/resume_render", { session_id: state.sessionId });
|
| 177 |
+
state.statusMessage = "Render resumed.";
|
| 178 |
+
render();
|
| 179 |
+
},
|
| 180 |
+
async cancelRender() {
|
| 181 |
+
await callPredict("/cancel_render", { session_id: state.sessionId });
|
| 182 |
+
state.renderSubmission?.cancel?.();
|
| 183 |
+
state.renderSubmission = null;
|
| 184 |
+
state.renderStatus = { status: "cancelled" };
|
| 185 |
+
state.step = "configure";
|
| 186 |
+
state.statusMessage = "Render cancelled.";
|
| 187 |
+
render();
|
| 188 |
+
},
|
| 189 |
+
async exportBook() {
|
| 190 |
+
await requestExport();
|
| 191 |
+
},
|
| 192 |
+
goConfigure() {
|
| 193 |
+
state.step = "configure";
|
| 194 |
+
render();
|
| 195 |
+
},
|
| 196 |
+
goGenerate() {
|
| 197 |
+
if (!state.book) return;
|
| 198 |
+
state.step = "generate";
|
| 199 |
+
render();
|
| 200 |
+
},
|
| 201 |
+
goExport() {
|
| 202 |
+
if (!state.renderResult) return;
|
| 203 |
+
state.step = "export";
|
| 204 |
+
render();
|
| 205 |
+
},
|
| 206 |
+
chooseUpload() {
|
| 207 |
+
document.getElementById("epub-input")?.click();
|
| 208 |
+
},
|
| 209 |
+
chooseCloneAudio() {
|
| 210 |
+
document.getElementById("clone-audio")?.click();
|
| 211 |
+
},
|
| 212 |
+
playExportPreview(target) {
|
| 213 |
+
const audio = document.getElementById("preview-audio");
|
| 214 |
+
if (!audio) return;
|
| 215 |
+
if (audio.paused) {
|
| 216 |
+
audio.play();
|
| 217 |
+
target.textContent = "❚❚";
|
| 218 |
+
} else {
|
| 219 |
+
audio.pause();
|
| 220 |
+
target.textContent = "▶";
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
async function uploadEpub(file) {
|
| 226 |
+
if (!file || !state.client) return;
|
| 227 |
+
state.errorMessage = "";
|
| 228 |
+
state.statusMessage = `Uploading ${file.name}…`;
|
| 229 |
+
render();
|
| 230 |
+
try {
|
| 231 |
+
const payload = await callPredict("/parse_epub", {
|
| 232 |
+
session_id: state.sessionId,
|
| 233 |
+
epub_file: handle_file(file),
|
| 234 |
+
});
|
| 235 |
+
state.book = payload;
|
| 236 |
+
state.chapters = payload.chapters;
|
| 237 |
+
state.currentChapterId = payload.chapters[0]?.id || null;
|
| 238 |
+
state.exportMetadata.title = payload.title;
|
| 239 |
+
state.exportMetadata.author = payload.author;
|
| 240 |
+
syncNarratorMetadata();
|
| 241 |
+
state.statusMessage = `Parsed ${payload.chapters.length} chapters from ${payload.title}.`;
|
| 242 |
+
} catch (error) {
|
| 243 |
+
state.errorMessage = error.message;
|
| 244 |
+
}
|
| 245 |
+
render();
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
async function requestPreview(chapterId) {
|
| 249 |
+
if (!state.client || !state.book) return;
|
| 250 |
+
state.errorMessage = "";
|
| 251 |
+
state.statusMessage = "Rendering preview…";
|
| 252 |
+
render();
|
| 253 |
+
try {
|
| 254 |
+
const payload = await callPredict("/generate_preview", {
|
| 255 |
+
session_id: state.sessionId,
|
| 256 |
+
chapter_id: chapterId,
|
| 257 |
+
voice_config: buildVoiceConfig(),
|
| 258 |
+
diffusion_steps: state.diffusionSteps,
|
| 259 |
+
speed: state.speed,
|
| 260 |
+
});
|
| 261 |
+
state.previewUrl = payload.url;
|
| 262 |
+
state.statusMessage = "Preview ready.";
|
| 263 |
+
} catch (error) {
|
| 264 |
+
state.errorMessage = error.message;
|
| 265 |
+
}
|
| 266 |
+
render();
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
async function submitRender() {
|
| 270 |
+
if (!state.client || !state.book) return;
|
| 271 |
+
state.errorMessage = "";
|
| 272 |
+
state.statusMessage = "Submitting audiobook render…";
|
| 273 |
+
state.step = "generate";
|
| 274 |
+
state.renderEvents = [];
|
| 275 |
+
state.renderResult = null;
|
| 276 |
+
render();
|
| 277 |
+
|
| 278 |
+
const submission = state.client.submit("/start_render", {
|
| 279 |
+
session_id: state.sessionId,
|
| 280 |
+
selected_chapter_ids: selectedChapterIds(),
|
| 281 |
+
voice_config: buildVoiceConfig(),
|
| 282 |
+
diffusion_steps: state.diffusionSteps,
|
| 283 |
+
speed: state.speed,
|
| 284 |
+
});
|
| 285 |
+
state.renderSubmission = submission;
|
| 286 |
+
|
| 287 |
+
try {
|
| 288 |
+
for await (const event of submission) {
|
| 289 |
+
if (event.type === "status") {
|
| 290 |
+
state.renderStatus = event;
|
| 291 |
+
if (event.position && event.status === "pending") {
|
| 292 |
+
state.statusMessage = `Queued in position ${event.position} of ${event.queue_size}.`;
|
| 293 |
+
}
|
| 294 |
+
render();
|
| 295 |
+
continue;
|
| 296 |
+
}
|
| 297 |
+
if (event.type !== "data") continue;
|
| 298 |
+
const payload = event.data?.[0] ?? event.data;
|
| 299 |
+
if (!payload) continue;
|
| 300 |
+
state.renderEvents.push(payload);
|
| 301 |
+
updateRenderState(payload);
|
| 302 |
+
render();
|
| 303 |
+
}
|
| 304 |
+
} catch (error) {
|
| 305 |
+
state.errorMessage = error.message;
|
| 306 |
+
render();
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
function updateRenderState(payload) {
|
| 311 |
+
switch (payload.type) {
|
| 312 |
+
case "started":
|
| 313 |
+
state.statusMessage = `Binding ${payload.total_chapters} chapters…`;
|
| 314 |
+
break;
|
| 315 |
+
case "chapter_started":
|
| 316 |
+
state.statusMessage = `Narrating ${payload.chapter_title}…`;
|
| 317 |
+
break;
|
| 318 |
+
case "chapter_done":
|
| 319 |
+
updateChapterDuration(payload.chapter_id, payload.duration_seconds);
|
| 320 |
+
break;
|
| 321 |
+
case "completed":
|
| 322 |
+
state.renderResult = payload;
|
| 323 |
+
state.step = "export";
|
| 324 |
+
state.statusMessage = "Audiobook render completed. Export is unlocked.";
|
| 325 |
+
break;
|
| 326 |
+
case "cancelled":
|
| 327 |
+
state.statusMessage = "Render cancelled.";
|
| 328 |
+
break;
|
| 329 |
+
case "failed":
|
| 330 |
+
state.errorMessage = payload.message || "Render failed.";
|
| 331 |
+
break;
|
| 332 |
+
default:
|
| 333 |
+
break;
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
async function requestExport() {
|
| 338 |
+
if (!state.client) return;
|
| 339 |
+
state.errorMessage = "";
|
| 340 |
+
state.statusMessage = `Preparing ${state.exportFormat.toUpperCase()} export…`;
|
| 341 |
+
render();
|
| 342 |
+
try {
|
| 343 |
+
const payload = await callPredict("/export_audiobook", {
|
| 344 |
+
session_id: state.sessionId,
|
| 345 |
+
format: state.exportFormat,
|
| 346 |
+
metadata: {
|
| 347 |
+
title: state.exportMetadata.title,
|
| 348 |
+
artist: state.exportMetadata.author,
|
| 349 |
+
narrator: state.exportMetadata.narrator,
|
| 350 |
+
genre: state.exportMetadata.genre,
|
| 351 |
+
},
|
| 352 |
+
embed_markers: state.embedMarkers,
|
| 353 |
+
});
|
| 354 |
+
state.exportFile = payload;
|
| 355 |
+
state.previewUrl = payload.url || state.previewUrl;
|
| 356 |
+
state.statusMessage = `${state.exportFormat.toUpperCase()} export ready.`;
|
| 357 |
+
} catch (error) {
|
| 358 |
+
state.errorMessage = error.message;
|
| 359 |
+
}
|
| 360 |
+
render();
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
async function callPredict(apiName, payload) {
|
| 364 |
+
if (!state.client) throw new Error("Client not connected");
|
| 365 |
+
const result = await state.client.predict(apiName, payload);
|
| 366 |
+
return result.data?.[0] ?? result;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
function buildVoiceConfig() {
|
| 370 |
+
if (state.voiceMode === "clone") {
|
| 371 |
+
return {
|
| 372 |
+
mode: "clone",
|
| 373 |
+
referenceText: state.cloneReferenceText,
|
| 374 |
+
cloneConsent: state.cloneConsent,
|
| 375 |
+
samplePath: state.cloneSampleFile ? handle_file(state.cloneSampleFile) : null,
|
| 376 |
+
};
|
| 377 |
+
}
|
| 378 |
+
if (state.voiceMode === "design") {
|
| 379 |
+
return {
|
| 380 |
+
mode: "design",
|
| 381 |
+
designPrompt: state.designPrompt,
|
| 382 |
+
};
|
| 383 |
+
}
|
| 384 |
+
return {
|
| 385 |
+
mode: "auto",
|
| 386 |
+
narratorId: state.narratorId,
|
| 387 |
+
};
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
function selectedChapterIds() {
|
| 391 |
+
return state.chapters.filter((chapter) => chapter.included).map((chapter) => chapter.id);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
function currentChapter() {
|
| 395 |
+
return state.chapters.find((chapter) => chapter.id === state.currentChapterId) || state.chapters[0];
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
function includedChapters() {
|
| 399 |
+
return state.chapters.filter((chapter) => chapter.included);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function totalMinutes() {
|
| 403 |
+
return includedChapters().reduce((sum, chapter) => sum + Number(chapter.est_minutes || chapter.estMinutes || 0), 0);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function formatRuntime(minutes) {
|
| 407 |
+
const total = Math.max(0, Math.round(minutes));
|
| 408 |
+
const hours = Math.floor(total / 60);
|
| 409 |
+
const mins = total % 60;
|
| 410 |
+
return hours ? `${hours}h ${String(mins).padStart(2, "0")}m` : `${mins}m`;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
function formatStamp(totalSeconds) {
|
| 414 |
+
const seconds = Math.max(0, Math.round(totalSeconds));
|
| 415 |
+
const hours = Math.floor(seconds / 3600);
|
| 416 |
+
const minutes = Math.floor((seconds % 3600) / 60);
|
| 417 |
+
const secs = seconds % 60;
|
| 418 |
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
function updateChapterDuration(chapterId, seconds) {
|
| 422 |
+
const chapter = state.chapters.find((item) => item.id === chapterId);
|
| 423 |
+
if (chapter) {
|
| 424 |
+
chapter.duration_seconds = seconds;
|
| 425 |
+
}
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
function render() {
|
| 429 |
+
const previousChapterListScrollTop = document.querySelector(".chapter-list")?.scrollTop ?? 0;
|
| 430 |
+
const previousReadingContainer = document.querySelector("[data-reading-scroll]");
|
| 431 |
+
const previousReadingScrollTop = previousReadingContainer?.scrollTop ?? 0;
|
| 432 |
+
const previousReadingChapterId = previousReadingContainer?.dataset.chapterId ?? "";
|
| 433 |
+
const nextReadingChapterId = String(currentChapter()?.id || "");
|
| 434 |
+
appRoot.innerHTML = `
|
| 435 |
+
<div class="app-shell">
|
| 436 |
+
<div class="sheet">
|
| 437 |
+
<div class="page">
|
| 438 |
+
${renderMasthead()}
|
| 439 |
+
${state.errorMessage ? `<div class="error-banner">${escapeHtml(state.errorMessage)}</div>` : ""}
|
| 440 |
+
${state.statusMessage ? `<div class="status-banner">${escapeHtml(state.statusMessage)}</div>` : ""}
|
| 441 |
+
${state.step === "configure" ? renderConfigure() : ""}
|
| 442 |
+
${state.step === "generate" ? renderGenerate() : ""}
|
| 443 |
+
${state.step === "export" ? renderExport() : ""}
|
| 444 |
+
</div>
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
`;
|
| 448 |
+
const chapterList = document.querySelector(".chapter-list");
|
| 449 |
+
if (chapterList) {
|
| 450 |
+
chapterList.scrollTop = previousChapterListScrollTop;
|
| 451 |
+
}
|
| 452 |
+
const nextReadingContainer = document.querySelector("[data-reading-scroll]");
|
| 453 |
+
if (nextReadingContainer && previousReadingChapterId === nextReadingChapterId) {
|
| 454 |
+
nextReadingContainer.scrollTop = previousReadingScrollTop;
|
| 455 |
+
}
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
function renderMasthead() {
|
| 459 |
+
const activeIndex = { configure: 0, generate: 1, export: 2 }[state.step] ?? 0;
|
| 460 |
+
return `
|
| 461 |
+
<div class="mast">
|
| 462 |
+
<div class="brand">
|
| 463 |
+
<div class="plaque">📖</div>
|
| 464 |
+
<div>
|
| 465 |
+
<div class="wordmark">Scriptorium</div>
|
| 466 |
+
<div class="tagline">Bind your library into spoken word · OmniVoice TTS</div>
|
| 467 |
+
</div>
|
| 468 |
+
</div>
|
| 469 |
+
<div class="steps">
|
| 470 |
+
${["Upload & Configure", "Generate", "Export"].map((label, index) => {
|
| 471 |
+
const cls = index < activeIndex ? "done" : index === activeIndex ? "now" : "";
|
| 472 |
+
const prefix = index < activeIndex ? "✓ " : "";
|
| 473 |
+
return `${index ? `<span class="sep">—</span>` : ""}<span class="step ${cls}">${prefix}${label}</span>`;
|
| 474 |
+
}).join("")}
|
| 475 |
+
</div>
|
| 476 |
+
</div>
|
| 477 |
+
`;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
function renderConfigure() {
|
| 481 |
+
const chapter = currentChapter();
|
| 482 |
+
const included = includedChapters();
|
| 483 |
+
const chapterCount = included.length;
|
| 484 |
+
const runtime = formatRuntime(totalMinutes());
|
| 485 |
+
return `
|
| 486 |
+
<div class="hero">
|
| 487 |
+
${renderCover()}
|
| 488 |
+
<div class="hero-meta">
|
| 489 |
+
<div class="hero-title">${escapeHtml(state.book?.title || "Upload an EPUB to begin")}</div>
|
| 490 |
+
<div class="hero-subtitle">${escapeHtml(state.book ? `by ${state.book.author}` : "A custom Gradio Space for audiobook binding")}</div>
|
| 491 |
+
<div class="hero-kicker">${escapeHtml(state.book?.meta || "Public Space · GPU-backed · session-only exports")}</div>
|
| 492 |
+
<div style="margin-top:15px; display:flex; gap:10px; flex-wrap:wrap;">
|
| 493 |
+
<button class="ghost-btn" data-action="chooseUpload">Change book</button>
|
| 494 |
+
<input id="epub-input" type="file" accept=".epub" class="hide" />
|
| 495 |
+
</div>
|
| 496 |
+
</div>
|
| 497 |
+
<div class="hero-tally">
|
| 498 |
+
<div class="hero-big">${chapterCount}<span>/${state.chapters.length || 0}</span></div>
|
| 499 |
+
<div class="smallcaps">chapters on the shelf</div>
|
| 500 |
+
<div class="hero-run">≈ <strong>${runtime}</strong> runtime</div>
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
|
| 504 |
+
<div class="cols">
|
| 505 |
+
<div>
|
| 506 |
+
<div class="panel-head">
|
| 507 |
+
<h2 class="panel-title">Select chapters to bind</h2>
|
| 508 |
+
<span class="right">${chapterCount ? `${chapterCount} selected` : "Upload an EPUB"}</span>
|
| 509 |
+
</div>
|
| 510 |
+
<div class="queue">
|
| 511 |
+
<div class="list-head">
|
| 512 |
+
Include in audiobook
|
| 513 |
+
<span class="count">${chapterCount} of ${state.chapters.length}</span>
|
| 514 |
+
</div>
|
| 515 |
+
<div class="chapter-list">
|
| 516 |
+
${state.chapters.length ? state.chapters.map(renderChapterRow).join("") : renderUploadPlaceholder()}
|
| 517 |
+
</div>
|
| 518 |
+
</div>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
<div>
|
| 522 |
+
<div class="panel-head">
|
| 523 |
+
<h2 class="panel-title">Now reading</h2>
|
| 524 |
+
</div>
|
| 525 |
+
<div class="reading-panel">
|
| 526 |
+
<div class="reading-head">
|
| 527 |
+
<div>
|
| 528 |
+
<h3>${escapeHtml(chapter?.title || "Waiting for an EPUB")}</h3>
|
| 529 |
+
<div class="chapter-meta">${chapter ? `${chapter.chars.toLocaleString()} chars · ≈ ${chapter.est_minutes} min · chapter ${chapter.n}` : "Upload a book to inspect chapters"}</div>
|
| 530 |
+
</div>
|
| 531 |
+
<div class="chips">
|
| 532 |
+
<button class="chip" data-action="previewChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>Preview</button>
|
| 533 |
+
<button class="chip warn" data-action="focusChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>${chapter?.included === false ? "Skipped" : "Selected"}</button>
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
<div class="verse reading-scroll" data-reading-scroll data-chapter-id="${escapeAttr(chapter?.id || "")}">${renderChapterBody(chapter?.text || "")}</div>
|
| 537 |
+
${state.previewUrl ? `<div style="margin-top: 16px;"><audio controls src="${state.previewUrl}"></audio></div>` : ""}
|
| 538 |
+
</div>
|
| 539 |
+
|
| 540 |
+
<div class="voice-panel">
|
| 541 |
+
<div class="seg">
|
| 542 |
+
${["auto", "clone", "design"].map((mode) => `
|
| 543 |
+
<button type="button" data-action="setVoiceMode" data-mode="${mode}" class="${state.voiceMode === mode ? "active" : ""}">
|
| 544 |
+
${mode}
|
| 545 |
+
</button>
|
| 546 |
+
`).join("")}
|
| 547 |
+
</div>
|
| 548 |
+
${renderVoiceMode()}
|
| 549 |
+
<div class="dial-grid">
|
| 550 |
+
<div>
|
| 551 |
+
<div class="dial-head">
|
| 552 |
+
<span class="smallcaps">Diffusion steps</span>
|
| 553 |
+
<span class="dial-value">${state.diffusionSteps}</span>
|
| 554 |
+
</div>
|
| 555 |
+
<input id="diffusion-steps" class="range" type="range" min="8" max="64" value="${state.diffusionSteps}" />
|
| 556 |
+
</div>
|
| 557 |
+
<div>
|
| 558 |
+
<div class="dial-head">
|
| 559 |
+
<span class="smallcaps">Reading speed</span>
|
| 560 |
+
<span class="dial-value">${state.speed.toFixed(1)}×</span>
|
| 561 |
+
</div>
|
| 562 |
+
<input id="reading-speed" class="range" type="range" min="0.5" max="2" step="0.1" value="${state.speed}" />
|
| 563 |
+
</div>
|
| 564 |
+
</div>
|
| 565 |
+
</div>
|
| 566 |
+
</div>
|
| 567 |
+
</div>
|
| 568 |
+
|
| 569 |
+
<div class="footer">
|
| 570 |
+
<div class="footer-note">Binding <b>${chapterCount} chapters</b> · approx <b>${runtime}</b> · exports as <b>.m4a</b></div>
|
| 571 |
+
<div class="spacer"></div>
|
| 572 |
+
<button class="btn-ghost" data-action="previewChapter" data-id="${chapter?.id || ""}" ${chapter ? "" : "disabled"}>Hear a preview</button>
|
| 573 |
+
<button class="btn-primary" data-action="startRender" ${canStartRender() ? "" : "disabled"}>Generate full audiobook</button>
|
| 574 |
+
</div>
|
| 575 |
+
`;
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
function renderGenerate() {
|
| 579 |
+
const included = includedChapters();
|
| 580 |
+
const currentEvent = [...state.renderEvents].reverse().find((event) => event.type === "chapter_started");
|
| 581 |
+
const currentId = currentEvent?.chapter_id;
|
| 582 |
+
const doneCount = state.renderEvents.filter((event) => event.type === "chapter_done").length;
|
| 583 |
+
const percent = currentEvent ? Math.round((currentEvent.overall_progress || 0) * 100) : state.renderResult ? 100 : 0;
|
| 584 |
+
const activeChapter = state.chapters.find((chapter) => chapter.id === currentId) || included[0];
|
| 585 |
+
return `
|
| 586 |
+
<div class="hero">
|
| 587 |
+
${renderCover({ w: 92, h: 134 })}
|
| 588 |
+
<div class="hero-meta">
|
| 589 |
+
<div class="smallcaps" style="display:flex; align-items:center; gap:9px; color:var(--leather);"><span class="status-dot"></span> Now binding · OmniVoice TTS</div>
|
| 590 |
+
<div class="screen-title" style="font-size:31px; font-weight:800; margin:6px 0 14px;">
|
| 591 |
+
Narrating <em style="color:var(--leather); font-style:italic;">${escapeHtml(activeChapter?.title || "your book")}</em>…
|
| 592 |
+
</div>
|
| 593 |
+
<div class="progress-bar"><div class="progress-fill" style="width:${percent}%"></div></div>
|
| 594 |
+
<div class="stats-hero">
|
| 595 |
+
<span><b>${doneCount} of ${included.length}</b> chapters bound</span>
|
| 596 |
+
<span>${formatRuntime(totalMinutes())} target runtime</span>
|
| 597 |
+
</div>
|
| 598 |
+
</div>
|
| 599 |
+
<div class="hero-stats">
|
| 600 |
+
<div class="pct">${percent}<span style="font-size:24px;">%</span></div>
|
| 601 |
+
<div class="smallcaps">complete</div>
|
| 602 |
+
<div style="margin-top:10px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap;">
|
| 603 |
+
<button class="small-btn" data-action="pauseRender">Pause</button>
|
| 604 |
+
<button class="small-btn danger" data-action="cancelRender">Cancel</button>
|
| 605 |
+
</div>
|
| 606 |
+
</div>
|
| 607 |
+
</div>
|
| 608 |
+
|
| 609 |
+
<div class="cols">
|
| 610 |
+
<div>
|
| 611 |
+
<div class="panel-head">
|
| 612 |
+
<h2 class="panel-title">Render queue</h2>
|
| 613 |
+
<span class="right">${doneCount}/${included.length} done</span>
|
| 614 |
+
</div>
|
| 615 |
+
<div class="queue">
|
| 616 |
+
<div class="queue-head">Chapter · status <span class="count">${state.renderStatus?.position ? `queue ${state.renderStatus.position}` : ""}</span></div>
|
| 617 |
+
${state.chapters.map((chapter) => renderQueueRow(chapter, currentId)).join("")}
|
| 618 |
+
</div>
|
| 619 |
+
</div>
|
| 620 |
+
<div>
|
| 621 |
+
<div class="panel-head">
|
| 622 |
+
<h2 class="panel-title">Now narrating</h2>
|
| 623 |
+
</div>
|
| 624 |
+
<div class="player">
|
| 625 |
+
<div class="reading-head" style="border-bottom:none; margin-bottom:8px;">
|
| 626 |
+
<div>
|
| 627 |
+
<div class="now-title" style="font-size:23px; font-weight:700;">${escapeHtml(activeChapter?.title || "Waiting in queue")}</div>
|
| 628 |
+
<div class="chapter-meta">${activeChapter ? `chapter ${activeChapter.n} · ${activeChapter.chars.toLocaleString()} chars` : ""}</div>
|
| 629 |
+
</div>
|
| 630 |
+
<div class="pill">${escapeHtml(activeNarratorName())}</div>
|
| 631 |
+
</div>
|
| 632 |
+
<div class="wave">${new Array(25).fill(0).map((_, index) => `<i style="height:${40 + ((index * 13) % 55)}%; animation-delay:${index * 0.045}s"></i>`).join("")}</div>
|
| 633 |
+
<div class="spoken">
|
| 634 |
+
<span class="lit">${escapeHtml(excerpt(activeChapter?.text || "", 120))}</span>
|
| 635 |
+
<span class="dim">${escapeHtml(excerpt(activeChapter?.text || "", 220, 120))}</span>
|
| 636 |
+
</div>
|
| 637 |
+
</div>
|
| 638 |
+
<div class="panel-head" style="margin-top:18px;">
|
| 639 |
+
<h2 class="panel-title" style="font-size:19px;">Activity</h2>
|
| 640 |
+
</div>
|
| 641 |
+
<div class="log">
|
| 642 |
+
${state.renderEvents.slice(-8).reverse().map(renderLogRow).join("") || `<div class="log-row"><span class="time">—</span><span>Waiting for render events…</span></div>`}
|
| 643 |
+
</div>
|
| 644 |
+
</div>
|
| 645 |
+
</div>
|
| 646 |
+
|
| 647 |
+
<div class="footer">
|
| 648 |
+
<div class="footer-note">Binding <b>${included.length} chapters</b> · <b>${doneCount} done</b> · Export unlocks when complete</div>
|
| 649 |
+
<div class="spacer"></div>
|
| 650 |
+
<button class="btn-ghost" data-action="pauseRender">Pause binding</button>
|
| 651 |
+
<button class="btn-primary" data-action="goExport" ${state.renderResult ? "" : "disabled"}>Continue to export</button>
|
| 652 |
+
</div>
|
| 653 |
+
`;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
function renderExport() {
|
| 657 |
+
const tracks = includedChapters();
|
| 658 |
+
let elapsed = 0;
|
| 659 |
+
const trackRows = tracks.map((track, index) => {
|
| 660 |
+
const start = elapsed;
|
| 661 |
+
elapsed += Number(track.duration_seconds || track.est_minutes * 60 || 0);
|
| 662 |
+
return { track, start, playing: index === 2 };
|
| 663 |
+
});
|
| 664 |
+
const totalSeconds = elapsed || Math.round(totalMinutes() * 60);
|
| 665 |
+
const playbackSeconds = Math.min(totalSeconds, (trackRows[2]?.start || 0) + 300);
|
| 666 |
+
const percent = totalSeconds ? (playbackSeconds / totalSeconds) * 100 : 0;
|
| 667 |
+
|
| 668 |
+
return `
|
| 669 |
+
<div class="hero">
|
| 670 |
+
${renderCover({ w: 96, h: 140 })}
|
| 671 |
+
<div class="hero-meta">
|
| 672 |
+
<div class="smallcaps" style="color:var(--green); display:flex; align-items:center; gap:9px;">✓ Audiobook bound · ready to export</div>
|
| 673 |
+
<div class="hero-title" style="font-size:33px;">${escapeHtml(state.book?.title || "Audiobook ready")}</div>
|
| 674 |
+
<div class="hero-subtitle">narrated by ${escapeHtml(activeNarratorName())}</div>
|
| 675 |
+
<div class="spec-strip">
|
| 676 |
+
<div class="spec"><div class="spec-value">${formatRuntime(totalSeconds / 60)}</div><div class="smallcaps">runtime</div></div>
|
| 677 |
+
<div class="spec"><div class="spec-value">${tracks.length}</div><div class="smallcaps">chapters</div></div>
|
| 678 |
+
<div class="spec"><div class="spec-value">.${state.exportFormat}</div><div class="smallcaps">format</div></div>
|
| 679 |
+
<div class="spec"><div class="spec-value">${state.exportFile ? humanBytes(state.exportFile.size_bytes) : "Pending"}</div><div class="smallcaps">size</div></div>
|
| 680 |
+
</div>
|
| 681 |
+
</div>
|
| 682 |
+
<div>
|
| 683 |
+
<button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download again" : "Download audiobook"}</button>
|
| 684 |
+
${state.exportFile ? `<div style="margin-top:10px;"><a href="${state.exportFile.url}" target="_blank" rel="noopener">Open exported file</a></div>` : `<div style="margin-top:10px; color:var(--faint); font-size:12.5px;">with embedded chapter markers</div>`}
|
| 685 |
+
</div>
|
| 686 |
+
</div>
|
| 687 |
+
|
| 688 |
+
<div class="cols">
|
| 689 |
+
<div>
|
| 690 |
+
<div class="panel-head">
|
| 691 |
+
<h2 class="panel-title">Chapters & markers</h2>
|
| 692 |
+
<span class="right">${tracks.length} tracks</span>
|
| 693 |
+
</div>
|
| 694 |
+
<div class="queue">
|
| 695 |
+
<div class="list-head">Chapter <span class="count">${formatRuntime(totalSeconds / 60)} total</span></div>
|
| 696 |
+
<div class="track-list">
|
| 697 |
+
${trackRows.map(({ track, start, playing }) => `
|
| 698 |
+
<div class="track-row ${playing ? "playing" : ""}">
|
| 699 |
+
<span class="roman">${escapeHtml(track.n)}</span>
|
| 700 |
+
<button class="circle-btn">${playing ? "❚❚" : "▶"}</button>
|
| 701 |
+
<div class="track-main">
|
| 702 |
+
<div class="track-name">${escapeHtml(track.title)}</div>
|
| 703 |
+
<div class="track-meta">starts at ${formatStamp(start)}</div>
|
| 704 |
+
</div>
|
| 705 |
+
<div>${formatStamp(Number(track.duration_seconds || track.est_minutes * 60 || 0))}</div>
|
| 706 |
+
</div>
|
| 707 |
+
`).join("")}
|
| 708 |
+
</div>
|
| 709 |
+
</div>
|
| 710 |
+
</div>
|
| 711 |
+
<div>
|
| 712 |
+
<div class="panel-head">
|
| 713 |
+
<h2 class="panel-title">Preview & export</h2>
|
| 714 |
+
</div>
|
| 715 |
+
<div class="player">
|
| 716 |
+
<div style="display:flex; gap:18px; align-items:center; margin-bottom:18px;">
|
| 717 |
+
${renderCover({ w: 70, h: 102 })}
|
| 718 |
+
<div>
|
| 719 |
+
<div class="smallcaps">Now playing · chapter ${trackRows[2]?.track.n || "i"}</div>
|
| 720 |
+
<div class="player-title" style="font-size:24px; font-weight:700;">${escapeHtml(trackRows[2]?.track.title || state.book?.title || "Preview")}</div>
|
| 721 |
+
<div class="chapter-meta">narrated by ${escapeHtml(activeNarratorName())} · 1.0×</div>
|
| 722 |
+
</div>
|
| 723 |
+
</div>
|
| 724 |
+
<div class="scrubber">
|
| 725 |
+
<div class="scrub-fill" style="width:${percent}%"></div>
|
| 726 |
+
${trackRows.map(({ start }, index) => index ? `<span class="scrub-tick" style="left:${(start / totalSeconds) * 100}%"></span>` : "").join("")}
|
| 727 |
+
<span class="scrub-head" style="left:${percent}%"></span>
|
| 728 |
+
</div>
|
| 729 |
+
<div class="track-meta" style="display:flex; justify-content:space-between;"> <span>${formatStamp(playbackSeconds)} elapsed</span> <span>−${formatRuntime((totalSeconds - playbackSeconds) / 60)}</span></div>
|
| 730 |
+
<div class="transport">
|
| 731 |
+
<button class="transport-btn">⏮</button>
|
| 732 |
+
<button class="transport-btn">↺15</button>
|
| 733 |
+
<button class="transport-btn main" data-action="playExportPreview">▶</button>
|
| 734 |
+
<button class="transport-btn">30↻</button>
|
| 735 |
+
<button class="transport-btn">⏭</button>
|
| 736 |
+
</div>
|
| 737 |
+
${state.previewUrl ? `<audio id="preview-audio" src="${state.previewUrl}" preload="metadata"></audio>` : ""}
|
| 738 |
+
</div>
|
| 739 |
+
<div class="options">
|
| 740 |
+
<div class="smallcaps" style="margin-bottom:9px;">Export format</div>
|
| 741 |
+
<div class="format-grid" style="grid-template-columns:repeat(3, minmax(0, 1fr));">
|
| 742 |
+
${["m4a", "mp3", "zip"].map((format) => `
|
| 743 |
+
<label class="format-card ${state.exportFormat === format ? "active" : ""}">
|
| 744 |
+
<input type="radio" class="hide" name="format" data-format="${format}" ${state.exportFormat === format ? "checked" : ""} />
|
| 745 |
+
<div class="spec-value" style="font-size:20px;">.${format}</div>
|
| 746 |
+
<div class="chapter-meta">${format === "zip" ? "one file per chapter" : "single file export"}</div>
|
| 747 |
+
</label>
|
| 748 |
+
`).join("")}
|
| 749 |
+
</div>
|
| 750 |
+
<div class="smallcaps" style="margin:18px 0 9px;">Audiobook metadata</div>
|
| 751 |
+
<div class="meta-grid">
|
| 752 |
+
${Object.entries(state.exportMetadata).map(([key, value]) => `
|
| 753 |
+
<label>
|
| 754 |
+
<div class="chapter-meta" style="margin-bottom:5px; text-transform:uppercase;">${escapeHtml(key)}</div>
|
| 755 |
+
<input class="field" data-meta="${key}" value="${escapeAttr(value)}" />
|
| 756 |
+
</label>
|
| 757 |
+
`).join("")}
|
| 758 |
+
</div>
|
| 759 |
+
<label style="display:flex; align-items:center; gap:11px; margin-top:16px;">
|
| 760 |
+
<input id="embed-markers" type="checkbox" ${state.embedMarkers ? "checked" : ""} />
|
| 761 |
+
Embed chapter markers & cover art into the file
|
| 762 |
+
</label>
|
| 763 |
+
</div>
|
| 764 |
+
</div>
|
| 765 |
+
</div>
|
| 766 |
+
|
| 767 |
+
<div class="footer">
|
| 768 |
+
<div class="footer-note">${escapeHtml(state.book?.title || "Audiobook")} · ${formatRuntime(totalSeconds / 60)} · ${tracks.length} chapters</div>
|
| 769 |
+
<div class="spacer"></div>
|
| 770 |
+
<button class="btn-ghost" data-action="goConfigure">Back to configure</button>
|
| 771 |
+
<button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download audiobook" : "Prepare export"}</button>
|
| 772 |
+
</div>
|
| 773 |
+
`;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
function renderCover(options = {}) {
|
| 777 |
+
const w = options.w || 124;
|
| 778 |
+
const h = options.h || 182;
|
| 779 |
+
const scale = w / 124;
|
| 780 |
+
const style = `width:${w}px;height:${h}px;padding:${16 * scale}px ${12 * scale}px;`;
|
| 781 |
+
return `
|
| 782 |
+
<div class="cover" style="${style}">
|
| 783 |
+
<span class="cover-corner top"></span>
|
| 784 |
+
<span class="cover-corner bottom"></span>
|
| 785 |
+
<div class="cover-title" style="font-size:${18 * scale}px;">${escapeHtml(state.book?.title || "Scriptorium")}</div>
|
| 786 |
+
<div class="cover-rule" style="width:${38 * scale}px;"></div>
|
| 787 |
+
<div class="cover-author" style="font-size:${13 * scale}px;">${escapeHtml(state.book?.author || "OmniVoice")}</div>
|
| 788 |
+
</div>
|
| 789 |
+
`;
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
function renderUploadPlaceholder() {
|
| 793 |
+
return `
|
| 794 |
+
<div style="padding: 22px 18px;">
|
| 795 |
+
<p style="margin-top:0;">Upload an EPUB to unlock chapter selection, narrator controls, preview, and render/export steps.</p>
|
| 796 |
+
<button class="btn-primary" data-action="chooseUpload">Choose EPUB</button>
|
| 797 |
+
<input id="epub-input" type="file" accept=".epub" class="hide" />
|
| 798 |
+
</div>
|
| 799 |
+
`;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
function renderChapterRow(chapter) {
|
| 803 |
+
const active = chapter.id === state.currentChapterId ? "focus" : "";
|
| 804 |
+
const off = chapter.included ? "" : "off";
|
| 805 |
+
return `
|
| 806 |
+
<label class="chapter-row ${active} ${off}">
|
| 807 |
+
<input type="checkbox" data-chapter-toggle="${chapter.id}" ${chapter.included ? "checked" : ""} />
|
| 808 |
+
<span class="roman">${escapeHtml(chapter.n)}</span>
|
| 809 |
+
<div class="chapter-main" data-action="focusChapter" data-id="${chapter.id}">
|
| 810 |
+
<div class="chapter-name">${escapeHtml(chapter.title)}</div>
|
| 811 |
+
<div class="chapter-meta">${chapter.chars.toLocaleString()} chars · ≈${chapter.est_minutes} min ${chapter.included ? "" : "· skipped"}</div>
|
| 812 |
+
</div>
|
| 813 |
+
<button class="circle-btn" type="button" data-action="previewChapter" data-id="${chapter.id}">▶</button>
|
| 814 |
+
</label>
|
| 815 |
+
`;
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
function renderVersePreview(text) {
|
| 819 |
+
if (!text) {
|
| 820 |
+
return `<p>Choose a chapter to inspect the reading pane.</p>`;
|
| 821 |
+
}
|
| 822 |
+
const lines = excerpt(text, 420).split(/\n+/).slice(0, 10);
|
| 823 |
+
return `<span class="dropcap">${escapeHtml(lines[0]?.[0] || "W")}</span>${lines
|
| 824 |
+
.map((line, index) => {
|
| 825 |
+
const body = index === 0 ? line.slice(1) : line;
|
| 826 |
+
return `<p>${escapeHtml(body)}</p>`;
|
| 827 |
+
})
|
| 828 |
+
.join("")}`;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
function renderChapterBody(text) {
|
| 832 |
+
if (!text) {
|
| 833 |
+
return `<p>Choose a chapter to inspect the reading pane.</p>`;
|
| 834 |
+
}
|
| 835 |
+
const paragraphs = text
|
| 836 |
+
.split(/\n{2,}/)
|
| 837 |
+
.map((chunk) => chunk.trim())
|
| 838 |
+
.filter(Boolean);
|
| 839 |
+
if (!paragraphs.length) {
|
| 840 |
+
return `<p>${escapeHtml(text)}</p>`;
|
| 841 |
+
}
|
| 842 |
+
const [first, ...rest] = paragraphs;
|
| 843 |
+
const firstLead = first[0] || "W";
|
| 844 |
+
const firstBody = first.slice(1);
|
| 845 |
+
return `
|
| 846 |
+
<p><span class="dropcap">${escapeHtml(firstLead)}</span>${escapeHtml(firstBody)}</p>
|
| 847 |
+
${rest.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join("")}
|
| 848 |
+
`;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
function renderVoiceMode() {
|
| 852 |
+
if (state.voiceMode === "clone") {
|
| 853 |
+
return `
|
| 854 |
+
<div class="drop-zone">
|
| 855 |
+
<div class="panel-title" style="font-size:21px; margin-bottom:6px;">Record your own narrator</div>
|
| 856 |
+
<div class="chapter-meta" style="margin-bottom:14px;">Recommended: 3–10 seconds of clean speech.</div>
|
| 857 |
+
<input id="clone-audio" class="field" type="file" accept="audio/*" />
|
| 858 |
+
<div class="chapter-meta" style="margin-top:10px;">${state.cloneSampleFile ? escapeHtml(state.cloneSampleFile.name) : "No sample selected yet."}</div>
|
| 859 |
+
</div>
|
| 860 |
+
<label style="display:block; margin-top:15px;">
|
| 861 |
+
<div class="smallcaps" style="margin-bottom:7px;">Reference text spoken (optional)</div>
|
| 862 |
+
<input id="clone-ref-text" class="field" value="${escapeAttr(state.cloneReferenceText)}" placeholder="Type exactly what is said in the sample…" />
|
| 863 |
+
</label>
|
| 864 |
+
<label style="display:flex; gap:10px; align-items:flex-start; margin-top:14px;">
|
| 865 |
+
<input id="clone-consent" type="checkbox" ${state.cloneConsent ? "checked" : ""} />
|
| 866 |
+
<span>${escapeHtml(VOICE_WARNING)}</span>
|
| 867 |
+
</label>
|
| 868 |
+
`;
|
| 869 |
+
}
|
| 870 |
+
if (state.voiceMode === "design") {
|
| 871 |
+
return `
|
| 872 |
+
<label style="display:block;">
|
| 873 |
+
<div class="smallcaps" style="margin-bottom:7px;">Describe the voice you want</div>
|
| 874 |
+
<textarea id="design-prompt" class="textarea" placeholder="A warm, unhurried narrator — low pitch, faintly British, the kind of voice that belongs by a fireplace…">${escapeHtml(state.designPrompt)}</textarea>
|
| 875 |
+
</label>
|
| 876 |
+
`;
|
| 877 |
+
}
|
| 878 |
+
return `
|
| 879 |
+
<div class="narrator-grid">
|
| 880 |
+
${NARRATORS.map((narrator) => `
|
| 881 |
+
<button class="narrator-card ${state.narratorId === narrator.id ? "active" : ""}" data-action="pickNarrator" data-id="${narrator.id}">
|
| 882 |
+
<div class="spec-value" style="font-size:17px;">${escapeHtml(narrator.name)}</div>
|
| 883 |
+
<div class="chapter-meta">${escapeHtml(narrator.desc)}</div>
|
| 884 |
+
</button>
|
| 885 |
+
`).join("")}
|
| 886 |
+
</div>
|
| 887 |
+
`;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
function renderQueueRow(chapter, currentId) {
|
| 891 |
+
const skipped = !chapter.included;
|
| 892 |
+
const done = state.renderEvents.some((event) => event.type === "chapter_done" && event.chapter_id === chapter.id);
|
| 893 |
+
const rendering = chapter.id === currentId && !done;
|
| 894 |
+
const status = skipped ? "skipped" : done ? "done" : rendering ? "rendering" : "queued";
|
| 895 |
+
const label = skipped
|
| 896 |
+
? "Skipped · not in audiobook"
|
| 897 |
+
: done
|
| 898 |
+
? `Bound · ${chapter.duration_seconds || chapter.est_minutes * 60}s`
|
| 899 |
+
: rendering
|
| 900 |
+
? "Rendering now…"
|
| 901 |
+
: `Queued · ≈${chapter.est_minutes} min`;
|
| 902 |
+
return `
|
| 903 |
+
<div class="queue-row ${status}">
|
| 904 |
+
<div class="queue-status ${status}">${status === "done" ? "✓" : status === "rendering" ? "●" : status === "queued" ? "○" : "–"}</div>
|
| 905 |
+
<span class="roman">${escapeHtml(chapter.n)}</span>
|
| 906 |
+
<div class="queue-main">
|
| 907 |
+
<div class="queue-name">${escapeHtml(chapter.title)}</div>
|
| 908 |
+
<div class="queue-meta">${escapeHtml(label)}</div>
|
| 909 |
+
${rendering ? `<div class="mini-bar"><div class="mini-fill" style="width:68%"></div></div>` : ""}
|
| 910 |
+
</div>
|
| 911 |
+
<div>${skipped ? "—" : `≈${chapter.est_minutes}m`}</div>
|
| 912 |
+
</div>
|
| 913 |
+
`;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
function renderLogRow(event) {
|
| 917 |
+
const stamp = new Date().toLocaleTimeString();
|
| 918 |
+
return `<div class="log-row ${event.type === "chapter_started" ? "current" : ""}"><span class="time">${stamp}</span><span>${escapeHtml(logMessage(event))}</span></div>`;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
function logMessage(event) {
|
| 922 |
+
switch (event.type) {
|
| 923 |
+
case "started":
|
| 924 |
+
return `Began binding ${event.total_chapters} chapters`;
|
| 925 |
+
case "chapter_started":
|
| 926 |
+
return `Synthesising ${event.chapter_title}`;
|
| 927 |
+
case "chapter_done":
|
| 928 |
+
return `Bound ${event.chapter_id} · ${event.duration_seconds}s`;
|
| 929 |
+
case "completed":
|
| 930 |
+
return "Completed audiobook render";
|
| 931 |
+
case "cancelled":
|
| 932 |
+
return "Cancelled render";
|
| 933 |
+
default:
|
| 934 |
+
return JSON.stringify(event);
|
| 935 |
+
}
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
function canStartRender() {
|
| 939 |
+
if (!state.book) return false;
|
| 940 |
+
if (!selectedChapterIds().length) return false;
|
| 941 |
+
if (state.voiceMode === "clone" && (!state.cloneSampleFile || !state.cloneConsent)) return false;
|
| 942 |
+
if (state.voiceMode === "design" && !state.designPrompt.trim()) return false;
|
| 943 |
+
return true;
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
function activeNarratorName() {
|
| 947 |
+
const narrator = NARRATORS.find((item) => item.id === state.narratorId);
|
| 948 |
+
return narrator?.name || "Custom narrator";
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
function syncNarratorMetadata() {
|
| 952 |
+
state.exportMetadata.narrator = `${activeNarratorName()} (OmniVoice)`;
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
function excerpt(text, length, start = 0) {
|
| 956 |
+
if (!text) return "";
|
| 957 |
+
return text.slice(start, start + length).trim();
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
function humanBytes(size) {
|
| 961 |
+
if (!size) return "0 B";
|
| 962 |
+
const units = ["B", "KB", "MB", "GB"];
|
| 963 |
+
let value = size;
|
| 964 |
+
let index = 0;
|
| 965 |
+
while (value >= 1024 && index < units.length - 1) {
|
| 966 |
+
value /= 1024;
|
| 967 |
+
index += 1;
|
| 968 |
+
}
|
| 969 |
+
return `${value.toFixed(index ? 1 : 0)} ${units[index]}`;
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
function escapeHtml(value) {
|
| 973 |
+
return String(value)
|
| 974 |
+
.replaceAll("&", "&")
|
| 975 |
+
.replaceAll("<", "<")
|
| 976 |
+
.replaceAll(">", ">")
|
| 977 |
+
.replaceAll('"', """)
|
| 978 |
+
.replaceAll("'", "'");
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
function escapeAttr(value) {
|
| 982 |
+
return escapeHtml(value);
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
main();
|
frontend/index.html
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Scriptorium</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Spectral:wght@400;500;600;700&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.3.2/iframeResizer.contentWindow.min.js"></script>
|
| 14 |
+
<link rel="stylesheet" href="/static/styles.css" />
|
| 15 |
+
</head>
|
| 16 |
+
<body>
|
| 17 |
+
<div id="app"></div>
|
| 18 |
+
<script type="module" src="/static/app.js"></script>
|
| 19 |
+
</body>
|
| 20 |
+
</html>
|
frontend/styles.css
ADDED
|
@@ -0,0 +1,1056 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--paper: #f6efde;
|
| 3 |
+
--paper2: #efe5cd;
|
| 4 |
+
--paperEdge: #e4d6b4;
|
| 5 |
+
--ink: #33271a;
|
| 6 |
+
--ink2: #5e4d36;
|
| 7 |
+
--faint: #8a795c;
|
| 8 |
+
--leather: #73302a;
|
| 9 |
+
--leather2: #8c3c33;
|
| 10 |
+
--leatherInk: #f3e2c8;
|
| 11 |
+
--brass: #b08a44;
|
| 12 |
+
--brass2: #caa75e;
|
| 13 |
+
--brassDeep: #876327;
|
| 14 |
+
--green: #3c5240;
|
| 15 |
+
--line: rgba(135, 99, 39, 0.25);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
* {
|
| 19 |
+
box-sizing: border-box;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body {
|
| 23 |
+
margin: 0;
|
| 24 |
+
min-height: 100vh;
|
| 25 |
+
font-family: "Spectral", Georgia, serif;
|
| 26 |
+
color: var(--ink);
|
| 27 |
+
background:
|
| 28 |
+
radial-gradient(70% 50% at 22% -6%, rgba(255, 234, 176, 0.5), transparent 55%),
|
| 29 |
+
repeating-linear-gradient(91deg, #5a3f28, #5a3f28 3px, #553a24 3px, #553a24 7px),
|
| 30 |
+
linear-gradient(160deg, #5e4229, #4a331f);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
a {
|
| 34 |
+
color: inherit;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
button,
|
| 38 |
+
input,
|
| 39 |
+
textarea,
|
| 40 |
+
select {
|
| 41 |
+
font: inherit;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.app-shell {
|
| 45 |
+
max-width: 1480px;
|
| 46 |
+
margin: 22px auto;
|
| 47 |
+
padding: 34px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.sheet {
|
| 51 |
+
min-height: calc(100vh - 44px);
|
| 52 |
+
border-radius: 8px;
|
| 53 |
+
background:
|
| 54 |
+
radial-gradient(90% 60% at 18% 0%, #fbf5e6, transparent 55%),
|
| 55 |
+
linear-gradient(170deg, #f6efde, #ece0c4);
|
| 56 |
+
box-shadow:
|
| 57 |
+
0 30px 70px rgba(0, 0, 0, 0.5),
|
| 58 |
+
0 2px 0 rgba(255, 255, 255, 0.4) inset,
|
| 59 |
+
0 0 0 1px rgba(135, 99, 39, 0.25);
|
| 60 |
+
overflow: hidden;
|
| 61 |
+
position: relative;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.sheet::before {
|
| 65 |
+
content: "";
|
| 66 |
+
position: absolute;
|
| 67 |
+
inset: 0;
|
| 68 |
+
pointer-events: none;
|
| 69 |
+
mix-blend-mode: multiply;
|
| 70 |
+
opacity: 0.45;
|
| 71 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='p'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23p)' opacity='.35'/%3E%3C/svg%3E");
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.page {
|
| 75 |
+
position: relative;
|
| 76 |
+
padding: 36px 48px 30px;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.mast {
|
| 80 |
+
display: flex;
|
| 81 |
+
justify-content: space-between;
|
| 82 |
+
align-items: center;
|
| 83 |
+
gap: 20px;
|
| 84 |
+
margin-bottom: 24px;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.brand {
|
| 88 |
+
display: flex;
|
| 89 |
+
align-items: center;
|
| 90 |
+
gap: 18px;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.plaque {
|
| 94 |
+
width: 58px;
|
| 95 |
+
height: 58px;
|
| 96 |
+
border-radius: 7px;
|
| 97 |
+
display: grid;
|
| 98 |
+
place-items: center;
|
| 99 |
+
color: #fff6e2;
|
| 100 |
+
background: linear-gradient(150deg, var(--brass2), var(--brassDeep));
|
| 101 |
+
border: 1px solid #e9cf92;
|
| 102 |
+
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.5), inset 0 -3px 6px rgba(0, 0, 0, 0.3), 0 4px 10px rgba(0, 0, 0, 0.3);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.wordmark {
|
| 106 |
+
font-family: "Playfair Display", serif;
|
| 107 |
+
font-size: 38px;
|
| 108 |
+
font-weight: 800;
|
| 109 |
+
color: var(--leather);
|
| 110 |
+
line-height: 0.95;
|
| 111 |
+
letter-spacing: -0.5px;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.tagline {
|
| 115 |
+
color: var(--ink2);
|
| 116 |
+
font-style: italic;
|
| 117 |
+
font-size: 16px;
|
| 118 |
+
margin-top: 4px;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.steps {
|
| 122 |
+
display: flex;
|
| 123 |
+
align-items: center;
|
| 124 |
+
gap: 12px;
|
| 125 |
+
flex-wrap: wrap;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.step {
|
| 129 |
+
color: var(--faint);
|
| 130 |
+
text-transform: uppercase;
|
| 131 |
+
letter-spacing: 1.5px;
|
| 132 |
+
font-size: 12.5px;
|
| 133 |
+
font-weight: 600;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.step.done {
|
| 137 |
+
color: var(--brassDeep);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.step.now {
|
| 141 |
+
color: var(--leather);
|
| 142 |
+
border-bottom: 2px solid var(--leather);
|
| 143 |
+
padding-bottom: 4px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.sep {
|
| 147 |
+
color: rgba(135, 99, 39, 0.45);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.card,
|
| 151 |
+
.panel,
|
| 152 |
+
.hero,
|
| 153 |
+
.queue,
|
| 154 |
+
.player,
|
| 155 |
+
.voice-panel,
|
| 156 |
+
.reading-panel,
|
| 157 |
+
.options,
|
| 158 |
+
.log {
|
| 159 |
+
border: 1px solid var(--line);
|
| 160 |
+
border-radius: 6px;
|
| 161 |
+
background: linear-gradient(180deg, #fffdf6, #f7efdc);
|
| 162 |
+
box-shadow: 0 8px 20px rgba(74, 51, 31, 0.1);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.hero {
|
| 166 |
+
display: flex;
|
| 167 |
+
align-items: center;
|
| 168 |
+
gap: 28px;
|
| 169 |
+
padding: 20px 26px;
|
| 170 |
+
margin-bottom: 18px;
|
| 171 |
+
background: linear-gradient(180deg, #fff, rgba(246, 239, 222, 0.4));
|
| 172 |
+
box-shadow: 0 2px 0 rgba(255, 255, 255, 0.6) inset, 0 10px 24px rgba(74, 51, 31, 0.12);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.cover {
|
| 176 |
+
width: 124px;
|
| 177 |
+
height: 182px;
|
| 178 |
+
flex: 0 0 auto;
|
| 179 |
+
padding: 16px 12px;
|
| 180 |
+
position: relative;
|
| 181 |
+
border-radius: 3px 5px 5px 3px;
|
| 182 |
+
background: linear-gradient(120deg, #7d342c, #5e241f);
|
| 183 |
+
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.4), inset -8px 0 14px rgba(0, 0, 0, 0.35);
|
| 184 |
+
display: flex;
|
| 185 |
+
flex-direction: column;
|
| 186 |
+
justify-content: center;
|
| 187 |
+
text-align: center;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.cover::before {
|
| 191 |
+
content: "";
|
| 192 |
+
position: absolute;
|
| 193 |
+
inset: 0 auto 0 0;
|
| 194 |
+
width: 11px;
|
| 195 |
+
border-radius: 3px 0 0 3px;
|
| 196 |
+
background: linear-gradient(90deg, #4a1b17, #742c25);
|
| 197 |
+
box-shadow: inset -2px 0 3px rgba(0, 0, 0, 0.5);
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.cover-title {
|
| 201 |
+
font-family: "Playfair Display", serif;
|
| 202 |
+
font-size: 18px;
|
| 203 |
+
font-weight: 700;
|
| 204 |
+
color: #f3dcb0;
|
| 205 |
+
line-height: 1.05;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.cover-rule {
|
| 209 |
+
width: 38px;
|
| 210 |
+
height: 2px;
|
| 211 |
+
margin: 11px auto;
|
| 212 |
+
background: var(--brass2);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.cover-author {
|
| 216 |
+
font-size: 13px;
|
| 217 |
+
font-style: italic;
|
| 218 |
+
color: #e6c894;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.cover-corner {
|
| 222 |
+
position: absolute;
|
| 223 |
+
width: 18px;
|
| 224 |
+
height: 18px;
|
| 225 |
+
border: 2px solid var(--brass2);
|
| 226 |
+
opacity: 0.85;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
.cover-corner.top {
|
| 230 |
+
top: 7px;
|
| 231 |
+
right: 7px;
|
| 232 |
+
border-left: none;
|
| 233 |
+
border-bottom: none;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.cover-corner.bottom {
|
| 237 |
+
right: 7px;
|
| 238 |
+
bottom: 7px;
|
| 239 |
+
border-left: none;
|
| 240 |
+
border-top: none;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.hero-meta {
|
| 244 |
+
flex: 1 1 auto;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
.hero-title,
|
| 248 |
+
.panel-title,
|
| 249 |
+
.screen-title {
|
| 250 |
+
font-family: "Playfair Display", serif;
|
| 251 |
+
color: var(--ink);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.hero-title {
|
| 255 |
+
font-weight: 800;
|
| 256 |
+
font-size: 38px;
|
| 257 |
+
line-height: 1.02;
|
| 258 |
+
letter-spacing: -0.5px;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.hero-subtitle {
|
| 262 |
+
margin-top: 5px;
|
| 263 |
+
color: var(--leather);
|
| 264 |
+
font-size: 20px;
|
| 265 |
+
font-style: italic;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.hero-kicker {
|
| 269 |
+
margin-top: 8px;
|
| 270 |
+
color: var(--faint);
|
| 271 |
+
font-size: 13px;
|
| 272 |
+
text-transform: uppercase;
|
| 273 |
+
letter-spacing: 1.6px;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.hero-tally {
|
| 277 |
+
text-align: right;
|
| 278 |
+
padding: 14px 22px;
|
| 279 |
+
border-radius: 6px;
|
| 280 |
+
color: #fff6e2;
|
| 281 |
+
background: linear-gradient(160deg, var(--brass2), var(--brassDeep));
|
| 282 |
+
border: 1px solid #e9cf92;
|
| 283 |
+
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.4), inset 0 -3px 8px rgba(0, 0, 0, 0.28);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.hero-big {
|
| 287 |
+
font-family: "Playfair Display", serif;
|
| 288 |
+
font-size: 46px;
|
| 289 |
+
font-weight: 800;
|
| 290 |
+
line-height: 0.9;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.hero-big span {
|
| 294 |
+
font-size: 24px;
|
| 295 |
+
opacity: 0.75;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.hero-run,
|
| 299 |
+
.smallcaps {
|
| 300 |
+
text-transform: uppercase;
|
| 301 |
+
letter-spacing: 1.2px;
|
| 302 |
+
font-size: 12px;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.hero-run {
|
| 306 |
+
margin-top: 10px;
|
| 307 |
+
padding-top: 10px;
|
| 308 |
+
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
| 309 |
+
font-size: 16px;
|
| 310 |
+
text-transform: none;
|
| 311 |
+
letter-spacing: 0;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.cols {
|
| 315 |
+
display: grid;
|
| 316 |
+
grid-template-columns: 1fr 1.08fr;
|
| 317 |
+
gap: 24px;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.panel-head {
|
| 321 |
+
display: flex;
|
| 322 |
+
align-items: center;
|
| 323 |
+
gap: 11px;
|
| 324 |
+
margin-bottom: 14px;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
.panel-head .right {
|
| 328 |
+
margin-left: auto;
|
| 329 |
+
color: var(--brassDeep);
|
| 330 |
+
font-size: 13px;
|
| 331 |
+
text-transform: uppercase;
|
| 332 |
+
letter-spacing: 1.2px;
|
| 333 |
+
font-weight: 600;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.panel-title {
|
| 337 |
+
margin: 0;
|
| 338 |
+
font-size: 23px;
|
| 339 |
+
font-weight: 700;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.queue-head,
|
| 343 |
+
.list-head {
|
| 344 |
+
display: flex;
|
| 345 |
+
align-items: center;
|
| 346 |
+
gap: 12px;
|
| 347 |
+
padding: 12px 18px;
|
| 348 |
+
border-bottom: 1px solid rgba(135, 99, 39, 0.22);
|
| 349 |
+
background: linear-gradient(180deg, #f1e4c6, #e9d9b6);
|
| 350 |
+
color: var(--ink2);
|
| 351 |
+
text-transform: uppercase;
|
| 352 |
+
letter-spacing: 1.3px;
|
| 353 |
+
font-size: 12.5px;
|
| 354 |
+
font-weight: 600;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
.queue-head .count,
|
| 358 |
+
.list-head .count {
|
| 359 |
+
margin-left: auto;
|
| 360 |
+
color: var(--leather);
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
.chapter-list,
|
| 364 |
+
.track-list {
|
| 365 |
+
max-height: 520px;
|
| 366 |
+
overflow: auto;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.chapter-row,
|
| 370 |
+
.track-row,
|
| 371 |
+
.queue-row {
|
| 372 |
+
display: grid;
|
| 373 |
+
grid-template-columns: auto auto 1fr auto;
|
| 374 |
+
gap: 14px;
|
| 375 |
+
align-items: center;
|
| 376 |
+
padding: 12px 18px;
|
| 377 |
+
border-bottom: 1px solid rgba(135, 99, 39, 0.13);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.chapter-row:nth-child(even),
|
| 381 |
+
.track-row:nth-child(even) {
|
| 382 |
+
background: rgba(135, 99, 39, 0.035);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.chapter-row.focus,
|
| 386 |
+
.queue-row.rendering,
|
| 387 |
+
.track-row.playing {
|
| 388 |
+
background: rgba(115, 48, 42, 0.08);
|
| 389 |
+
box-shadow: inset 3px 0 0 var(--leather);
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.checkbox {
|
| 393 |
+
width: 22px;
|
| 394 |
+
height: 22px;
|
| 395 |
+
border: 1.5px solid var(--brass);
|
| 396 |
+
border-radius: 4px;
|
| 397 |
+
display: grid;
|
| 398 |
+
place-items: center;
|
| 399 |
+
background: #fff;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.checkbox.checked {
|
| 403 |
+
background: linear-gradient(180deg, var(--leather2), var(--leather));
|
| 404 |
+
border-color: var(--leather);
|
| 405 |
+
color: white;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.roman {
|
| 409 |
+
width: 30px;
|
| 410 |
+
text-align: center;
|
| 411 |
+
font-family: "Playfair Display", serif;
|
| 412 |
+
font-style: italic;
|
| 413 |
+
color: var(--brassDeep);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.chapter-main,
|
| 417 |
+
.track-main,
|
| 418 |
+
.queue-main {
|
| 419 |
+
min-width: 0;
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
.chapter-name,
|
| 423 |
+
.track-name,
|
| 424 |
+
.queue-name {
|
| 425 |
+
font-size: 17px;
|
| 426 |
+
font-weight: 500;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.chapter-meta,
|
| 430 |
+
.track-meta,
|
| 431 |
+
.queue-meta {
|
| 432 |
+
color: var(--faint);
|
| 433 |
+
font-size: 12.5px;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.chapter-row.off .chapter-name,
|
| 437 |
+
.queue-row.skipped .queue-name {
|
| 438 |
+
color: var(--faint);
|
| 439 |
+
text-decoration: line-through;
|
| 440 |
+
text-decoration-color: rgba(138, 121, 92, 0.5);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.circle-btn,
|
| 444 |
+
.transport-btn,
|
| 445 |
+
.pill,
|
| 446 |
+
.seg button,
|
| 447 |
+
.ghost-btn,
|
| 448 |
+
.small-btn {
|
| 449 |
+
border: 1px solid rgba(135, 99, 39, 0.4);
|
| 450 |
+
background: #fff;
|
| 451 |
+
color: var(--ink);
|
| 452 |
+
cursor: pointer;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.circle-btn,
|
| 456 |
+
.transport-btn {
|
| 457 |
+
width: 34px;
|
| 458 |
+
height: 34px;
|
| 459 |
+
border-radius: 50%;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.circle-btn:hover,
|
| 463 |
+
.transport-btn:hover,
|
| 464 |
+
.ghost-btn:hover,
|
| 465 |
+
.small-btn:hover {
|
| 466 |
+
background: rgba(176, 138, 68, 0.12);
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
.reading-panel,
|
| 470 |
+
.player {
|
| 471 |
+
padding: 20px 24px;
|
| 472 |
+
box-shadow: 0 12px 28px rgba(74, 51, 31, 0.16);
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.reading-panel {
|
| 476 |
+
position: relative;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.reading-panel::before {
|
| 480 |
+
content: "";
|
| 481 |
+
position: absolute;
|
| 482 |
+
left: 54px;
|
| 483 |
+
top: 0;
|
| 484 |
+
bottom: 0;
|
| 485 |
+
width: 1px;
|
| 486 |
+
background: rgba(176, 80, 70, 0.25);
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
.reading-head {
|
| 490 |
+
display: flex;
|
| 491 |
+
justify-content: space-between;
|
| 492 |
+
gap: 12px;
|
| 493 |
+
border-bottom: 1px solid rgba(135, 99, 39, 0.25);
|
| 494 |
+
padding-bottom: 14px;
|
| 495 |
+
margin-bottom: 16px;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.reading-head h3,
|
| 499 |
+
.now-title,
|
| 500 |
+
.player-title {
|
| 501 |
+
margin: 0;
|
| 502 |
+
font-family: "Playfair Display", serif;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
.reading-head h3 {
|
| 506 |
+
font-size: 25px;
|
| 507 |
+
font-weight: 700;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.chips {
|
| 511 |
+
display: flex;
|
| 512 |
+
gap: 8px;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.chip,
|
| 516 |
+
.small-btn,
|
| 517 |
+
.ghost-btn {
|
| 518 |
+
display: inline-flex;
|
| 519 |
+
align-items: center;
|
| 520 |
+
justify-content: center;
|
| 521 |
+
gap: 8px;
|
| 522 |
+
padding: 9px 14px;
|
| 523 |
+
border-radius: 5px;
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.chip.warn,
|
| 527 |
+
.small-btn.danger {
|
| 528 |
+
color: var(--leather);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.verse {
|
| 532 |
+
padding-left: 14px;
|
| 533 |
+
font-size: 20px;
|
| 534 |
+
line-height: 1.6;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.reading-scroll {
|
| 538 |
+
max-height: 460px;
|
| 539 |
+
overflow: auto;
|
| 540 |
+
padding-right: 14px;
|
| 541 |
+
scroll-behavior: smooth;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.reading-scroll p {
|
| 545 |
+
margin: 0 0 1rem;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
.dropcap {
|
| 549 |
+
float: left;
|
| 550 |
+
margin: 4px 14px 0 0;
|
| 551 |
+
font-family: "Playfair Display", serif;
|
| 552 |
+
font-size: 88px;
|
| 553 |
+
font-weight: 800;
|
| 554 |
+
line-height: 0.74;
|
| 555 |
+
color: var(--leather);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.voice-panel,
|
| 559 |
+
.options {
|
| 560 |
+
padding: 20px 24px;
|
| 561 |
+
margin-top: 16px;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
.seg {
|
| 565 |
+
display: flex;
|
| 566 |
+
overflow: hidden;
|
| 567 |
+
border: 1px solid rgba(135, 99, 39, 0.4);
|
| 568 |
+
border-radius: 5px;
|
| 569 |
+
background: #f1e4c6;
|
| 570 |
+
margin-bottom: 18px;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
.seg button {
|
| 574 |
+
flex: 1;
|
| 575 |
+
border: 0;
|
| 576 |
+
border-right: 1px solid rgba(135, 99, 39, 0.25);
|
| 577 |
+
padding: 12px 8px;
|
| 578 |
+
color: var(--ink2);
|
| 579 |
+
text-transform: uppercase;
|
| 580 |
+
letter-spacing: 1.4px;
|
| 581 |
+
font-size: 13px;
|
| 582 |
+
font-weight: 600;
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
.seg button:last-child {
|
| 586 |
+
border-right: 0;
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
.seg button.active {
|
| 590 |
+
color: var(--leatherInk);
|
| 591 |
+
background: linear-gradient(180deg, var(--leather2), var(--leather));
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
.narrator-grid,
|
| 595 |
+
.format-grid,
|
| 596 |
+
.meta-grid {
|
| 597 |
+
display: grid;
|
| 598 |
+
gap: 12px;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.narrator-grid {
|
| 602 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.narrator-card,
|
| 606 |
+
.format-card {
|
| 607 |
+
border: 1px solid rgba(135, 99, 39, 0.28);
|
| 608 |
+
border-radius: 6px;
|
| 609 |
+
background: #fff;
|
| 610 |
+
padding: 12px 14px;
|
| 611 |
+
cursor: pointer;
|
| 612 |
+
text-align: left;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.narrator-card.active,
|
| 616 |
+
.format-card.active {
|
| 617 |
+
border-color: var(--leather);
|
| 618 |
+
background: rgba(115, 48, 42, 0.07);
|
| 619 |
+
box-shadow: 0 0 0 1px var(--leather) inset;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
.drop-zone {
|
| 623 |
+
border: 1.5px dashed rgba(135, 99, 39, 0.5);
|
| 624 |
+
border-radius: 5px;
|
| 625 |
+
padding: 24px;
|
| 626 |
+
background: rgba(176, 138, 68, 0.05);
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
.drop-zone input[type="file"] {
|
| 630 |
+
margin-top: 8px;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
.field,
|
| 634 |
+
.textarea {
|
| 635 |
+
width: 100%;
|
| 636 |
+
border: 1px solid rgba(135, 99, 39, 0.35);
|
| 637 |
+
border-radius: 4px;
|
| 638 |
+
background: #fff;
|
| 639 |
+
color: var(--ink);
|
| 640 |
+
padding: 12px 14px;
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
.textarea {
|
| 644 |
+
min-height: 110px;
|
| 645 |
+
resize: vertical;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.dial-grid {
|
| 649 |
+
display: grid;
|
| 650 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 651 |
+
gap: 22px;
|
| 652 |
+
margin-top: 16px;
|
| 653 |
+
padding-top: 16px;
|
| 654 |
+
border-top: 1px solid rgba(135, 99, 39, 0.22);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.dial-head {
|
| 658 |
+
display: flex;
|
| 659 |
+
justify-content: space-between;
|
| 660 |
+
margin-bottom: 11px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.dial-value {
|
| 664 |
+
color: var(--leather);
|
| 665 |
+
font-family: "Playfair Display", serif;
|
| 666 |
+
font-size: 20px;
|
| 667 |
+
font-weight: 700;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.range {
|
| 671 |
+
width: 100%;
|
| 672 |
+
accent-color: var(--leather);
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
.footer {
|
| 676 |
+
display: flex;
|
| 677 |
+
align-items: center;
|
| 678 |
+
gap: 20px;
|
| 679 |
+
margin-top: 18px;
|
| 680 |
+
padding-top: 18px;
|
| 681 |
+
border-top: 1px solid rgba(135, 99, 39, 0.3);
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
.footer-note {
|
| 685 |
+
color: var(--ink2);
|
| 686 |
+
font-size: 16px;
|
| 687 |
+
font-style: italic;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.spacer {
|
| 691 |
+
flex: 1;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.btn-ghost,
|
| 695 |
+
.btn-primary {
|
| 696 |
+
display: inline-flex;
|
| 697 |
+
align-items: center;
|
| 698 |
+
justify-content: center;
|
| 699 |
+
gap: 12px;
|
| 700 |
+
border-radius: 5px;
|
| 701 |
+
padding: 15px 26px;
|
| 702 |
+
font-family: "Playfair Display", serif;
|
| 703 |
+
font-size: 18px;
|
| 704 |
+
font-weight: 600;
|
| 705 |
+
cursor: pointer;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
.btn-ghost {
|
| 709 |
+
background: #fff;
|
| 710 |
+
color: var(--ink);
|
| 711 |
+
border: 1px solid rgba(135, 99, 39, 0.5);
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.btn-primary {
|
| 715 |
+
background: linear-gradient(180deg, var(--leather2), var(--leather) 60%, #5e241f);
|
| 716 |
+
color: #f6e6cc;
|
| 717 |
+
border: 1px solid #9c4a40;
|
| 718 |
+
box-shadow: 0 8px 20px rgba(115, 48, 42, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
.btn-primary:disabled,
|
| 722 |
+
.btn-ghost:disabled,
|
| 723 |
+
.small-btn:disabled {
|
| 724 |
+
cursor: not-allowed;
|
| 725 |
+
opacity: 0.5;
|
| 726 |
+
filter: grayscale(0.3);
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.status-banner,
|
| 730 |
+
.error-banner {
|
| 731 |
+
margin-bottom: 16px;
|
| 732 |
+
padding: 12px 14px;
|
| 733 |
+
border-radius: 6px;
|
| 734 |
+
font-size: 14px;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.status-banner {
|
| 738 |
+
background: rgba(176, 138, 68, 0.1);
|
| 739 |
+
border: 1px solid rgba(176, 138, 68, 0.3);
|
| 740 |
+
color: var(--ink2);
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
.error-banner {
|
| 744 |
+
background: rgba(115, 48, 42, 0.08);
|
| 745 |
+
border: 1px solid rgba(115, 48, 42, 0.35);
|
| 746 |
+
color: var(--leather);
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
.progress-bar,
|
| 750 |
+
.mini-bar,
|
| 751 |
+
.scrubber {
|
| 752 |
+
position: relative;
|
| 753 |
+
overflow: hidden;
|
| 754 |
+
background: rgba(135, 99, 39, 0.18);
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
.progress-bar {
|
| 758 |
+
height: 14px;
|
| 759 |
+
border-radius: 8px;
|
| 760 |
+
border: 1px solid rgba(135, 99, 39, 0.25);
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
.mini-bar {
|
| 764 |
+
height: 5px;
|
| 765 |
+
border-radius: 3px;
|
| 766 |
+
margin-top: 7px;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.progress-fill,
|
| 770 |
+
.mini-fill,
|
| 771 |
+
.scrub-fill {
|
| 772 |
+
position: absolute;
|
| 773 |
+
inset: 0 auto 0 0;
|
| 774 |
+
background: linear-gradient(90deg, var(--brassDeep), var(--brass2));
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
.progress-fill::after {
|
| 778 |
+
content: "";
|
| 779 |
+
position: absolute;
|
| 780 |
+
inset: 0;
|
| 781 |
+
background: repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.18) 0 10px, transparent 10px 22px);
|
| 782 |
+
animation: stripe 1s linear infinite;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
.status-dot {
|
| 786 |
+
width: 11px;
|
| 787 |
+
height: 11px;
|
| 788 |
+
border-radius: 50%;
|
| 789 |
+
background: var(--leather);
|
| 790 |
+
display: inline-block;
|
| 791 |
+
box-shadow: 0 0 0 0 rgba(115, 48, 42, 0.5);
|
| 792 |
+
animation: pulse 1.8s ease-out infinite;
|
| 793 |
+
}
|
| 794 |
+
|
| 795 |
+
.queue-status {
|
| 796 |
+
width: 26px;
|
| 797 |
+
height: 26px;
|
| 798 |
+
border-radius: 50%;
|
| 799 |
+
display: grid;
|
| 800 |
+
place-items: center;
|
| 801 |
+
font-size: 13px;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
.queue-status.done {
|
| 805 |
+
background: linear-gradient(150deg, #4e6b52, #3c5240);
|
| 806 |
+
color: #eaf3ea;
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
.queue-status.rendering {
|
| 810 |
+
background: var(--leather);
|
| 811 |
+
color: white;
|
| 812 |
+
}
|
| 813 |
+
|
| 814 |
+
.queue-status.queued {
|
| 815 |
+
border: 1.5px solid rgba(135, 99, 39, 0.4);
|
| 816 |
+
color: var(--faint);
|
| 817 |
+
}
|
| 818 |
+
|
| 819 |
+
.queue-status.skipped {
|
| 820 |
+
border: 1.5px dashed rgba(138, 121, 92, 0.5);
|
| 821 |
+
color: var(--faint);
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.stats-hero {
|
| 825 |
+
display: flex;
|
| 826 |
+
justify-content: space-between;
|
| 827 |
+
gap: 20px;
|
| 828 |
+
margin-top: 9px;
|
| 829 |
+
color: var(--ink2);
|
| 830 |
+
font-size: 14px;
|
| 831 |
+
}
|
| 832 |
+
|
| 833 |
+
.hero-stats {
|
| 834 |
+
padding-left: 24px;
|
| 835 |
+
border-left: 1px solid rgba(135, 99, 39, 0.24);
|
| 836 |
+
text-align: right;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
.pct {
|
| 840 |
+
font-family: "Playfair Display", serif;
|
| 841 |
+
font-size: 52px;
|
| 842 |
+
line-height: 0.85;
|
| 843 |
+
color: var(--leather);
|
| 844 |
+
font-weight: 800;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.wave {
|
| 848 |
+
display: flex;
|
| 849 |
+
align-items: center;
|
| 850 |
+
justify-content: center;
|
| 851 |
+
gap: 3px;
|
| 852 |
+
height: 72px;
|
| 853 |
+
margin: 4px 0 16px;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
.wave i {
|
| 857 |
+
width: 5px;
|
| 858 |
+
border-radius: 3px;
|
| 859 |
+
background: linear-gradient(180deg, var(--brass2), var(--leather));
|
| 860 |
+
animation: wave 1.1s ease-in-out infinite;
|
| 861 |
+
}
|
| 862 |
+
|
| 863 |
+
.spoken {
|
| 864 |
+
border: 1px solid rgba(135, 99, 39, 0.22);
|
| 865 |
+
border-radius: 5px;
|
| 866 |
+
background: #fff;
|
| 867 |
+
padding: 16px 18px;
|
| 868 |
+
font-size: 19px;
|
| 869 |
+
line-height: 1.55;
|
| 870 |
+
font-style: italic;
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
.spoken .lit {
|
| 874 |
+
color: var(--ink);
|
| 875 |
+
font-style: normal;
|
| 876 |
+
background: linear-gradient(180deg, transparent 58%, rgba(202, 167, 94, 0.55) 58%);
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
.spoken .dim {
|
| 880 |
+
color: var(--faint);
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.log {
|
| 884 |
+
overflow: hidden;
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
.log-row {
|
| 888 |
+
display: flex;
|
| 889 |
+
gap: 14px;
|
| 890 |
+
align-items: baseline;
|
| 891 |
+
padding: 10px 16px;
|
| 892 |
+
border-bottom: 1px solid rgba(135, 99, 39, 0.12);
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
.log-row:last-child {
|
| 896 |
+
border-bottom: 0;
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
.log-row.current {
|
| 900 |
+
color: var(--leather);
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
.time {
|
| 904 |
+
color: var(--brassDeep);
|
| 905 |
+
font-variant-numeric: tabular-nums;
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
.spec-strip {
|
| 909 |
+
display: flex;
|
| 910 |
+
flex-wrap: wrap;
|
| 911 |
+
width: fit-content;
|
| 912 |
+
margin-top: 14px;
|
| 913 |
+
border-radius: 6px;
|
| 914 |
+
overflow: hidden;
|
| 915 |
+
border: 1px solid rgba(135, 99, 39, 0.22);
|
| 916 |
+
background: #fffaf0;
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
.spec {
|
| 920 |
+
padding: 10px 18px;
|
| 921 |
+
border-right: 1px solid rgba(135, 99, 39, 0.18);
|
| 922 |
+
}
|
| 923 |
+
|
| 924 |
+
.spec:last-child {
|
| 925 |
+
border-right: 0;
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
.spec-value {
|
| 929 |
+
font-family: "Playfair Display", serif;
|
| 930 |
+
font-size: 19px;
|
| 931 |
+
font-weight: 700;
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
.scrubber {
|
| 935 |
+
height: 8px;
|
| 936 |
+
border-radius: 5px;
|
| 937 |
+
margin: 6px 0;
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
.scrub-fill {
|
| 941 |
+
background: linear-gradient(90deg, var(--leather2), var(--leather));
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
.scrub-head,
|
| 945 |
+
.scrub-tick {
|
| 946 |
+
position: absolute;
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
.scrub-tick {
|
| 950 |
+
top: -2px;
|
| 951 |
+
width: 2px;
|
| 952 |
+
height: 12px;
|
| 953 |
+
background: rgba(135, 99, 39, 0.5);
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
.scrub-head {
|
| 957 |
+
top: 50%;
|
| 958 |
+
width: 16px;
|
| 959 |
+
height: 16px;
|
| 960 |
+
border-radius: 50%;
|
| 961 |
+
transform: translate(-50%, -50%);
|
| 962 |
+
background: radial-gradient(circle at 35% 30%, #fff, var(--leather));
|
| 963 |
+
border: 1px solid #fff;
|
| 964 |
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
.transport {
|
| 968 |
+
display: flex;
|
| 969 |
+
justify-content: center;
|
| 970 |
+
gap: 18px;
|
| 971 |
+
margin-top: 10px;
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
.transport-btn {
|
| 975 |
+
width: 46px;
|
| 976 |
+
height: 46px;
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.transport-btn.main {
|
| 980 |
+
width: 64px;
|
| 981 |
+
height: 64px;
|
| 982 |
+
color: #f6e6cc;
|
| 983 |
+
border: 1px solid #9c4a40;
|
| 984 |
+
background: linear-gradient(180deg, var(--leather2), var(--leather) 60%, #5e241f);
|
| 985 |
+
box-shadow: 0 8px 18px rgba(115, 48, 42, 0.4);
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
.meta-grid {
|
| 989 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
.toggle {
|
| 993 |
+
width: 42px;
|
| 994 |
+
height: 24px;
|
| 995 |
+
border-radius: 13px;
|
| 996 |
+
position: relative;
|
| 997 |
+
background: linear-gradient(180deg, var(--leather2), var(--leather));
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
.toggle::after {
|
| 1001 |
+
content: "";
|
| 1002 |
+
position: absolute;
|
| 1003 |
+
top: 2px;
|
| 1004 |
+
left: 20px;
|
| 1005 |
+
width: 20px;
|
| 1006 |
+
height: 20px;
|
| 1007 |
+
border-radius: 50%;
|
| 1008 |
+
background: white;
|
| 1009 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
.hide {
|
| 1013 |
+
display: none !important;
|
| 1014 |
+
}
|
| 1015 |
+
|
| 1016 |
+
@keyframes pulse {
|
| 1017 |
+
0% { box-shadow: 0 0 0 0 rgba(115, 48, 42, 0.5); }
|
| 1018 |
+
70% { box-shadow: 0 0 0 9px rgba(115, 48, 42, 0); }
|
| 1019 |
+
100% { box-shadow: 0 0 0 0 rgba(115, 48, 42, 0); }
|
| 1020 |
+
}
|
| 1021 |
+
|
| 1022 |
+
@keyframes stripe {
|
| 1023 |
+
to { background-position: 22px 0; }
|
| 1024 |
+
}
|
| 1025 |
+
|
| 1026 |
+
@keyframes wave {
|
| 1027 |
+
0%, 100% { transform: scaleY(0.32); }
|
| 1028 |
+
50% { transform: scaleY(1); }
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
@media (max-width: 1080px) {
|
| 1032 |
+
.page {
|
| 1033 |
+
padding: 26px 24px 22px;
|
| 1034 |
+
}
|
| 1035 |
+
|
| 1036 |
+
.mast,
|
| 1037 |
+
.hero,
|
| 1038 |
+
.footer {
|
| 1039 |
+
flex-direction: column;
|
| 1040 |
+
align-items: flex-start;
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
.cols,
|
| 1044 |
+
.dial-grid,
|
| 1045 |
+
.meta-grid,
|
| 1046 |
+
.narrator-grid {
|
| 1047 |
+
grid-template-columns: 1fr;
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
.hero-tally,
|
| 1051 |
+
.hero-stats {
|
| 1052 |
+
text-align: left;
|
| 1053 |
+
padding-left: 0;
|
| 1054 |
+
border-left: 0;
|
| 1055 |
+
}
|
| 1056 |
+
}
|
pytest.ini
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
pythonpath = .
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ebooklib>=0.18
|
| 2 |
+
beautifulsoup4>=4.12.0
|
| 3 |
+
mutagen>=1.47.0
|
| 4 |
+
soundfile>=0.13.0
|
| 5 |
+
numpy>=1.26.0
|
| 6 |
+
pytest>=8.3.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.31.0
|
| 2 |
+
fastapi>=0.115.0
|
| 3 |
+
ebooklib>=0.18
|
| 4 |
+
beautifulsoup4>=4.12.0
|
| 5 |
+
mutagen>=1.47.0
|
| 6 |
+
soundfile>=0.13.0
|
| 7 |
+
numpy>=1.26.0
|
| 8 |
+
torch>=2.8.0
|
| 9 |
+
torchaudio>=2.8.0
|
| 10 |
+
omnivoice>=0.1.5
|
tests/test_epub.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from ebooklib import epub
|
| 5 |
+
|
| 6 |
+
from backend.epub import EpubConfig, parse_epub
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _write_sample_epub(target: Path, chapter_count: int = 2) -> Path:
|
| 10 |
+
book = epub.EpubBook()
|
| 11 |
+
book.set_identifier("sample-id")
|
| 12 |
+
book.set_title("Sample Book")
|
| 13 |
+
book.set_language("en")
|
| 14 |
+
book.add_author("Example Author")
|
| 15 |
+
|
| 16 |
+
chapters = []
|
| 17 |
+
for idx in range(chapter_count):
|
| 18 |
+
chapter = epub.EpubHtml(
|
| 19 |
+
title=f"Chapter {idx + 1}",
|
| 20 |
+
file_name=f"chap_{idx + 1}.xhtml",
|
| 21 |
+
lang="en",
|
| 22 |
+
)
|
| 23 |
+
chapter.content = (
|
| 24 |
+
f"<h1>Chapter {idx + 1}</h1>"
|
| 25 |
+
f"<p>This is chapter {idx + 1}. It has enough words to estimate audio.</p>"
|
| 26 |
+
)
|
| 27 |
+
book.add_item(chapter)
|
| 28 |
+
chapters.append(chapter)
|
| 29 |
+
|
| 30 |
+
book.toc = tuple(chapters)
|
| 31 |
+
book.spine = ["nav", *chapters]
|
| 32 |
+
book.add_item(epub.EpubNcx())
|
| 33 |
+
book.add_item(epub.EpubNav())
|
| 34 |
+
epub.write_epub(str(target), book)
|
| 35 |
+
return target
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_parse_epub_extracts_metadata_and_chapters(tmp_path: Path) -> None:
|
| 39 |
+
epub_path = _write_sample_epub(tmp_path / "sample.epub", chapter_count=3)
|
| 40 |
+
|
| 41 |
+
payload = parse_epub(epub_path, config=EpubConfig(max_file_bytes=2_000_000))
|
| 42 |
+
|
| 43 |
+
assert payload["title"] == "Sample Book"
|
| 44 |
+
assert payload["author"] == "Example Author"
|
| 45 |
+
assert len(payload["chapters"]) == 3
|
| 46 |
+
assert payload["chapters"][0]["title"] == "Chapter 1"
|
| 47 |
+
assert payload["chapters"][0]["included"] is True
|
| 48 |
+
assert payload["chapters"][0]["text"]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_parse_epub_rejects_oversized_upload(tmp_path: Path) -> None:
|
| 52 |
+
epub_path = _write_sample_epub(tmp_path / "sample.epub", chapter_count=1)
|
| 53 |
+
|
| 54 |
+
with pytest.raises(ValueError, match="too large"):
|
| 55 |
+
parse_epub(epub_path, config=EpubConfig(max_file_bytes=32))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_parse_epub_rejects_malformed_file(tmp_path: Path) -> None:
|
| 59 |
+
bad_path = tmp_path / "bad.epub"
|
| 60 |
+
bad_path.write_text("not really an epub", encoding="utf-8")
|
| 61 |
+
|
| 62 |
+
with pytest.raises(ValueError, match="Invalid EPUB"):
|
| 63 |
+
parse_epub(bad_path, config=EpubConfig(max_file_bytes=2_000_000))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_parse_epub_accepts_valid_epub_without_epub_suffix(tmp_path: Path) -> None:
|
| 67 |
+
epub_path = _write_sample_epub(tmp_path / "sample.epub", chapter_count=1)
|
| 68 |
+
no_suffix_path = tmp_path / "upload-cache-file"
|
| 69 |
+
no_suffix_path.write_bytes(epub_path.read_bytes())
|
| 70 |
+
|
| 71 |
+
payload = parse_epub(no_suffix_path, config=EpubConfig(max_file_bytes=2_000_000))
|
| 72 |
+
|
| 73 |
+
assert payload["title"] == "Sample Book"
|
| 74 |
+
assert len(payload["chapters"]) == 1
|
tests/test_input_files.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from backend.input_files import resolve_uploaded_name, resolve_uploaded_path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_resolve_uploaded_path_accepts_string_path(tmp_path: Path) -> None:
|
| 9 |
+
path = tmp_path / "book.epub"
|
| 10 |
+
path.write_text("x", encoding="utf-8")
|
| 11 |
+
|
| 12 |
+
resolved = resolve_uploaded_path(str(path))
|
| 13 |
+
|
| 14 |
+
assert resolved == path
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_resolve_uploaded_path_accepts_gradio_dict_payload(tmp_path: Path) -> None:
|
| 18 |
+
path = tmp_path / "book.epub"
|
| 19 |
+
path.write_text("x", encoding="utf-8")
|
| 20 |
+
|
| 21 |
+
resolved = resolve_uploaded_path({"path": str(path), "meta": {"_type": "gradio.FileData"}})
|
| 22 |
+
|
| 23 |
+
assert resolved == path
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_resolve_uploaded_path_rejects_missing_path_key() -> None:
|
| 27 |
+
with pytest.raises(TypeError, match="Unsupported uploaded file payload"):
|
| 28 |
+
resolve_uploaded_path({"url": "/tmp/book.epub"})
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_resolve_uploaded_name_prefers_original_filename() -> None:
|
| 32 |
+
name = resolve_uploaded_name(
|
| 33 |
+
{"path": "/tmp/gradio/abcd", "orig_name": "real-book.epub", "meta": {"_type": "gradio.FileData"}}
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
assert name == "real-book.epub"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_resolve_uploaded_name_falls_back_to_path_name(tmp_path: Path) -> None:
|
| 40 |
+
path = tmp_path / "fallback.epub"
|
| 41 |
+
path.write_text("x", encoding="utf-8")
|
| 42 |
+
|
| 43 |
+
name = resolve_uploaded_name(str(path))
|
| 44 |
+
|
| 45 |
+
assert name == "fallback.epub"
|
tests/test_render_pipeline.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from backend.render_pipeline import RenderPipeline
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class FakeSynthesizer:
|
| 7 |
+
def __init__(self) -> None:
|
| 8 |
+
self.calls = []
|
| 9 |
+
|
| 10 |
+
def synthesize(self, *, text: str, output_path: Path, **kwargs) -> dict:
|
| 11 |
+
self.calls.append({"text": text, "output_path": output_path, **kwargs})
|
| 12 |
+
output_path.write_bytes(b"RIFFfakewave")
|
| 13 |
+
return {"duration_seconds": max(1, len(text.split()) // 2), "sample_rate": 24000}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _chapters() -> list:
|
| 17 |
+
return [
|
| 18 |
+
{"id": "c1", "title": "One", "text": "hello world " * 20, "included": True, "est_minutes": 1},
|
| 19 |
+
{"id": "c2", "title": "Two", "text": "next chapter " * 20, "included": True, "est_minutes": 1},
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_render_pipeline_streams_ordered_progress_events(tmp_path: Path) -> None:
|
| 24 |
+
synthesizer = FakeSynthesizer()
|
| 25 |
+
pipeline = RenderPipeline(session_root=tmp_path, synthesizer=synthesizer)
|
| 26 |
+
|
| 27 |
+
events = list(
|
| 28 |
+
pipeline.render(
|
| 29 |
+
session_id="session-a",
|
| 30 |
+
book={"title": "Book"},
|
| 31 |
+
chapters=_chapters(),
|
| 32 |
+
voice_config={"mode": "auto"},
|
| 33 |
+
diffusion_steps=32,
|
| 34 |
+
speed=1.0,
|
| 35 |
+
)
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
event_types = [event["type"] for event in events]
|
| 39 |
+
assert event_types[0] == "started"
|
| 40 |
+
assert "chapter_started" in event_types
|
| 41 |
+
assert "chapter_done" in event_types
|
| 42 |
+
assert event_types[-1] == "completed"
|
| 43 |
+
assert len(synthesizer.calls) == 2
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_render_pipeline_can_be_cancelled(tmp_path: Path) -> None:
|
| 47 |
+
synthesizer = FakeSynthesizer()
|
| 48 |
+
pipeline = RenderPipeline(session_root=tmp_path, synthesizer=synthesizer)
|
| 49 |
+
iterator = pipeline.render(
|
| 50 |
+
session_id="session-a",
|
| 51 |
+
book={"title": "Book"},
|
| 52 |
+
chapters=_chapters(),
|
| 53 |
+
voice_config={"mode": "auto"},
|
| 54 |
+
diffusion_steps=32,
|
| 55 |
+
speed=1.0,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
first = next(iterator)
|
| 59 |
+
pipeline.cancel("session-a")
|
| 60 |
+
remaining = list(iterator)
|
| 61 |
+
|
| 62 |
+
assert first["type"] == "started"
|
| 63 |
+
assert remaining[-1]["type"] == "cancelled"
|
tests/test_session_store.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from backend.session_store import SessionStore
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_session_store_creates_isolated_session_dirs(tmp_path: Path) -> None:
|
| 7 |
+
store = SessionStore(root=tmp_path, ttl_seconds=60)
|
| 8 |
+
|
| 9 |
+
first = store.ensure_session("session-a")
|
| 10 |
+
second = store.ensure_session("session-b")
|
| 11 |
+
|
| 12 |
+
assert first.session_id == "session-a"
|
| 13 |
+
assert second.session_id == "session-b"
|
| 14 |
+
assert first.root.exists()
|
| 15 |
+
assert second.root.exists()
|
| 16 |
+
assert first.root != second.root
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_session_store_prunes_expired_sessions(tmp_path: Path) -> None:
|
| 20 |
+
store = SessionStore(root=tmp_path, ttl_seconds=0)
|
| 21 |
+
session = store.ensure_session("session-a")
|
| 22 |
+
marker = session.root / "marker.txt"
|
| 23 |
+
marker.write_text("old", encoding="utf-8")
|
| 24 |
+
|
| 25 |
+
removed = store.cleanup_expired(now=store._now() + 1)
|
| 26 |
+
|
| 27 |
+
assert removed == ["session-a"]
|
| 28 |
+
assert not session.root.exists()
|