From 8ed5202ab8563f16272dce7c2fb5ff277e78a886 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 11 Oct 2025 09:31:49 -0700 Subject: [PATCH] Add chapter and speaker preparation templates for audiobook conversion workflow - Created `prepare_chapters.html` to allow users to select and configure chapters for conversion, including options for voice profiles and chunk granularity. - Developed `prepare_speakers.html` for assigning voices to detected speakers, auditioning samples, and applying saved speaker configurations. - Implemented step indicators and navigation between chapters and speakers in the UI. - Added error and notice handling for user feedback during the preparation process. - Included scripts for voice catalog and language mapping to enhance user experience. --- abogen/web/routes.py | 15 +- abogen/web/static/prepare.js | 258 ++--------- abogen/web/static/styles.css | 94 ++++ abogen/web/templates/prepare_chapters.html | 185 ++++++++ abogen/web/templates/prepare_job.html | 501 +-------------------- abogen/web/templates/prepare_speakers.html | 408 +++++++++++++++++ 6 files changed, 732 insertions(+), 729 deletions(-) create mode 100644 abogen/web/templates/prepare_chapters.html create mode 100644 abogen/web/templates/prepare_speakers.html diff --git a/abogen/web/routes.py b/abogen/web/routes.py index f6d4fe5..95c109e 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -2613,14 +2613,25 @@ def _render_prepare_page( if request.method == "POST" else request.args.get("step") ) or "chapters" + + normalized_step = (active_step or "chapters").strip().lower() + if normalized_step not in {"chapters", "speakers"}: + normalized_step = "chapters" + + is_multi = pending.speaker_mode == "multi" + if normalized_step == "speakers" and not is_multi: + normalized_step = "chapters" + + template_name = "prepare_speakers.html" if normalized_step == "speakers" else "prepare_chapters.html" + return render_template( - "prepare_job.html", + template_name, pending=pending, options=_template_options(), settings=_load_settings(), error=error, notice=notice, - active_step=active_step, + active_step=normalized_step, ) diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 25c90f4..d73892c 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -229,246 +229,46 @@ document.addEventListener("DOMContentLoaded", () => { updatePreviewVoice(select); }); - const finalizeAction = form.getAttribute("action"); - const analyzeUrl = form.dataset.analyzeUrl || ""; const activeStepInput = form.querySelector('[data-role="active-step-input"]'); - const wizard = document.querySelector('[data-role="prepare-wizard"]'); - if (wizard) { - const stepOrder = ["chapters", "speakers"]; - const indicatorOrder = ["setup", ...stepOrder]; - const indicator = wizard.querySelector('[data-role="wizard-indicator"]'); - const indicatorSteps = indicator ? Array.from(indicator.querySelectorAll('[data-role="wizard-step"]')) : []; - const navButtons = Array.from(wizard.querySelectorAll('[data-role="prepare-step-nav"] [data-step-target]')); - const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]')); - const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]')); - const panels = new Map(); - const initialStep = wizard.dataset.initialStep || "chapters"; - const speakerModeSelect = form.querySelector("#speaker_mode"); - const speakerIndicator = indicator ? indicator.querySelector('[data-step-key="speakers"]') : null; - const speakerPanel = wizard.querySelector('[data-speaker-panel="true"]'); - const chapterActions = wizard.querySelector('[data-role="chapter-actions"]'); - const continueButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="continue"]') : null; - const finalizeButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="finalize"]') : null; - stepOrder.forEach((step) => { - const panel = wizard.querySelector(`[data-step-panel="${step}"]`); - if (panel) { - panels.set(step, panel); - const isInitial = step === initialStep; - panel.hidden = !isInitial; - panel.setAttribute("aria-hidden", isInitial ? "false" : "true"); - } - }); - - const unlockedSteps = new Set(["chapters"]); - if (initialStep === "speakers") { - unlockedSteps.add("speakers"); - } - let currentStep = initialStep; - - const shouldSkipSpeakersStep = () => { - const modeValue = (speakerModeSelect?.value || "").toLowerCase(); - if (!modeValue) { - return true; - } - return modeValue !== "multi"; - }; - - const submitFinalize = () => { - if (!form.reportValidity()) { - return; - } - if (activeStepInput) { - activeStepInput.value = currentStep; - } - if (finalizeAction) { - form.action = finalizeAction; - } - form.submit(); - }; - - const updateIndicator = (activeStep) => { - const activeIndex = indicatorOrder.indexOf(activeStep); - indicatorSteps.forEach((item) => { - const key = item.dataset.stepKey; - if (!key) return; - const index = indicatorOrder.indexOf(key); - item.classList.remove("is-active", "is-complete"); - if (index < activeIndex) { - item.classList.add("is-complete"); - } else if (index === activeIndex) { - item.classList.add("is-active"); - } - }); - }; - - const unlockStep = (step) => { - if (unlockedSteps.has(step)) { - return; - } - unlockedSteps.add(step); - navButtons.forEach((button) => { - if (button.dataset.stepTarget === step) { - button.disabled = false; - button.removeAttribute("aria-disabled"); - button.dataset.state = "unlocked"; - } - }); - }; - - const setStep = (step) => { - let targetStep = step; - if (targetStep === "speakers" && shouldSkipSpeakersStep()) { - targetStep = "chapters"; - } - if (!panels.has(targetStep)) { - return; - } - currentStep = targetStep; - wizard.dataset.activeStep = targetStep; - if (activeStepInput) { - activeStepInput.value = targetStep; - } - panels.forEach((panel, key) => { - const isActive = key === targetStep; - panel.hidden = !isActive; - panel.setAttribute("aria-hidden", isActive ? "false" : "true"); - }); - navButtons.forEach((button) => { - const target = button.dataset.stepTarget; - if (!target) return; - const isActive = target === targetStep; - button.classList.toggle("is-active", isActive); - if (button.dataset.state === "locked" && !unlockedSteps.has(target)) { - button.disabled = true; - button.setAttribute("aria-disabled", "true"); - } else { - button.disabled = false; - button.removeAttribute("aria-disabled"); - } - }); - updateIndicator(targetStep); - }; - - const updateSpeakerControls = () => { - const skipSpeakers = shouldSkipSpeakersStep(); - if (wizard) { - wizard.dataset.speakerStep = skipSpeakers ? "disabled" : "enabled"; - } - if (speakerIndicator) { - speakerIndicator.hidden = skipSpeakers; - if (skipSpeakers) { - speakerIndicator.setAttribute("aria-hidden", "true"); - } else { - speakerIndicator.removeAttribute("aria-hidden"); - } - } - if (!skipSpeakers) { - unlockStep("speakers"); - } - if (speakerPanel) { - speakerPanel.dataset.speakerEnabled = skipSpeakers ? "false" : "true"; - } - if (continueButton) { - if (skipSpeakers) { - continueButton.hidden = true; - continueButton.setAttribute("aria-hidden", "true"); - continueButton.setAttribute("tabindex", "-1"); - } else { - continueButton.hidden = false; - continueButton.removeAttribute("aria-hidden"); - continueButton.removeAttribute("tabindex"); - } - } - if (finalizeButton) { - if (skipSpeakers) { - finalizeButton.hidden = false; - finalizeButton.removeAttribute("aria-hidden"); - } else { - finalizeButton.hidden = true; - finalizeButton.setAttribute("aria-hidden", "true"); - } - } - if (skipSpeakers && currentStep === "speakers") { - setStep("chapters"); - } - }; - - const submitForAnalysis = () => { - if (!analyzeUrl) { - unlockStep("speakers"); - setStep("speakers"); - return; - } - if (!form.reportValidity()) { - return; - } + const analysisButtons = Array.from(form.querySelectorAll('[data-role="submit-speaker-analysis"]')); + analysisButtons.forEach((button) => { + button.addEventListener("click", () => { if (activeStepInput) { activeStepInput.value = "speakers"; } - if (finalizeAction) { - form.setAttribute("data-finalize-action", finalizeAction); - } - form.action = analyzeUrl; - form.submit(); - if (finalizeAction) { - window.setTimeout(() => { - form.action = finalizeAction; - }, 0); - } - }; - - navButtons.forEach((button) => { - button.addEventListener("click", () => { - if (button.disabled) { - return; - } - const target = button.dataset.stepTarget; - if (!target) return; - unlockStep(target); - setStep(target); - }); }); + }); - nextButtons.forEach((button) => { - button.addEventListener("click", () => { - const target = button.dataset.stepTarget || "speakers"; - if (target === "speakers") { - if (shouldSkipSpeakersStep()) { - submitFinalize(); - return; - } - submitForAnalysis(); - return; - } - unlockStep(target); - setStep(target); - }); - }); + const speakerModeSelect = form.querySelector("#speaker_mode"); + const analysisToggleButtons = Array.from(form.querySelectorAll('[data-step-toggle="analysis"]')); + const finalizeToggleButtons = Array.from(form.querySelectorAll('[data-step-toggle="finalize"]')); - prevButtons.forEach((button) => { - button.addEventListener("click", () => { - const target = button.dataset.stepTarget || "chapters"; - setStep(target); - }); - }); - - if (speakerModeSelect) { - speakerModeSelect.addEventListener("change", () => { - updateSpeakerControls(); - }); + const setButtonVisibility = (button, isVisible) => { + if (!button) return; + if (isVisible) { + button.hidden = false; + button.removeAttribute("aria-hidden"); + button.removeAttribute("tabindex"); + } else { + button.hidden = true; + button.setAttribute("aria-hidden", "true"); + button.setAttribute("tabindex", "-1"); } + }; - if (finalizeButton) { - finalizeButton.addEventListener("click", (event) => { - if (shouldSkipSpeakersStep()) { - event.preventDefault(); - submitFinalize(); - } - }); + const updateStepButtons = () => { + if (!speakerModeSelect) { + return; } + const modeValue = (speakerModeSelect.value || "").toLowerCase(); + const isMulti = modeValue === "multi"; + analysisToggleButtons.forEach((button) => setButtonVisibility(button, isMulti)); + finalizeToggleButtons.forEach((button) => setButtonVisibility(button, !isMulti)); + }; - setStep(currentStep); - updateSpeakerControls(); + if (speakerModeSelect) { + updateStepButtons(); + speakerModeSelect.addEventListener("change", updateStepButtons); } const voiceModal = document.querySelector('[data-role="voice-modal"]'); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 534b74b..257ecb0 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -515,6 +515,89 @@ body { } } + .wizard-page { + display: flex; + justify-content: center; + padding: 3rem 0 5rem; + } + + .wizard-card { + width: min(1100px, calc(100% - 2.5rem)); + margin: 0 auto; + } + + .wizard-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 2rem; + } + + .wizard-card__aside { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-end; + text-align: right; + max-width: 320px; + } + + .wizard-card__links { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: flex-end; + } + + .wizard-card__filename { + font-size: 0.85rem; + color: var(--muted); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .wizard-card__body { + display: flex; + flex-direction: column; + gap: 2rem; + } + + .wizard-card__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + } + + .wizard-card__footer-actions { + display: flex; + gap: 0.75rem; + align-items: center; + } + + .wizard-hidden-inputs { + display: none; + } + + @media (max-width: 900px) { + .wizard-card__header { + flex-direction: column; + align-items: stretch; + } + + .wizard-card__aside { + align-items: flex-start; + text-align: left; + } + + .wizard-card__links { + justify-content: flex-start; + } + } + .step-indicator { display: flex; flex-wrap: wrap; @@ -1310,6 +1393,17 @@ body { margin-bottom: 2rem; align-items: start; } +.form-section__title-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} +.form-section__title-row .form-section__title { + margin-bottom: 0; +} .prepare-summary__stats dl { margin: 0; diff --git a/abogen/web/templates/prepare_chapters.html b/abogen/web/templates/prepare_chapters.html new file mode 100644 index 0000000..ec84cb1 --- /dev/null +++ b/abogen/web/templates/prepare_chapters.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} + +{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} + +{% set is_multi_speaker = pending.speaker_mode == 'multi' %} + +{% block content %} +
+
+ +
+ + + +
+ + +
+
+
+
+
+{% endblock %} + +{% block scripts %} + {{ super() }} + + + + +{% endblock %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 5354f70..345c96c 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -1,498 +1,3 @@ -{% extends "base.html" %} - -{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} - -{% block content %} -{% set wizard_step = (active_step or 'chapters') %} -{% set is_multi_speaker = pending.speaker_mode == 'multi' %} -{% if wizard_step == 'speakers' and not is_multi_speaker %} - {% set wizard_step = 'chapters' %} -{% endif %} -{% set is_chapters = wizard_step == 'chapters' %} -{% set is_speakers = wizard_step == 'speakers' %} -{% set hide_speakers_panel = not is_speakers %} -
-
- - 1 - Upload & settings - - - 2 - Chapters - - -
-
Prepare audiobook
-

Review the detected chapters, tune the speaker roster, and optionally override the voice per chapter before sending the job to the queue.

- - {% set analysis = pending.speaker_analysis or {} %} - {% set analysis_speakers = analysis.get('speakers', {}) %} - {% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %} - {% set has_metadata = pending.metadata_tags %} - {% set roster = pending.speakers or {} %} - {% set speaker_configs = options.speaker_configs or [] %} - {% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %} - {% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} - -
- - - {% if error %} -
{{ error }}
- {% endif %} - {% if notice %} -
{{ notice }}
- {% endif %} - -
- - - -
-
-
- -
-{% endblock %} - -{% block scripts %} - {{ super() }} - - - - -{% endblock %} +{# This template is intentionally left empty. + Step-specific templates now live in `prepare_chapters.html` and `prepare_speakers.html`. + The file is kept as a placeholder to avoid breaking documentation references. #} diff --git a/abogen/web/templates/prepare_speakers.html b/abogen/web/templates/prepare_speakers.html new file mode 100644 index 0000000..97858c1 --- /dev/null +++ b/abogen/web/templates/prepare_speakers.html @@ -0,0 +1,408 @@ +{% 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." %} + +{% block content %} +
+
+ + +
+ + + + + + +
+ + +
+
+
+ + +
+
+{% endblock %} + +{% block scripts %} + {{ super() }} + + + + +{% endblock %}