mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Refactor job preparation templates and routes
- Removed `prepare_chapters.html` and `prepare_entities.html` templates as they are no longer needed. - Updated `routes.py` to remove references to the removed templates and adjusted job preparation logic. - Simplified response handling in job preparation routes by removing unnecessary parameters. - Consolidated job preparation logic to improve maintainability and clarity.
This commit is contained in:
+17
-74
@@ -18,6 +18,7 @@ from xml.etree import ElementTree as ET
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
jsonify,
|
||||
@@ -28,10 +29,13 @@ from flask import (
|
||||
url_for,
|
||||
)
|
||||
from flask.typing import ResponseReturnValue
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SAMPLE_VOICE_TEXTS,
|
||||
@@ -40,11 +44,9 @@ from abogen.constants import (
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.chunking import ChunkLevel, build_chunks_for_chapters
|
||||
from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
|
||||
from abogen.utils import (
|
||||
calculate_text_length,
|
||||
clean_text,
|
||||
get_user_output_path,
|
||||
load_config,
|
||||
load_numpy_kpipeline,
|
||||
@@ -2825,8 +2827,8 @@ def enqueue_job() -> ResponseReturnValue:
|
||||
if isinstance(languages, list):
|
||||
pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)]
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(pending, "chapters", embed_scripts=False)
|
||||
return redirect(url_for("web.prepare_job", pending_id=pending.id))
|
||||
return _wizard_json_response(pending, "chapters")
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
|
||||
@web_bp.get("/jobs/prepare/<pending_id>")
|
||||
@@ -2835,8 +2837,8 @@ def prepare_job(pending_id: str) -> ResponseReturnValue:
|
||||
requested_step = request.args.get("step") or "chapters"
|
||||
normalized_step = _normalize_wizard_step(requested_step, pending)
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(pending, normalized_step, embed_scripts=False)
|
||||
return _render_prepare_page(pending, active_step=normalized_step)
|
||||
return _wizard_json_response(pending, normalized_step)
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
|
||||
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
|
||||
@@ -2862,10 +2864,9 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending,
|
||||
"chapters",
|
||||
error=message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(pending, error=message, active_step="chapters")
|
||||
abort(400, message)
|
||||
|
||||
if pending.speaker_mode != "multi":
|
||||
setattr(pending, "analysis_requested", False)
|
||||
@@ -2877,14 +2878,9 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending,
|
||||
"chapters",
|
||||
error=error_message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error=error_message,
|
||||
active_step="chapters",
|
||||
)
|
||||
abort(400, error_message)
|
||||
|
||||
if not enabled_overrides:
|
||||
setattr(pending, "analysis_requested", False)
|
||||
@@ -2896,14 +2892,9 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending,
|
||||
"chapters",
|
||||
error=error_message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error=error_message,
|
||||
active_step="chapters",
|
||||
)
|
||||
abort(400, error_message)
|
||||
|
||||
raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal)
|
||||
analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence")
|
||||
@@ -2957,9 +2948,8 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending,
|
||||
"entities",
|
||||
notice=notice_message,
|
||||
embed_scripts=False,
|
||||
)
|
||||
return _render_prepare_page(pending, notice=notice_message, active_step="entities")
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
|
||||
@web_bp.post("/jobs/prepare/<pending_id>")
|
||||
@@ -2987,14 +2977,9 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending,
|
||||
normalized_step,
|
||||
error=message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error=message,
|
||||
active_step=normalized_step,
|
||||
)
|
||||
abort(400, message)
|
||||
|
||||
if pending.speaker_mode != "multi":
|
||||
setattr(pending, "analysis_requested", False)
|
||||
@@ -3007,14 +2992,9 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending,
|
||||
"chapters",
|
||||
error=error_message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error=error_message,
|
||||
active_step="chapters",
|
||||
)
|
||||
abort(400, error_message)
|
||||
|
||||
active_step = (request.form.get("active_step") or "chapters").strip().lower()
|
||||
if active_step == "speakers":
|
||||
@@ -3091,13 +3071,8 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending,
|
||||
"entities",
|
||||
notice=notice_message or None,
|
||||
embed_scripts=False,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
notice=notice_message or None,
|
||||
active_step="entities",
|
||||
)
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
total_characters = selected_total or pending.total_characters
|
||||
service.pop_pending_job(pending_id)
|
||||
@@ -3233,7 +3208,6 @@ def _render_wizard_partial(
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
notice: Optional[str] = None,
|
||||
embed_scripts: bool = False,
|
||||
) -> str:
|
||||
templates = {
|
||||
"book": "partials/new_job_step_book.html",
|
||||
@@ -3248,7 +3222,6 @@ def _render_wizard_partial(
|
||||
"settings": _load_settings(),
|
||||
"error": error,
|
||||
"notice": notice,
|
||||
"embed_scripts": embed_scripts,
|
||||
}
|
||||
return render_template(template_name, **context)
|
||||
|
||||
@@ -3288,43 +3261,13 @@ def _wizard_json_response(
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
notice: Optional[str] = None,
|
||||
embed_scripts: bool = False,
|
||||
status: int = 200,
|
||||
) -> ResponseReturnValue:
|
||||
html = _render_wizard_partial(pending, step, error=error, notice=notice, embed_scripts=embed_scripts)
|
||||
html = _render_wizard_partial(pending, step, error=error, notice=notice)
|
||||
payload = _wizard_step_payload(pending, step, html, error=error, notice=notice)
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
def _render_prepare_page(
|
||||
pending: PendingJob,
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
notice: Optional[str] = None,
|
||||
active_step: Optional[str] = None,
|
||||
) -> str:
|
||||
if not active_step:
|
||||
active_step = (
|
||||
request.form.get("active_step")
|
||||
if request.method == "POST"
|
||||
else request.args.get("step")
|
||||
) or "chapters"
|
||||
|
||||
normalized_step = _normalize_wizard_step(active_step, pending)
|
||||
|
||||
template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html"
|
||||
|
||||
return render_template(
|
||||
template_name,
|
||||
pending=pending,
|
||||
options=_template_options(),
|
||||
settings=_load_settings(),
|
||||
error=error,
|
||||
notice=notice,
|
||||
active_step=normalized_step,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.get("/jobs/<job_id>")
|
||||
def job_detail(job_id: str) -> str:
|
||||
job = _service().get_job(job_id)
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
||||
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
{% set total_steps = 3 if is_multi_speaker else 2 %}
|
||||
|
||||
{% block content %}
|
||||
<section class="wizard-page wizard-page--modal" data-step="chapters">
|
||||
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
|
||||
<div class="modal__overlay" aria-hidden="true"></div>
|
||||
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-chapters-title">
|
||||
<header class="modal__header wizard-card__header">
|
||||
<div class="wizard-card__headline">
|
||||
<nav class="step-indicator" aria-label="Audiobook workflow">
|
||||
<button type="button"
|
||||
class="step-indicator__item is-complete"
|
||||
data-role="open-upload-modal">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload & settings</span>
|
||||
</button>
|
||||
<a class="step-indicator__item is-active"
|
||||
aria-current="step"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Chapters</span>
|
||||
</a>
|
||||
{% if is_multi_speaker %}
|
||||
<a class="step-indicator__item"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Entities</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2>
|
||||
<p class="hint">Choose which chapters to convert. We'll analyse entities automatically when you continue.</p>
|
||||
</div>
|
||||
<div class="wizard-card__aside">
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-speaker-mode="{{ pending.speaker_mode }}"
|
||||
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}">
|
||||
<input type="hidden" name="active_step" value="chapters" data-role="active-step-input">
|
||||
<div class="wizard-hidden-inputs" aria-hidden="true">
|
||||
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
|
||||
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
|
||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
{% if pending.generate_epub3 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="form-section__title-row">
|
||||
<h3 class="form-section__title">Detected chapters</h3>
|
||||
<p class="hint">Toggle chapters on or off, rename them, and override the voice per chapter if needed.</p>
|
||||
</div>
|
||||
<div class="chapter-grid">
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
<article class="chapter-card"
|
||||
data-role="chapter-row"
|
||||
data-disabled="{{ 'false' if is_enabled else 'true' }}"
|
||||
data-expanded="false">
|
||||
<header class="chapter-card__summary" data-role="chapter-summary">
|
||||
<label class="chapter-card__checkbox">
|
||||
<input type="checkbox"
|
||||
name="chapter-{{ loop.index0 }}-enabled"
|
||||
data-role="chapter-enabled"
|
||||
{% if is_enabled %}checked{% endif %}>
|
||||
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="chapter-card__toggle"
|
||||
data-role="chapter-toggle"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle chapter details">
|
||||
<span class="chapter-card__toggle-icon" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="chapter-card__details"
|
||||
data-role="chapter-details">
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-title">Title</label>
|
||||
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
</div>
|
||||
<div class="chapter-card__preview">
|
||||
<details>
|
||||
<summary>Preview full text</summary>
|
||||
<pre>{{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="chapter-card__field">
|
||||
<label for="chapter-{{ loop.index0 }}-voice">Voice override</label>
|
||||
<select id="chapter-{{ loop.index0 }}-voice" name="chapter-{{ loop.index0 }}-voice" data-role="voice-select">
|
||||
<option value="__default" {% if selected_option == '__default' %}selected{% endif %}>Use job default</option>
|
||||
<optgroup label="Voices">
|
||||
{% for voice in options.voices %}
|
||||
<option value="voice:{{ voice }}" {% if selected_option == 'voice:' ~ voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Profiles">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="profile:{{ profile.name }}" {% if selected_option == 'profile:' ~ profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} · {{ profile.language|upper }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="formula" {% if selected_option == 'formula' %}selected{% endif %}>Custom formula…</option>
|
||||
</select>
|
||||
<input type="text"
|
||||
name="chapter-{{ loop.index0 }}-formula"
|
||||
class="chapter-card__formula"
|
||||
data-role="formula-input"
|
||||
placeholder="af_nova*0.4+am_liam*0.6"
|
||||
value="{{ chapter.voice_formula or '' }}"
|
||||
{% if selected_option != 'formula' %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="button" class="button button--ghost" data-role="wizard-previous">Previous</button>
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
{% if is_multi_speaker %}
|
||||
<button type="submit"
|
||||
class="button"
|
||||
data-role="submit-speaker-analysis"
|
||||
data-step-toggle="analysis"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post">
|
||||
Continue to entities
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="submit" class="button" data-step-toggle="finalize"{% if is_multi_speaker %} hidden aria-hidden="true"{% endif %}>
|
||||
Queue conversion
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% with pending=pending, readonly=True, active_step='chapters' %}
|
||||
{% include "partials/upload_modal.html" %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,579 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
|
||||
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
{% set analysis = pending.speaker_analysis or {} %}
|
||||
{% set analysis_speakers = analysis.get('speakers', {}) %}
|
||||
{% set speaker_configs = options.speaker_configs or [] %}
|
||||
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
|
||||
{% set total_steps = 3 if is_multi_speaker else 2 %}
|
||||
|
||||
{% block content %}
|
||||
<section class="wizard-page wizard-page--modal" data-step="entities">
|
||||
<div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
|
||||
<div class="modal__overlay" aria-hidden="true"></div>
|
||||
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-entities-title">
|
||||
<header class="modal__header wizard-card__header">
|
||||
<div class="wizard-card__headline">
|
||||
<nav class="step-indicator" aria-label="Audiobook workflow">
|
||||
<button type="button"
|
||||
class="step-indicator__item is-complete"
|
||||
data-role="open-upload-modal">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload & settings</span>
|
||||
</button>
|
||||
<a class="step-indicator__item is-complete"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Chapters</span>
|
||||
</a>
|
||||
{% if is_multi_speaker %}
|
||||
<a class="step-indicator__item is-active"
|
||||
aria-current="step"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Entities</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<h2 class="modal__title" id="prepare-entities-title">Review entities</h2>
|
||||
<p class="hint">Assign voices, tune pronunciations, and curate manual overrides before queueing the conversion.</p>
|
||||
</div>
|
||||
<div class="wizard-card__aside">
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-speaker-mode="{{ pending.speaker_mode }}"
|
||||
data-analyze-url="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
data-entities-url="{{ url_for('api.api_pending_entities', pending_id=pending.id) }}"
|
||||
data-manual-list-url="{{ url_for('api.api_list_manual_overrides', pending_id=pending.id) }}"
|
||||
data-manual-upsert-url="{{ url_for('api.api_upsert_manual_override', pending_id=pending.id) }}"
|
||||
data-manual-delete-url-template="{{ url_for('api.api_delete_manual_override', pending_id=pending.id, override_id='__OVERRIDE_ID__') }}"
|
||||
data-manual-search-url="{{ url_for('api.api_search_manual_override_candidates', pending_id=pending.id) }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-base-voice="{{ pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
<input type="hidden" name="active_step" value="entities" data-role="active-step-input">
|
||||
|
||||
<div class="wizard-hidden-inputs" aria-hidden="true">
|
||||
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
|
||||
<input type="hidden" name="speaker_mode" value="{{ pending.speaker_mode }}">
|
||||
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
|
||||
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
|
||||
{% if pending.generate_epub3 %}
|
||||
<input type="hidden" name="generate_epub3" value="true">
|
||||
{% endif %}
|
||||
{% for chapter in pending.chapters %}
|
||||
{% set selected_option = '__default' %}
|
||||
{% if chapter.voice_profile %}
|
||||
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
|
||||
{% elif chapter.voice %}
|
||||
{% set selected_option = 'voice:' ~ chapter.voice %}
|
||||
{% elif chapter.voice_formula %}
|
||||
{% set selected_option = 'formula' %}
|
||||
{% endif %}
|
||||
{% if chapter.enabled %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-enabled" value="on">
|
||||
{% endif %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-voice" value="{{ selected_option }}">
|
||||
{% if chapter.voice_formula %}
|
||||
<input type="hidden" name="chapter-{{ loop.index0 }}-formula" value="{{ chapter.voice_formula }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% elif pending.speaker_mode != 'multi' %}
|
||||
<div class="alert alert--info">Multi-speaker mode is disabled. Switch back to chapters to enable additional voices.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="entity-tabs" data-role="entity-tabs">
|
||||
<div class="entity-tabs__nav" role="tablist" aria-label="Entity categories">
|
||||
<button type="button"
|
||||
class="entity-tabs__tab is-active"
|
||||
data-role="entity-tab"
|
||||
data-panel="people"
|
||||
id="entity-tab-people"
|
||||
aria-controls="entity-panel-people"
|
||||
aria-selected="true"
|
||||
role="tab">
|
||||
People
|
||||
</button>
|
||||
<button type="button"
|
||||
class="entity-tabs__tab"
|
||||
data-role="entity-tab"
|
||||
data-panel="entities"
|
||||
id="entity-tab-entities"
|
||||
aria-controls="entity-panel-entities"
|
||||
aria-selected="false"
|
||||
role="tab">
|
||||
Entities
|
||||
</button>
|
||||
<button type="button"
|
||||
class="entity-tabs__tab"
|
||||
data-role="entity-tab"
|
||||
data-panel="manual"
|
||||
id="entity-tab-manual"
|
||||
aria-controls="entity-panel-manual"
|
||||
aria-selected="false"
|
||||
role="tab">
|
||||
Manual Overrides
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="entity-tabs__panels">
|
||||
<section class="entity-tabs__panel is-active"
|
||||
data-role="entity-panel"
|
||||
data-panel="people"
|
||||
id="entity-panel-people"
|
||||
role="tabpanel"
|
||||
aria-labelledby="entity-tab-people">
|
||||
<section class="prepare-speaker-config">
|
||||
<div class="prepare-speaker-config__header">
|
||||
<h2>Speaker configuration</h2>
|
||||
<p class="hint">Reuse saved presets to keep character voices consistent between projects.</p>
|
||||
</div>
|
||||
<div class="prepare-speaker-config__grid">
|
||||
<label class="field prepare-speaker-config__field" for="applied_speaker_config">
|
||||
<span>Saved preset</span>
|
||||
<select id="applied_speaker_config" name="applied_speaker_config">
|
||||
<option value="">None</option>
|
||||
{% for config in speaker_configs %}
|
||||
<option value="{{ config.name }}" {% if pending.applied_speaker_config == config.name %}selected{% endif %}>
|
||||
{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Presets are managed on the <a href="{{ url_for('web.speaker_configs_page') }}">Speakers page</a>.</p>
|
||||
</label>
|
||||
<div class="prepare-speaker-config__actions">
|
||||
<button type="submit"
|
||||
class="button button--ghost"
|
||||
name="apply_speaker_config"
|
||||
data-role="submit-speaker-analysis"
|
||||
value="1"
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if not speaker_configs %}disabled{% endif %}>
|
||||
Apply preset
|
||||
</button>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="save_speaker_config" value="1">
|
||||
<span>Save roster updates back to preset</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if pending.speakers %}
|
||||
<div class="prepare-speakers">
|
||||
<h2>Speaker settings</h2>
|
||||
<p class="hint">Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.</p>
|
||||
<ul class="speaker-list">
|
||||
{% for speaker_id, speaker in pending.speakers.items() %}
|
||||
{% set pronunciation_text = speaker.pronunciation or speaker.label %}
|
||||
{% set selected_voice = speaker.resolved_voice or speaker.voice %}
|
||||
{% set seen = namespace(values=[]) %}
|
||||
{% set sample_quotes = speaker.sample_quotes or [] %}
|
||||
{% set detected_gender = speaker.detected_gender or speaker.gender or 'unknown' %}
|
||||
{% set current_gender = speaker.gender or detected_gender %}
|
||||
{% set gender_label = 'Either' if current_gender == 'either' else (current_gender|title if current_gender != 'unknown' else 'Unknown') %}
|
||||
{% set detected_label = 'Either' if detected_gender == 'either' else (detected_gender|title if detected_gender != 'unknown' else 'Unknown') %}
|
||||
<li class="speaker-list__item"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-default-pronunciation="{{ pronunciation_text }}">
|
||||
<div class="speaker-line speaker-list__header">
|
||||
<span class="speaker-list__name">{{ speaker.label }}</span>
|
||||
<button type="button"
|
||||
class="icon-button speaker-list__preview"
|
||||
data-role="speaker-preview"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-source="pronunciation"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
aria-label="Preview pronunciation for {{ speaker.label }}"
|
||||
title="Preview pronunciation">
|
||||
🔊
|
||||
</button>
|
||||
</div>
|
||||
<template data-role="speaker-samples">{{ sample_quotes | tojson }}</template>
|
||||
<div class="speaker-list__meta">
|
||||
<div class="speaker-gender" data-role="speaker-gender" data-speaker-id="{{ speaker_id }}">
|
||||
<button type="button"
|
||||
class="chip speaker-gender__pill"
|
||||
data-role="gender-pill"
|
||||
data-current="{{ current_gender }}">
|
||||
{{ gender_label }} voice
|
||||
</button>
|
||||
<div class="speaker-gender__menu" data-role="gender-menu" hidden>
|
||||
<p class="hint">Detected: {{ detected_label }}</p>
|
||||
<div class="speaker-gender__options">
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="female">Female</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="male">Male</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="either">Either</button>
|
||||
<button type="button" class="chip" data-role="gender-option" data-value="{{ detected_gender }}" {% if detected_gender == current_gender %}data-state="active"{% endif %}>
|
||||
Use detected ({{ detected_label }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-gender" value="{{ current_gender }}" data-role="gender-input">
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-detected-gender" value="{{ detected_gender }}">
|
||||
</div>
|
||||
{% if speaker.get('analysis_count') %}
|
||||
<span class="badge badge--muted">{{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-pronunciation">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text"
|
||||
id="speaker-{{ speaker_id }}-pronunciation"
|
||||
name="speaker-{{ speaker_id }}-pronunciation"
|
||||
value="{{ pronunciation_text }}"
|
||||
data-role="speaker-pronunciation"
|
||||
placeholder="{{ speaker.label }}">
|
||||
</label>
|
||||
<div class="speaker-list__controls">
|
||||
<div class="speaker-list__selection">
|
||||
<label class="speaker-list__field" for="speaker-{{ speaker_id }}-voice">
|
||||
<span>Assigned voice</span>
|
||||
<select id="speaker-{{ speaker_id }}-voice"
|
||||
name="speaker-{{ speaker_id }}-voice"
|
||||
data-role="speaker-voice"
|
||||
data-default-voice="{{ pending.voice }}">
|
||||
<option value="" {% if not selected_voice %}selected{% endif %}>Use narrator voice ({{ pending.voice }})</option>
|
||||
<option value="__custom_mix" data-role="custom-mix-option" {% if speaker.voice_formula %}selected{% else %}hidden disabled{% endif %}>
|
||||
Custom mix
|
||||
</option>
|
||||
{% if speaker.recommended_voices %}
|
||||
<optgroup label="Recommended">
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% if voice_id not in seen.values %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<option value="{{ voice_id }}" {% if selected_voice == voice_id %}selected{% endif %}>
|
||||
{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}
|
||||
</option>
|
||||
{% set _ = seen.values.append(voice_id) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<optgroup label="All voices">
|
||||
{% for voice in options.voice_catalog %}
|
||||
{% if voice.id not in seen.values %}
|
||||
<option value="{{ voice.id }}"
|
||||
{% if selected_voice == voice.id %}selected{% endif %}>
|
||||
{{ voice.display_name }} · {{ voice.language_label }} · {{ voice.gender }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Browse voices
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="generate-voice"
|
||||
data-speaker-id="{{ speaker_id }}">
|
||||
Generate voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-kind="generated"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-source="generated"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
{% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
Preview generated
|
||||
</button>
|
||||
</div>
|
||||
<div class="speaker-list__mix" data-role="speaker-mix" {% if not speaker.voice_formula %}hidden{% endif %}>
|
||||
<span class="tag">Custom mix</span>
|
||||
<span data-role="speaker-mix-label">{{ speaker.voice_formula or '' }}</span>
|
||||
<div class="speaker-list__mix-actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-source="mix"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ pronunciation_text|e }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ speaker.voice_formula or selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview mix
|
||||
</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="clear-mix">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="speaker-{{ speaker_id }}-formula" value="{{ speaker.voice_formula or '' }}" data-role="speaker-formula">
|
||||
</div>
|
||||
<details class="speaker-list__samples" {% if not sample_quotes %}data-state="empty"{% endif %}>
|
||||
<summary>Sample paragraphs</summary>
|
||||
{% if sample_quotes %}
|
||||
{% set first_sample = sample_quotes[0] if sample_quotes|length > 0 else None %}
|
||||
{% set first_excerpt = first_sample.excerpt if first_sample is mapping else first_sample %}
|
||||
{% set first_hint = first_sample.gender_hint if first_sample is mapping else '' %}
|
||||
<article class="speaker-sample" data-role="speaker-sample">
|
||||
<p data-role="sample-text">{{ first_excerpt }}</p>
|
||||
<p class="hint" data-role="sample-hint" {% if not first_hint %}hidden{% endif %}>{{ first_hint }}</p>
|
||||
<div class="speaker-sample__actions">
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-preview"
|
||||
data-preview-source="sample"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-preview-text="{{ first_excerpt }}"
|
||||
data-language="{{ pending.language }}"
|
||||
data-voice="{{ selected_voice or pending.voice }}"
|
||||
data-speed="{{ '%.2f'|format(pending.speed) }}"
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}">
|
||||
Preview with assigned voice
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="open-voice-browser"
|
||||
data-speaker-id="{{ speaker_id }}"
|
||||
data-sample-index="0">
|
||||
Preview in voice browser
|
||||
</button>
|
||||
{% if sample_quotes|length > 1 %}
|
||||
<button type="button"
|
||||
class="button button--ghost button--small"
|
||||
data-role="speaker-next-sample">
|
||||
Show another example
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="hint">No paragraphs captured yet. Continue from Step 2 to gather dialogue samples automatically.</p>
|
||||
{% endif %}
|
||||
</details>
|
||||
{% if speaker.recommended_voices %}
|
||||
<div class="speaker-list__recommendations">
|
||||
<span class="hint">Suggested:</span>
|
||||
{% for voice_id in speaker.recommended_voices[:6] %}
|
||||
{% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %}
|
||||
<button type="button"
|
||||
class="chip"
|
||||
data-role="recommended-voice"
|
||||
data-voice="{{ voice_id }}"
|
||||
title="{{ voice_meta.display_name or voice_id }} · {{ voice_meta.language_label or voice_id[0]|upper }} · {{ voice_meta.gender or 'Unknown' }}">
|
||||
{{ voice_meta.display_name or voice_id }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">No additional speakers detected yet. The narrator voice will be used for all dialogue.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="entity-tabs__panel"
|
||||
data-role="entity-panel"
|
||||
data-panel="entities"
|
||||
id="entity-panel-entities"
|
||||
role="tabpanel"
|
||||
aria-labelledby="entity-tab-entities"
|
||||
hidden>
|
||||
<div class="entity-summary" data-role="entity-summary">
|
||||
<header class="entity-summary__header">
|
||||
<h2>Entity insights</h2>
|
||||
<div class="entity-summary__actions">
|
||||
<button type="button" class="button button--ghost" data-role="entities-refresh">Refresh</button>
|
||||
<button type="button" class="button button--ghost" data-role="entities-download" hidden>Download CSV</button>
|
||||
</div>
|
||||
</header>
|
||||
<p class="hint">Review organisations, locations, and other proper nouns detected in the manuscript. Add manual overrides to customise pronunciations where needed.</p>
|
||||
<div class="entity-summary__stats" data-role="entity-stats"></div>
|
||||
<ul class="entity-summary__list" data-role="entity-list"></ul>
|
||||
<template data-role="entity-row-template">
|
||||
<li class="entity-summary__item" data-entity-id="">
|
||||
<header class="entity-summary__item-head">
|
||||
<div>
|
||||
<span class="entity-summary__label" data-role="entity-label"></span>
|
||||
<span class="entity-summary__kind" data-role="entity-kind"></span>
|
||||
</div>
|
||||
<div class="entity-summary__meta">
|
||||
<span class="badge badge--muted" data-role="entity-count"></span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entity-add-override">Add manual override</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="entity-summary__samples" data-role="entity-samples"></div>
|
||||
</li>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="entity-tabs__panel"
|
||||
data-role="entity-panel"
|
||||
data-panel="manual"
|
||||
id="entity-panel-manual"
|
||||
role="tabpanel"
|
||||
aria-labelledby="entity-tab-manual"
|
||||
hidden>
|
||||
<div class="manual-overrides" data-role="manual-overrides">
|
||||
<header class="manual-overrides__header">
|
||||
<h2>Manual overrides</h2>
|
||||
<p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p>
|
||||
</header>
|
||||
<div class="manual-overrides__search">
|
||||
<label class="field" for="manual-override-query">
|
||||
<span>Search manuscript tokens</span>
|
||||
<input type="search" id="manual-override-query" data-role="manual-override-query" placeholder="Search by name or phrase">
|
||||
</label>
|
||||
<div class="manual-overrides__search-actions">
|
||||
<button type="button" class="button button--ghost" data-role="manual-override-search">Search</button>
|
||||
<button type="button" class="button button--ghost" data-role="manual-override-add-custom">Add custom token</button>
|
||||
</div>
|
||||
<ul class="manual-overrides__results" data-role="manual-override-results"></ul>
|
||||
</div>
|
||||
<div class="manual-overrides__list" data-role="manual-override-list"></div>
|
||||
<template data-role="manual-override-template">
|
||||
<article class="manual-override" data-override-id="">
|
||||
<header class="manual-override__header">
|
||||
<div>
|
||||
<h3 class="manual-override__label" data-role="override-label"></h3>
|
||||
<p class="manual-override__notes" data-role="override-notes"></p>
|
||||
</div>
|
||||
<div class="manual-override__actions">
|
||||
<button type="button" class="button button--ghost button--small" data-role="speaker-preview" data-preview-source="manual">Preview</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="manual-override-delete">Remove</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="manual-override__body">
|
||||
<label class="field" data-role="manual-override-pronunciation-label">
|
||||
<span>Pronunciation</span>
|
||||
<input type="text" data-role="manual-override-pronunciation" value="">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Assigned voice</span>
|
||||
<select data-role="manual-override-voice" data-default-voice="{{ pending.voice }}">
|
||||
<option value="">Use narrator voice ({{ pending.voice }})</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="manual-override__meta" data-role="manual-override-meta"></div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<p class="manual-overrides__empty" data-role="manual-overrides-empty" hidden>No overrides yet. Use search or add a custom entry to begin.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<a class="button button--ghost" href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">Previous</a>
|
||||
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
|
||||
<div class="modal" data-role="voice-modal" hidden>
|
||||
<div class="modal__overlay" data-role="voice-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content voice-browser" role="dialog" aria-modal="true" aria-labelledby="voice-modal-title">
|
||||
<header class="voice-browser__header">
|
||||
<h2 id="voice-modal-title">Choose a voice</h2>
|
||||
<button type="button" class="icon-button" data-role="voice-modal-close" aria-label="Close voice browser">✕</button>
|
||||
</header>
|
||||
<div class="voice-browser__body">
|
||||
<aside class="voice-browser__filters">
|
||||
<label class="field">
|
||||
<span>Search</span>
|
||||
<input type="search" data-role="voice-modal-search" placeholder="Search by name or language">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Language</span>
|
||||
<select data-role="voice-modal-language">
|
||||
<option value="">All languages</option>
|
||||
{% for code, label in options.languages.items() %}
|
||||
<option value="{{ code }}">{{ label }} ({{ code|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="voice-browser__gender" role="group" aria-label="Filter by gender">
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="">All</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="f">Female</button>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-modal-gender" data-value="m">Male</button>
|
||||
</div>
|
||||
<button type="button" class="button button--ghost" data-role="voice-modal-clear">Clear selection</button>
|
||||
</aside>
|
||||
<section class="voice-browser__catalog" aria-label="Voice catalog">
|
||||
<ul class="voice-browser__list" data-role="voice-modal-list"></ul>
|
||||
</section>
|
||||
<section class="voice-browser__mix" data-role="voice-modal-mix">
|
||||
<header class="voice-browser__mix-header">
|
||||
<h3>Selected voices</h3>
|
||||
<p class="tag" data-role="voice-modal-mix-total">Total weight: 0.00</p>
|
||||
</header>
|
||||
<div class="voice-browser__mix-list" data-role="voice-modal-mix-list"></div>
|
||||
<section class="voice-browser__preview" data-role="voice-modal-preview">
|
||||
<h3 data-role="voice-modal-selected-name">Select a voice to preview</h3>
|
||||
<p class="tag" data-role="voice-modal-selected-meta"></p>
|
||||
<div class="voice-browser__samples" data-role="voice-modal-samples"></div>
|
||||
<div class="voice-browser__actions">
|
||||
<button type="button" class="button" data-role="voice-modal-apply" disabled>Apply mix</button>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% with pending=pending, readonly=True, active_step='entities' %}
|
||||
{% include "partials/upload_modal.html" %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script id="voice-sample-texts" type="application/json">{{ options.sample_voice_texts | tojson }}</script>
|
||||
<script id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
<script id="entity-summary-data" type="application/json">{{ pending.entity_summary or {} | tojson }}</script>
|
||||
<script id="entity-cache-key" type="application/json">{{ pending.entity_cache_key or '' | tojson }}</script>
|
||||
<script id="manual-overrides-data" type="application/json">{{ pending.manual_overrides or [] | tojson }}</script>
|
||||
<script id="pronunciation-overrides-data" type="application/json">{{ pending.pronunciation_overrides or [] | tojson }}</script>
|
||||
<script type="module" src="{{ url_for('static', filename='speakers.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='prepare.js') }}"></script>
|
||||
<script type="module" src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,3 +0,0 @@
|
||||
{# This template is intentionally left empty.
|
||||
Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`.
|
||||
The file is kept as a placeholder to avoid breaking documentation references. #}
|
||||
Reference in New Issue
Block a user