mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Enhance upload modal and chapter preparation templates for improved user experience
This commit is contained in:
@@ -188,6 +188,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const enabled = row.querySelector('[data-role=chapter-enabled]');
|
||||
const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea"));
|
||||
const warning = row.querySelector('[data-role=chapter-warning]');
|
||||
const snippet = row.querySelector('[data-role="chapter-snippet"]');
|
||||
const details = row.querySelector('[data-role="chapter-details"]');
|
||||
const isChecked = enabled ? enabled.checked : true;
|
||||
row.dataset.disabled = isChecked ? "false" : "true";
|
||||
|
||||
@@ -212,6 +214,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const select = row.querySelector("select[data-role=voice-select]");
|
||||
toggleFormula(select);
|
||||
|
||||
if (snippet) {
|
||||
snippet.hidden = !isChecked;
|
||||
snippet.setAttribute("aria-hidden", isChecked ? "false" : "true");
|
||||
}
|
||||
|
||||
if (details) {
|
||||
details.hidden = !isChecked;
|
||||
details.setAttribute("aria-hidden", isChecked ? "false" : "true");
|
||||
}
|
||||
|
||||
if (warning) {
|
||||
warning.hidden = isChecked;
|
||||
warning.setAttribute("aria-hidden", isChecked ? "true" : "false");
|
||||
@@ -337,7 +349,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
|
||||
const speakerItems = Array.from(form.querySelectorAll(".speaker-list__item"));
|
||||
speakerItems.forEach((item) => initialiseSpeakerItem(item));
|
||||
speakerItems.forEach((item) => {
|
||||
initialiseSpeakerItem(item);
|
||||
const pronunciationInput = item.querySelector('[data-role="speaker-pronunciation"]');
|
||||
if (pronunciationInput) {
|
||||
const sync = () => syncPronunciationPreview(item);
|
||||
pronunciationInput.addEventListener("input", sync);
|
||||
pronunciationInput.addEventListener("change", sync);
|
||||
}
|
||||
});
|
||||
|
||||
const activeStepInput = form.querySelector('[data-role="active-step-input"]');
|
||||
const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]'));
|
||||
|
||||
@@ -26,6 +26,21 @@ const stopCurrentPlayback = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePreviewText = (button) => {
|
||||
const source = (button.dataset.previewSource || "").toLowerCase();
|
||||
if (source === "pronunciation") {
|
||||
const container = button.closest(".speaker-list__item");
|
||||
if (container) {
|
||||
const input = container.querySelector('[data-role="speaker-pronunciation"]');
|
||||
const fallback = (container.dataset.defaultPronunciation || "").trim();
|
||||
const value = (input?.value || "").trim() || fallback;
|
||||
button.dataset.previewText = value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return (button.dataset.previewText || "").trim();
|
||||
};
|
||||
|
||||
audioElement.addEventListener("ended", () => {
|
||||
stopCurrentPlayback();
|
||||
});
|
||||
@@ -37,7 +52,7 @@ audioElement.addEventListener("pause", () => {
|
||||
});
|
||||
|
||||
const playPreview = async (button) => {
|
||||
const text = (button.dataset.previewText || "").trim();
|
||||
const text = resolvePreviewText(button);
|
||||
const voice = (button.dataset.voice || "").trim();
|
||||
const language = (button.dataset.language || "a").trim() || "a";
|
||||
const speedRaw = button.dataset.speed || "1";
|
||||
|
||||
@@ -618,6 +618,16 @@ body {
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
button.step-indicator__item {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button.step-indicator__item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.step-indicator__index {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
|
||||
@@ -17,163 +17,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="modal" data-role="upload-modal" hidden>
|
||||
<div class="modal__overlay" data-role="upload-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
|
||||
<header class="modal__header">
|
||||
<div class="modal__heading">
|
||||
<p class="modal__eyebrow">Step 1 of 3</p>
|
||||
<h2 class="modal__title" id="upload-modal-title">Upload & settings</h2>
|
||||
<p class="hint">Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.</p>
|
||||
</div>
|
||||
<button type="button" class="icon-button" data-role="upload-modal-close" aria-label="Close upload settings">✕</button>
|
||||
</header>
|
||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="upload-form">
|
||||
<div class="modal__body upload-form__sections">
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Manuscript & subtitles</h3>
|
||||
<div class="form-section__layout">
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field field--file">
|
||||
<label for="source_file">Source file</label>
|
||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field">
|
||||
<label for="subtitle_mode">Subtitle mode</label>
|
||||
<select id="subtitle_mode" name="subtitle_mode">
|
||||
<option value="Disabled">Disabled</option>
|
||||
<option value="Sentence">Sentence</option>
|
||||
<option value="Sentence + Comma">Sentence + Comma</option>
|
||||
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<span class="field__caption">Additional outputs</span>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="generate_epub3" value="true" {% if settings.generate_epub3 %}checked{% endif %}>
|
||||
<span>Generate EPUB 3 (experimental)</span>
|
||||
</label>
|
||||
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Narrator defaults</h3>
|
||||
<div class="form-section__layout form-section__layout--split">
|
||||
<div class="form-section__group">
|
||||
<div class="field">
|
||||
<label for="voice_profile">Voice profile</label>
|
||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile">
|
||||
<option value="__standard" {% if not options.voice_profile_options %}selected{% endif %}>Standard voice</option>
|
||||
<option value="__formula">Custom voice formula</option>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved mixes">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if loop.first %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-role="voice-field" {% if options.voice_profile_options %}hidden aria-hidden="true"{% endif %}>
|
||||
<label for="voice">Voice</label>
|
||||
<select id="voice" name="voice" data-role="voice-select" data-default="{{ settings.default_voice }}">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}" {% if settings.default_voice == voice %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-conditional="formula" data-role="formula-field" hidden aria-hidden="true">
|
||||
<label for="voice_formula">Custom voice formula</label>
|
||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section__group">
|
||||
<div class="field field--slider">
|
||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||
</div>
|
||||
<div class="field field--with-action field--preview" data-role="voice-preview">
|
||||
<div class="field__label-row">
|
||||
<span class="field__label">Preview</span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-preview-button">Preview voice</button>
|
||||
</div>
|
||||
<p class="hint field__status" data-role="voice-preview-status" aria-live="polite" hidden></p>
|
||||
<audio class="voice-preview__audio" data-role="voice-preview-audio" controls preload="none" hidden></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Speakers & casting</h3>
|
||||
<div class="form-section__layout">
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field field--stack">
|
||||
<label for="speaker_config">Speaker preset</label>
|
||||
<select id="speaker_config" name="speaker_config">
|
||||
<option value="">None</option>
|
||||
{% for config in options.speaker_configs %}
|
||||
<option value="{{ config.name }}">{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Reuse a saved roster to keep character voices consistent.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_mode">Speaker mode</label>
|
||||
<select id="speaker_mode" name="speaker_mode">
|
||||
{% for option in options.speaker_modes %}
|
||||
<option value="{{ option.value }}" {% if settings.speaker_mode == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field">
|
||||
<label for="chunk_level">Chunk granularity</label>
|
||||
<select id="chunk_level" name="chunk_level">
|
||||
{% for option in options.chunk_levels %}
|
||||
<option value="{{ option.value }}" {% if settings.chunk_level == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
|
||||
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ settings.speaker_analysis_threshold }}">
|
||||
<p class="hint">Speakers appearing less often fall back to the narrator voice.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(settings.chapter_intro_delay) }}">
|
||||
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="modal__footer">
|
||||
<button type="button" class="button button--ghost" data-role="upload-modal-close">Cancel</button>
|
||||
<button type="submit" class="button">Chapter Selection</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include "partials/upload_modal.html" with pending=None, readonly=False, active_step='settings' %}
|
||||
|
||||
{% include "partials/reader_modal.html" %}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
{% set pending = pending if pending is defined else None %}
|
||||
{% set readonly = readonly if readonly is defined else False %}
|
||||
{% set modal_step = (active_step if active_step is defined else 'settings') or 'settings' %}
|
||||
{% set settings_dict = settings if settings is defined else {} %}
|
||||
{% set is_multi = False %}
|
||||
{% if pending %}
|
||||
{% set is_multi = pending.speaker_mode == 'multi' %}
|
||||
{% elif settings_dict %}
|
||||
{% set is_multi = settings_dict.get('speaker_mode', 'single') == 'multi' %}
|
||||
{% endif %}
|
||||
{% set language_value = pending.language if pending else settings_dict.get('language', '') %}
|
||||
{% if not language_value %}
|
||||
{% set sorted_languages = options.languages|dictsort %}
|
||||
{% if sorted_languages %}
|
||||
{% set language_value = sorted_languages[0][0] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set subtitle_value = pending.subtitle_mode if pending else settings_dict.get('subtitle_mode', 'Disabled') %}
|
||||
{% set generate_epub3 = pending.generate_epub3 if pending else settings_dict.get('generate_epub3', False) %}
|
||||
{% set speaker_mode_value = pending.speaker_mode if pending else settings_dict.get('speaker_mode', 'single') %}
|
||||
{% set chunk_level_value = pending.chunk_level if pending else settings_dict.get('chunk_level', 'paragraph') %}
|
||||
{% set analysis_threshold_value = pending.speaker_analysis_threshold if pending else settings_dict.get('speaker_analysis_threshold', 3) %}
|
||||
{% set chapter_delay_value = pending.chapter_intro_delay if pending else settings_dict.get('chapter_intro_delay', 0.5) %}
|
||||
{% set selected_config = pending.applied_speaker_config if pending else '' %}
|
||||
{% set narrator_voice = pending.voice if pending else settings_dict.get('default_voice', options.voices[0] if options.voices else '') %}
|
||||
{% set narrator_profile = pending.voice_profile if pending else '' %}
|
||||
{% set narrator_speed = pending.speed if pending else 1.0 %}
|
||||
{% set voice_formula_value = '' %}
|
||||
{% set profile_value = '__standard' %}
|
||||
{% if narrator_profile %}
|
||||
{% set profile_value = narrator_profile %}
|
||||
{% else %}
|
||||
{% if narrator_voice and ('+' in narrator_voice or '*' in narrator_voice) %}
|
||||
{% set profile_value = '__formula' %}
|
||||
{% set voice_formula_value = narrator_voice %}
|
||||
{% else %}
|
||||
{% set profile_value = '__standard' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not narrator_voice and options.voices %}
|
||||
{% set narrator_voice = options.voices[0] %}
|
||||
{% endif %}
|
||||
{% set speed_display = '%.2f'|format(narrator_speed) %}
|
||||
{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'speakers'] else '') %}
|
||||
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'speakers' else '') %}
|
||||
{% set step3_state = 'is-active' if modal_step == 'speakers' else '' %}
|
||||
<div class="modal" data-role="upload-modal" hidden>
|
||||
<div class="modal__overlay" data-role="upload-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
|
||||
<header class="modal__header wizard-card__header">
|
||||
<div class="wizard-card__headline">
|
||||
<nav class="step-indicator" aria-label="Audiobook workflow">
|
||||
<span class="step-indicator__item {{ step1_state }}">
|
||||
<span class="step-indicator__index">1</span>
|
||||
<span class="step-indicator__label">Upload & settings</span>
|
||||
</span>
|
||||
<span class="step-indicator__item {{ step2_state }}">
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Chapters</span>
|
||||
</span>
|
||||
{% if is_multi %}
|
||||
<span class="step-indicator__item {{ step3_state }}">
|
||||
<span class="step-indicator__index">3</span>
|
||||
<span class="step-indicator__label">Speakers</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<p class="modal__eyebrow">Step 1 of 3</p>
|
||||
<h2 class="modal__title" id="upload-modal-title">Upload & settings</h2>
|
||||
<p class="hint">Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.</p>
|
||||
</div>
|
||||
<div class="wizard-card__aside">
|
||||
<button type="button" class="icon-button wizard-card__close" data-role="upload-modal-close" aria-label="Close upload settings">✕</button>
|
||||
{% if pending %}
|
||||
<p class="wizard-card__filename" title="{{ pending.original_filename }}">{{ pending.original_filename }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<form action="{{ url_for('web.enqueue_job') if not readonly else '#' }}"
|
||||
method="post"
|
||||
{% if not readonly %}enctype="multipart/form-data"{% endif %}
|
||||
class="upload-form">
|
||||
<div class="modal__body wizard-card__body upload-form__sections">
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Manuscript & subtitles</h3>
|
||||
<div class="form-section__layout">
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field field--file">
|
||||
<label for="source_file">Source file</label>
|
||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown" {{ 'disabled' if readonly else '' }}>
|
||||
{% if pending %}
|
||||
<p class="hint">Current file: {{ pending.original_filename }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language" {{ 'disabled' if readonly else '' }}>
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}" {% if key == language_value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field">
|
||||
<label for="subtitle_mode">Subtitle mode</label>
|
||||
<select id="subtitle_mode" name="subtitle_mode" {{ 'disabled' if readonly else '' }}>
|
||||
{% set subtitle_options = ['Disabled', 'Sentence', 'Sentence + Comma', 'Sentence + Highlighting'] %}
|
||||
{% for option in subtitle_options %}
|
||||
<option value="{{ option }}" {% if subtitle_value == option %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
{% for i in range(1, 11) %}
|
||||
{% set label = i ~ ' ' ~ ('word' if i == 1 else 'words') %}
|
||||
<option value="{{ label }}" {% if subtitle_value == label %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<span class="field__caption">Additional outputs</span>
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="generate_epub3" value="true" {% if generate_epub3 %}checked{% endif %} {{ 'disabled' if readonly else '' }}>
|
||||
<span>Generate EPUB 3 (experimental)</span>
|
||||
</label>
|
||||
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Narrator defaults</h3>
|
||||
<div class="form-section__layout form-section__layout--split">
|
||||
<div class="form-section__group">
|
||||
<div class="field">
|
||||
<label for="voice_profile">Voice profile</label>
|
||||
<select id="voice_profile" name="voice_profile" data-role="voice-profile" {{ 'disabled' if readonly else '' }}>
|
||||
<option value="__standard" {% if profile_value == '__standard' %}selected{% endif %}>Standard voice</option>
|
||||
<option value="__formula" {% if profile_value == '__formula' %}selected{% endif %}>Custom voice formula</option>
|
||||
{% if options.voice_profile_options %}
|
||||
<optgroup label="Saved mixes">
|
||||
{% for profile in options.voice_profile_options %}
|
||||
<option value="{{ profile.name }}" data-language="{{ profile.language }}" data-formula="{{ profile.formula|e }}" {% if profile_value == profile.name %}selected{% endif %}>{{ profile.name }}{% if profile.language %} ({{ profile.language|upper }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-role="voice-field" {% if profile_value != '__standard' %}hidden aria-hidden="true"{% endif %}>
|
||||
<label for="voice">Voice</label>
|
||||
<select id="voice" name="voice" data-role="voice-select" data-default="{{ narrator_voice or settings_dict.get('default_voice', '') }}" {{ 'disabled' if readonly else '' }}>
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}" {% if narrator_voice == voice and profile_value == '__standard' %}selected{% endif %}>{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-conditional="formula" data-role="formula-field" {% if profile_value != '__formula' %}hidden aria-hidden="true"{% endif %}>
|
||||
<label for="voice_formula">Custom voice formula</label>
|
||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6" data-role="voice-formula" value="{{ voice_formula_value }}" {{ 'disabled' if readonly else '' }}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section__group">
|
||||
<div class="field field--slider">
|
||||
<label for="speed">Speed <span class="tag" id="speed_value">{{ speed_display }}×</span></label>
|
||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="{{ speed_display }}" {{ 'disabled' if readonly else '' }} oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||
</div>
|
||||
<div class="field field--with-action field--preview" data-role="voice-preview">
|
||||
<div class="field__label-row">
|
||||
<span class="field__label">Preview</span>
|
||||
<button type="button" class="button button--ghost button--small" data-role="voice-preview-button" {{ 'disabled' if readonly else '' }}>Preview voice</button>
|
||||
</div>
|
||||
<p class="hint field__status" data-role="voice-preview-status" aria-live="polite" hidden></p>
|
||||
<audio class="voice-preview__audio" data-role="voice-preview-audio" controls preload="none" hidden></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Speakers & casting</h3>
|
||||
<div class="form-section__layout">
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field field--stack">
|
||||
<label for="speaker_config">Speaker preset</label>
|
||||
<select id="speaker_config" name="speaker_config" {{ 'disabled' if readonly else '' }}>
|
||||
<option value="">None</option>
|
||||
{% for config in options.speaker_configs %}
|
||||
<option value="{{ config.name }}" {% if selected_config == config.name %}selected{% endif %}>{{ config.name }} · {{ config.speakers|length }} speaker{% if config.speakers|length != 1 %}s{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Reuse a saved roster to keep character voices consistent.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_mode">Speaker mode</label>
|
||||
<select id="speaker_mode" name="speaker_mode" {{ 'disabled' if readonly else '' }}>
|
||||
{% for option in options.speaker_modes %}
|
||||
<option value="{{ option.value }}" {% if speaker_mode_value == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid field-grid--two">
|
||||
<div class="field">
|
||||
<label for="chunk_level">Chunk granularity</label>
|
||||
<select id="chunk_level" name="chunk_level" {{ 'disabled' if readonly else '' }}>
|
||||
{% for option in options.chunk_levels %}
|
||||
<option value="{{ option.value }}" {% if chunk_level_value == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="hint">Paragraphs work well for long-form narration; sentences give finer subtitle sync.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_analysis_threshold">Speaker analysis minimum mentions</label>
|
||||
<input type="number" min="1" max="25" id="speaker_analysis_threshold" name="speaker_analysis_threshold" value="{{ analysis_threshold_value }}" {{ 'disabled' if readonly else '' }}>
|
||||
<p class="hint">Speakers appearing less often fall back to the narrator voice.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="chapter_intro_delay">Pause after chapter titles (seconds)</label>
|
||||
<input type="number" step="0.1" min="0" id="chapter_intro_delay" name="chapter_intro_delay" value="{{ '%.2f'|format(chapter_delay_value) }}" {{ 'disabled' if readonly else '' }}>
|
||||
<p class="hint">Set to 0 to disable the pause after speaking each chapter title.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer class="modal__footer wizard-card__footer">
|
||||
<button type="button" class="button button--ghost" data-role="upload-modal-close">Cancel</button>
|
||||
{% if readonly %}
|
||||
<button type="button" class="button" data-role="upload-modal-close">Close</button>
|
||||
{% else %}
|
||||
<button type="submit" class="button">Chapter Selection</button>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,10 +10,12 @@
|
||||
<header class="modal__header wizard-card__header">
|
||||
<div class="wizard-card__headline">
|
||||
<nav class="step-indicator" aria-label="Audiobook workflow">
|
||||
<a class="step-indicator__item is-complete" href="{{ url_for('web.index') }}">
|
||||
<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>
|
||||
</a>
|
||||
</button>
|
||||
<a class="step-indicator__item is-active"
|
||||
aria-current="step"
|
||||
href="{{ url_for('web.prepare_job', pending_id=pending.id, step='chapters') }}">
|
||||
@@ -90,11 +92,15 @@
|
||||
{% if is_enabled %}checked{% endif %}>
|
||||
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
|
||||
</label>
|
||||
<p class="chapter-card__snippet">
|
||||
<p class="chapter-card__snippet"
|
||||
data-role="chapter-snippet"
|
||||
{% if not is_enabled %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
{{ excerpt }}{% if raw_excerpt|length > 240 %}…{% endif %}
|
||||
</p>
|
||||
</header>
|
||||
<div class="chapter-card__details" data-role="chapter-details">
|
||||
<div class="chapter-card__details"
|
||||
data-role="chapter-details"
|
||||
{% if not is_enabled %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}>
|
||||
<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 }}">
|
||||
@@ -165,12 +171,15 @@
|
||||
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
|
||||
</div>
|
||||
</section>
|
||||
{% include "partials/upload_modal.html" with pending=pending, readonly=True, active_step='chapters' %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
<header class="modal__header wizard-card__header">
|
||||
<div class="wizard-card__headline">
|
||||
<nav class="step-indicator" aria-label="Audiobook workflow">
|
||||
<a class="step-indicator__item is-complete" href="{{ url_for('web.index') }}">
|
||||
<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>
|
||||
</a>
|
||||
</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>
|
||||
@@ -409,12 +411,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% include "partials/upload_modal.html" with pending=pending, readonly=True, active_step='speakers' %}
|
||||
{% 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 %}
|
||||
|
||||
Reference in New Issue
Block a user