mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Add new job step templates for book, chapters, and entities
- Implemented `new_job_step_book.html` to handle manuscript and subtitle settings, including language selection, subtitle mode, and narrator defaults. - Created `new_job_step_chapters.html` for managing detected chapters, allowing users to toggle chapters, rename them, and set voice overrides. - Developed `new_job_step_entities.html` to configure speaker settings, manage entity pronunciations, and handle manual overrides for voice assignments. - Enhanced user experience with dynamic forms and validation, ensuring smooth navigation through the job creation process.
This commit is contained in:
+292
-19
@@ -59,6 +59,7 @@ from abogen.entity_analysis import (
|
||||
from abogen.pronunciation_store import (
|
||||
delete_override as delete_pronunciation_override,
|
||||
load_overrides as load_pronunciation_overrides,
|
||||
all_overrides as all_pronunciation_overrides,
|
||||
save_override as save_pronunciation_override,
|
||||
search_overrides as search_pronunciation_overrides,
|
||||
)
|
||||
@@ -115,6 +116,26 @@ _SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS}
|
||||
_DEFAULT_ANALYSIS_THRESHOLD = 3
|
||||
|
||||
|
||||
_WIZARD_STEP_ORDER = ["book", "chapters", "entities"]
|
||||
_WIZARD_STEP_META = {
|
||||
"book": {
|
||||
"index": 1,
|
||||
"title": "Book parameters",
|
||||
"hint": "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.",
|
||||
},
|
||||
"chapters": {
|
||||
"index": 2,
|
||||
"title": "Select chapters",
|
||||
"hint": "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
|
||||
},
|
||||
"entities": {
|
||||
"index": 3,
|
||||
"title": "Review entities",
|
||||
"hint": "Assign pronunciations, voices, and manual overrides before queueing the conversion.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _coerce_path(value: Any) -> Optional[Path]:
|
||||
if isinstance(value, Path):
|
||||
return value
|
||||
@@ -2029,6 +2050,88 @@ def voice_profiles_page() -> str:
|
||||
return render_template("voices.html", options=options)
|
||||
|
||||
|
||||
@web_bp.get("/entities")
|
||||
def entities_page() -> ResponseReturnValue:
|
||||
options = _template_options()
|
||||
settings = _load_settings()
|
||||
languages_map = options.get("languages", {})
|
||||
|
||||
raw_language = (request.args.get("lang") or settings.get("language") or "a").strip().lower()
|
||||
language = raw_language if raw_language in languages_map else "a"
|
||||
|
||||
query = (request.args.get("q") or "").strip()
|
||||
voice_filter = (request.args.get("voice") or "all").strip().lower()
|
||||
pronunciation_filter = (request.args.get("pronunciation") or "all").strip().lower()
|
||||
limit_value = _coerce_int(request.args.get("limit"), 200, minimum=10, maximum=500)
|
||||
|
||||
if query:
|
||||
overrides = search_pronunciation_overrides(language, query, limit=limit_value)
|
||||
else:
|
||||
overrides = all_pronunciation_overrides(language)
|
||||
if limit_value and len(overrides) > limit_value:
|
||||
overrides = overrides[:limit_value]
|
||||
|
||||
display_rows: List[Dict[str, Any]] = []
|
||||
for entry in overrides:
|
||||
has_voice = bool((entry.get("voice") or "").strip())
|
||||
has_pronunciation = bool((entry.get("pronunciation") or "").strip())
|
||||
if voice_filter == "with-voice" and not has_voice:
|
||||
continue
|
||||
if voice_filter == "without-voice" and has_voice:
|
||||
continue
|
||||
if pronunciation_filter == "with-pronunciation" and not has_pronunciation:
|
||||
continue
|
||||
if pronunciation_filter == "without-pronunciation" and has_pronunciation:
|
||||
continue
|
||||
row = dict(entry)
|
||||
row["has_voice"] = has_voice
|
||||
row["has_pronunciation"] = has_pronunciation
|
||||
try:
|
||||
updated_dt = datetime.fromtimestamp(float(entry.get("updated_at") or 0))
|
||||
created_dt = datetime.fromtimestamp(float(entry.get("created_at") or 0))
|
||||
except (TypeError, ValueError):
|
||||
updated_dt = datetime.fromtimestamp(0)
|
||||
created_dt = datetime.fromtimestamp(0)
|
||||
row["updated_at_label"] = updated_dt.strftime("%Y-%m-%d %H:%M")
|
||||
row["created_at_label"] = created_dt.strftime("%Y-%m-%d %H:%M")
|
||||
display_rows.append(row)
|
||||
|
||||
stats = {
|
||||
"total": len(overrides),
|
||||
"filtered": len(display_rows),
|
||||
"with_voice": sum(1 for row in display_rows if row["has_voice"]),
|
||||
"with_pronunciation": sum(1 for row in display_rows if row["has_pronunciation"]),
|
||||
}
|
||||
|
||||
language_options = sorted(languages_map.items(), key=lambda item: item[1])
|
||||
voice_filters = [
|
||||
{"value": "all", "label": "All voices"},
|
||||
{"value": "with-voice", "label": "Assigned voice"},
|
||||
{"value": "without-voice", "label": "No voice"},
|
||||
]
|
||||
pronunciation_filters = [
|
||||
{"value": "all", "label": "All pronunciations"},
|
||||
{"value": "with-pronunciation", "label": "Has pronunciation"},
|
||||
{"value": "without-pronunciation", "label": "No pronunciation"},
|
||||
]
|
||||
|
||||
context = {
|
||||
"options": options,
|
||||
"language": language,
|
||||
"language_label": languages_map.get(language, language.upper()),
|
||||
"languages": language_options,
|
||||
"query": query,
|
||||
"voice_filter": voice_filter,
|
||||
"pronunciation_filter": pronunciation_filter,
|
||||
"voice_filter_options": voice_filters,
|
||||
"pronunciation_filter_options": pronunciation_filters,
|
||||
"limit": limit_value,
|
||||
"overrides": display_rows,
|
||||
"stats": stats,
|
||||
}
|
||||
return render_template("entities.html", **context)
|
||||
|
||||
|
||||
@web_bp.route("/speakers", methods=["GET", "POST"])
|
||||
def speaker_configs_page() -> ResponseReturnValue:
|
||||
options = _template_options()
|
||||
@@ -2721,13 +2824,19 @@ def enqueue_job() -> ResponseReturnValue:
|
||||
languages = speaker_config_payload.get("languages")
|
||||
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))
|
||||
|
||||
|
||||
@web_bp.get("/jobs/prepare/<pending_id>")
|
||||
def prepare_job(pending_id: str) -> str:
|
||||
def prepare_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending = _require_pending_job(pending_id)
|
||||
return _render_prepare_page(pending, active_step="chapters")
|
||||
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)
|
||||
|
||||
|
||||
@web_bp.post("/jobs/prepare/<pending_id>/analyze")
|
||||
@@ -2747,15 +2856,33 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
) = _apply_prepare_form(pending, request.form)
|
||||
|
||||
if errors:
|
||||
return _render_prepare_page(pending, error=" ".join(errors), active_step="chapters")
|
||||
message = " ".join(errors)
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(
|
||||
pending,
|
||||
"chapters",
|
||||
error=message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(pending, error=message, active_step="chapters")
|
||||
|
||||
if pending.speaker_mode != "multi":
|
||||
setattr(pending, "analysis_requested", False)
|
||||
pending.chunks = []
|
||||
pending.speaker_analysis = {}
|
||||
error_message = "Switch to multi-speaker mode to analyze speakers."
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(
|
||||
pending,
|
||||
"chapters",
|
||||
error=error_message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error="Switch to multi-speaker mode to analyze speakers.",
|
||||
error=error_message,
|
||||
active_step="chapters",
|
||||
)
|
||||
|
||||
@@ -2763,9 +2890,18 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
setattr(pending, "analysis_requested", False)
|
||||
pending.chunks = []
|
||||
pending.speaker_analysis = {}
|
||||
error_message = "Select at least one chapter to analyze."
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(
|
||||
pending,
|
||||
"chapters",
|
||||
error=error_message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error="Select at least one chapter to analyze.",
|
||||
error=error_message,
|
||||
active_step="chapters",
|
||||
)
|
||||
|
||||
@@ -2816,6 +2952,13 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
notice_message = "Entity insights updated."
|
||||
if persist_config_requested and config_name:
|
||||
notice_message = "Entity insights updated and configuration saved."
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(
|
||||
pending,
|
||||
"entities",
|
||||
notice=notice_message,
|
||||
embed_scripts=False,
|
||||
)
|
||||
return _render_prepare_page(pending, notice=notice_message, active_step="entities")
|
||||
|
||||
|
||||
@@ -2836,10 +2979,21 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
) = _apply_prepare_form(pending, request.form)
|
||||
|
||||
if errors:
|
||||
active_hint = request.form.get("active_step") or "entities"
|
||||
normalized_step = _normalize_wizard_step(active_hint, pending)
|
||||
message = " ".join(errors)
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(
|
||||
pending,
|
||||
normalized_step,
|
||||
error=message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error=" ".join(errors),
|
||||
active_step=request.form.get("active_step") or "entities",
|
||||
error=message,
|
||||
active_step=normalized_step,
|
||||
)
|
||||
|
||||
if pending.speaker_mode != "multi":
|
||||
@@ -2847,9 +3001,18 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
|
||||
if not enabled_overrides:
|
||||
pending.chunks = []
|
||||
error_message = "Select at least one chapter to convert."
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(
|
||||
pending,
|
||||
"chapters",
|
||||
error=error_message,
|
||||
embed_scripts=False,
|
||||
status=400,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
error="Select at least one chapter to convert.",
|
||||
error=error_message,
|
||||
active_step="chapters",
|
||||
)
|
||||
|
||||
@@ -2916,6 +3079,13 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
if persist_config_requested and config_key:
|
||||
notice_message = "Configuration saved. Review entity settings before queuing."
|
||||
service.store_pending_job(pending)
|
||||
if _wants_wizard_json():
|
||||
return _wizard_json_response(
|
||||
pending,
|
||||
"entities",
|
||||
notice=notice_message,
|
||||
embed_scripts=False,
|
||||
)
|
||||
return _render_prepare_page(
|
||||
pending,
|
||||
notice=notice_message,
|
||||
@@ -2972,7 +3142,10 @@ def finalize_job(pending_id: str) -> ResponseReturnValue:
|
||||
if isinstance(config_key, str) and config_key:
|
||||
job.applied_speaker_config = config_key
|
||||
|
||||
return redirect(url_for("web.index", _anchor="queue"))
|
||||
redirect_url = url_for("web.index", _anchor="queue")
|
||||
if _wants_wizard_json():
|
||||
return jsonify({"redirect_url": redirect_url})
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
@web_bp.post("/jobs/prepare/<pending_id>/cancel")
|
||||
@@ -2988,7 +3161,10 @@ def cancel_pending_job(pending_id: str) -> ResponseReturnValue:
|
||||
pending.cover_image_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return redirect(url_for("web.index", _anchor="queue"))
|
||||
redirect_url = url_for("web.index", _anchor="queue")
|
||||
if _wants_wizard_json():
|
||||
return jsonify({"cancelled": True, "redirect_url": redirect_url})
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
def _render_jobs_panel() -> str:
|
||||
@@ -3008,6 +3184,111 @@ def _render_jobs_panel() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_wizard_step(step: Optional[str], pending: Optional[PendingJob] = None) -> str:
|
||||
if pending is None:
|
||||
default_step = "book"
|
||||
else:
|
||||
default_step = "chapters"
|
||||
if not step:
|
||||
chosen = default_step
|
||||
else:
|
||||
normalized = step.strip().lower()
|
||||
if normalized in {"", "upload", "settings"}:
|
||||
chosen = default_step
|
||||
elif normalized == "speakers":
|
||||
chosen = "entities"
|
||||
elif normalized in _WIZARD_STEP_ORDER:
|
||||
chosen = normalized
|
||||
else:
|
||||
chosen = default_step
|
||||
if chosen == "entities" and pending is not None and pending.speaker_mode != "multi":
|
||||
return "chapters"
|
||||
return chosen
|
||||
|
||||
|
||||
def _wants_wizard_json() -> bool:
|
||||
format_hint = request.args.get("format", "").strip().lower()
|
||||
if format_hint == "json":
|
||||
return True
|
||||
accept_header = (request.headers.get("Accept") or "").lower()
|
||||
if "application/json" in accept_header:
|
||||
return True
|
||||
requested_with = (request.headers.get("X-Requested-With") or "").lower()
|
||||
if requested_with in {"xmlhttprequest", "fetch"}:
|
||||
return True
|
||||
wizard_header = (request.headers.get("X-Abogen-Wizard") or "").lower()
|
||||
return wizard_header == "json"
|
||||
|
||||
|
||||
def _render_wizard_partial(
|
||||
pending: Optional[PendingJob],
|
||||
step: str,
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
notice: Optional[str] = None,
|
||||
embed_scripts: bool = False,
|
||||
) -> str:
|
||||
templates = {
|
||||
"book": "partials/new_job_step_book.html",
|
||||
"chapters": "partials/new_job_step_chapters.html",
|
||||
"entities": "partials/new_job_step_entities.html",
|
||||
}
|
||||
template_name = templates[step]
|
||||
context: Dict[str, Any] = {
|
||||
"pending": pending,
|
||||
"readonly": False,
|
||||
"options": _template_options(),
|
||||
"settings": _load_settings(),
|
||||
"error": error,
|
||||
"notice": notice,
|
||||
"embed_scripts": embed_scripts,
|
||||
}
|
||||
return render_template(template_name, **context)
|
||||
|
||||
|
||||
def _wizard_step_payload(
|
||||
pending: Optional[PendingJob],
|
||||
step: str,
|
||||
html: str,
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
notice: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
meta = _WIZARD_STEP_META.get(step, {})
|
||||
try:
|
||||
active_index = _WIZARD_STEP_ORDER.index(step)
|
||||
except ValueError:
|
||||
active_index = 0
|
||||
completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx < active_index]
|
||||
return {
|
||||
"step": step,
|
||||
"step_index": int(meta.get("index", active_index + 1)),
|
||||
"total_steps": len(_WIZARD_STEP_ORDER),
|
||||
"title": meta.get("title", ""),
|
||||
"hint": meta.get("hint", ""),
|
||||
"html": html,
|
||||
"completed_steps": completed,
|
||||
"pending_id": pending.id if pending else "",
|
||||
"filename": pending.original_filename if pending and pending.original_filename else "",
|
||||
"error": error or "",
|
||||
"notice": notice or "",
|
||||
}
|
||||
|
||||
|
||||
def _wizard_json_response(
|
||||
pending: Optional[PendingJob],
|
||||
step: str,
|
||||
*,
|
||||
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)
|
||||
payload = _wizard_step_payload(pending, step, html, error=error, notice=notice)
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
def _render_prepare_page(
|
||||
pending: PendingJob,
|
||||
*,
|
||||
@@ -3022,15 +3303,7 @@ def _render_prepare_page(
|
||||
else request.args.get("step")
|
||||
) or "chapters"
|
||||
|
||||
normalized_step = (active_step or "chapters").strip().lower()
|
||||
if normalized_step == "speakers":
|
||||
normalized_step = "entities"
|
||||
if normalized_step not in {"chapters", "entities"}:
|
||||
normalized_step = "chapters"
|
||||
|
||||
is_multi = pending.speaker_mode == "multi"
|
||||
if normalized_step == "entities" and not is_multi:
|
||||
normalized_step = "chapters"
|
||||
normalized_step = _normalize_wizard_step(active_step, pending)
|
||||
|
||||
template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html"
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { initReaderUI } from "./reader.js";
|
||||
import { initWizard } from "./wizard.js";
|
||||
|
||||
const dashboardState = (window.AbogenDashboardState = window.AbogenDashboardState || {
|
||||
boundKeydown: false,
|
||||
boundBeforeUnload: false,
|
||||
});
|
||||
|
||||
const initDashboard = () => {
|
||||
const uploadModal = document.querySelector('[data-role="upload-modal"]');
|
||||
const uploadModal =
|
||||
document.querySelector('[data-role="new-job-modal"]') ||
|
||||
document.querySelector('[data-role="upload-modal"]');
|
||||
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
|
||||
const scope = uploadModal || document;
|
||||
const sourceFileInput = scope.querySelector('#source_file');
|
||||
@@ -138,22 +146,35 @@ const initDashboard = () => {
|
||||
};
|
||||
|
||||
openModalButtons.forEach((button) => {
|
||||
if (!button || button.dataset.dashboardBound === "true") {
|
||||
return;
|
||||
}
|
||||
button.dataset.dashboardBound = "true";
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
openUploadModal(button);
|
||||
});
|
||||
});
|
||||
|
||||
if (uploadModal) {
|
||||
if (uploadModal && uploadModal.dataset.dashboardCloseBound !== "true") {
|
||||
uploadModal.dataset.dashboardCloseBound = "true";
|
||||
uploadModal.addEventListener("click", (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest('[data-role="upload-modal-close"]')) {
|
||||
if (
|
||||
target instanceof Element &&
|
||||
(target.closest('[data-role="new-job-modal-close"]') ||
|
||||
target.closest('[data-role="upload-modal-close"]') ||
|
||||
target.closest('[data-role="wizard-close"]') ||
|
||||
target.closest('[data-role="wizard-cancel"]'))
|
||||
) {
|
||||
event.preventDefault();
|
||||
closeUploadModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!dashboardState.boundKeydown) {
|
||||
dashboardState.boundKeydown = true;
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
if (uploadModal && !uploadModal.hidden) {
|
||||
@@ -162,11 +183,15 @@ const initDashboard = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initReaderUI({ onBeforeOpen: closeUploadModal });
|
||||
|
||||
if (sourceFileInput) {
|
||||
if (sourceFileInput.dataset.dashboardChangeBound !== "true") {
|
||||
sourceFileInput.dataset.dashboardChangeBound = "true";
|
||||
sourceFileInput.addEventListener("change", updateDropzoneFilename);
|
||||
}
|
||||
updateDropzoneFilename();
|
||||
} else {
|
||||
setDropzoneStatus("");
|
||||
@@ -366,14 +391,16 @@ const initDashboard = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (previewButton) {
|
||||
if (previewButton && previewButton.dataset.dashboardBound !== "true") {
|
||||
previewButton.dataset.dashboardBound = "true";
|
||||
previewButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
handleVoicePreview();
|
||||
});
|
||||
}
|
||||
|
||||
if (dropzone) {
|
||||
if (dropzone && dropzone.dataset.dashboardDragBound !== "true") {
|
||||
dropzone.dataset.dashboardDragBound = "true";
|
||||
let dragDepth = 0;
|
||||
|
||||
dropzone.addEventListener("dragenter", (event) => {
|
||||
@@ -536,7 +563,10 @@ const initDashboard = () => {
|
||||
|
||||
if (profileSelect) {
|
||||
const hasSaved = selectFirstProfileIfAvailable();
|
||||
if (profileSelect.dataset.dashboardBound !== "true") {
|
||||
profileSelect.dataset.dashboardBound = "true";
|
||||
profileSelect.addEventListener("change", updateVoiceControls);
|
||||
}
|
||||
updateVoiceControls();
|
||||
if (!hasSaved) {
|
||||
hydrateDefaultVoice();
|
||||
@@ -545,14 +575,25 @@ const initDashboard = () => {
|
||||
hydrateDefaultVoice();
|
||||
}
|
||||
|
||||
if (!dashboardState.boundBeforeUnload) {
|
||||
dashboardState.boundBeforeUnload = true;
|
||||
window.addEventListener("beforeunload", () => {
|
||||
cancelPreviewRequest();
|
||||
stopPreviewAudio();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.AbogenDashboard = window.AbogenDashboard || {};
|
||||
window.AbogenDashboard.init = initDashboard;
|
||||
|
||||
const bootDashboard = () => {
|
||||
initDashboard();
|
||||
initWizard();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initDashboard, { once: true });
|
||||
document.addEventListener("DOMContentLoaded", bootDashboard, { once: true });
|
||||
} else {
|
||||
initDashboard();
|
||||
bootDashboard();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector(".prepare-form");
|
||||
const prepareState = (window.AbogenPrepareState = window.AbogenPrepareState || {
|
||||
modalEventsBound: false,
|
||||
});
|
||||
|
||||
const initPrepare = (root = document) => {
|
||||
const rootEl = root instanceof HTMLElement ? root : document;
|
||||
const form = rootEl.querySelector(".prepare-form") || document.querySelector(".prepare-form");
|
||||
if (!form) return;
|
||||
if (form.dataset.prepareInitialized === "true") {
|
||||
return;
|
||||
}
|
||||
form.dataset.prepareInitialized = "true";
|
||||
|
||||
const wizardModal = document.querySelector('[data-role="wizard-modal"]');
|
||||
const uploadModal = document.querySelector('[data-role="upload-modal"]');
|
||||
const wizardPreviousButtons = Array.from(document.querySelectorAll('[data-role="wizard-previous"]'));
|
||||
const uploadModal =
|
||||
document.querySelector('[data-role="new-job-modal"]') ||
|
||||
document.querySelector('[data-role="upload-modal"]');
|
||||
const wizardPreviousButtons = Array.from(
|
||||
document.querySelectorAll('[data-role="wizard-previous"], [data-role="wizard-back"]')
|
||||
);
|
||||
const openUploadTriggers = Array.from(document.querySelectorAll('[data-role="open-upload-modal"]'));
|
||||
|
||||
const showWizardModal = () => {
|
||||
@@ -40,10 +53,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
showWizardModal();
|
||||
|
||||
if (!prepareState.modalEventsBound) {
|
||||
prepareState.modalEventsBound = true;
|
||||
document.addEventListener("upload-modal:open", hideWizardModal);
|
||||
document.addEventListener("upload-modal:close", showWizardModal);
|
||||
}
|
||||
|
||||
wizardPreviousButtons.forEach((button) => {
|
||||
if (!button || button.dataset.prepareBackBound === "true") {
|
||||
return;
|
||||
}
|
||||
const targetStep = (button.dataset.targetStep || "").toLowerCase();
|
||||
if (targetStep && targetStep !== "book") {
|
||||
return;
|
||||
}
|
||||
button.dataset.prepareBackBound = "true";
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
hideWizardModal();
|
||||
@@ -1775,4 +1799,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
hideGenderMenus();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window.AbogenPrepare = window.AbogenPrepare || {};
|
||||
window.AbogenPrepare.init = initPrepare;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => initPrepare());
|
||||
} else {
|
||||
initPrepare();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
const STEP_ORDER = ["book", "chapters", "entities"];
|
||||
const STEP_META = {
|
||||
book: {
|
||||
index: 1,
|
||||
title: "Book parameters",
|
||||
hint: "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.",
|
||||
},
|
||||
chapters: {
|
||||
index: 2,
|
||||
title: "Select chapters",
|
||||
hint: "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
|
||||
},
|
||||
entities: {
|
||||
index: 3,
|
||||
title: "Review entities",
|
||||
hint: "Assign pronunciations, voices, and manual overrides before queueing the conversion.",
|
||||
},
|
||||
};
|
||||
|
||||
const wizardState = (window.AbogenWizardState = window.AbogenWizardState || {
|
||||
initialized: false,
|
||||
modal: null,
|
||||
stage: null,
|
||||
submitting: false,
|
||||
});
|
||||
|
||||
const normalizeStep = (step) => {
|
||||
let value = (step || "book").toLowerCase();
|
||||
if (value === "speakers") {
|
||||
value = "entities";
|
||||
}
|
||||
if (value === "settings" || value === "upload" || value === "") {
|
||||
value = "book";
|
||||
}
|
||||
if (!STEP_ORDER.includes(value)) {
|
||||
return "book";
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const setButtonLoading = (button, isLoading) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
if (isLoading) {
|
||||
if (!button.dataset.originalDisabled) {
|
||||
button.dataset.originalDisabled = button.disabled ? "true" : "false";
|
||||
}
|
||||
button.disabled = true;
|
||||
button.dataset.loading = "true";
|
||||
} else {
|
||||
if (button.dataset.loading) {
|
||||
delete button.dataset.loading;
|
||||
}
|
||||
const original = button.dataset.originalDisabled;
|
||||
if (original !== undefined) {
|
||||
button.disabled = original === "true";
|
||||
delete button.dataset.originalDisabled;
|
||||
} else {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setSubmitting = (modal, isSubmitting, button) => {
|
||||
if (!modal) return;
|
||||
wizardState.submitting = isSubmitting;
|
||||
if (isSubmitting) {
|
||||
modal.dataset.submitting = "true";
|
||||
} else {
|
||||
delete modal.dataset.submitting;
|
||||
}
|
||||
setButtonLoading(button, isSubmitting);
|
||||
};
|
||||
|
||||
const findModal = () => document.querySelector('[data-role="new-job-modal"]');
|
||||
|
||||
const ensureModalRef = () => {
|
||||
if (wizardState.modal && wizardState.modal.isConnected) {
|
||||
return wizardState.modal;
|
||||
}
|
||||
wizardState.modal = findModal();
|
||||
return wizardState.modal;
|
||||
};
|
||||
|
||||
const dispatchWizardEvent = (modal, type, detail = {}) => {
|
||||
if (!modal) return;
|
||||
const event = new CustomEvent(`wizard:${type}`, { bubbles: true, detail });
|
||||
modal.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const destroyTransientAlerts = (stage) => {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
const alerts = stage.querySelectorAll('[data-role="wizard-error"]');
|
||||
alerts.forEach((alert) => alert.remove());
|
||||
};
|
||||
|
||||
const displayTransientError = (modal, message) => {
|
||||
if (!modal) return;
|
||||
const stage = modal.querySelector('[data-role="wizard-stage"]');
|
||||
if (!stage) return;
|
||||
const existing = stage.querySelector('[data-role="wizard-error"]');
|
||||
if (existing) {
|
||||
existing.textContent = message;
|
||||
return;
|
||||
}
|
||||
const alert = document.createElement("div");
|
||||
alert.className = "alert alert--error";
|
||||
alert.dataset.role = "wizard-error";
|
||||
alert.textContent = message;
|
||||
stage.prepend(alert);
|
||||
};
|
||||
|
||||
const updateStepIndicators = (modal, activeStep) => {
|
||||
const indicators = modal.querySelectorAll('[data-role="wizard-step-indicator"]');
|
||||
const activeIndex = STEP_ORDER.indexOf(activeStep);
|
||||
indicators.forEach((indicator) => {
|
||||
const step = normalizeStep(indicator.dataset.step || "book");
|
||||
indicator.classList.remove("is-active", "is-complete");
|
||||
const index = STEP_ORDER.indexOf(step);
|
||||
if (index < activeIndex) {
|
||||
indicator.classList.add("is-complete");
|
||||
} else if (index === activeIndex) {
|
||||
indicator.classList.add("is-active");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateHeaderCopy = (modal, step, payload) => {
|
||||
const meta = STEP_META[step];
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
const titleEl = modal.querySelector("#new-job-modal-title");
|
||||
const hintEl = modal.querySelector('[data-role="wizard-hint"]');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = payload?.title || meta.title;
|
||||
}
|
||||
if (hintEl) {
|
||||
hintEl.textContent = payload?.hint || meta.hint;
|
||||
}
|
||||
updateStepIndicators(modal, step);
|
||||
};
|
||||
|
||||
const updateFilenameLabel = (modal, filename) => {
|
||||
const label = modal.querySelector(".wizard-card__filename");
|
||||
if (!label) return;
|
||||
if (filename) {
|
||||
label.hidden = false;
|
||||
label.textContent = filename;
|
||||
label.setAttribute("title", filename);
|
||||
} else {
|
||||
label.hidden = true;
|
||||
label.textContent = "";
|
||||
label.removeAttribute("title");
|
||||
}
|
||||
};
|
||||
|
||||
const reinitializeStageModules = (stage) => {
|
||||
if (!stage) return;
|
||||
if (window.AbogenDashboard?.init) {
|
||||
window.AbogenDashboard.init();
|
||||
}
|
||||
if (window.AbogenPrepare?.init) {
|
||||
window.AbogenPrepare.init(stage);
|
||||
}
|
||||
};
|
||||
|
||||
const focusFirstInteractive = (stage) => {
|
||||
if (!stage) return;
|
||||
const focusable = stage.querySelector(
|
||||
'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])'
|
||||
);
|
||||
if (focusable instanceof HTMLElement) {
|
||||
try {
|
||||
focusable.focus({ preventScroll: true });
|
||||
} catch (error) {
|
||||
// Ignore focus errors, browser may block programmatic focus
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const applyWizardPayload = (payload) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
if (payload.pending_id !== undefined) {
|
||||
modal.dataset.pendingId = payload.pending_id || "";
|
||||
}
|
||||
const step = normalizeStep(payload.step || modal.dataset.step || "book");
|
||||
modal.dataset.step = step;
|
||||
modal.hidden = false;
|
||||
modal.dataset.open = "true";
|
||||
document.body.classList.add("modal-open");
|
||||
updateHeaderCopy(modal, step, payload);
|
||||
updateFilenameLabel(modal, payload.filename);
|
||||
|
||||
const stage = modal.querySelector('[data-role="wizard-stage"]');
|
||||
if (stage) {
|
||||
destroyTransientAlerts(stage);
|
||||
stage.innerHTML = payload.html || "";
|
||||
wizardState.stage = stage;
|
||||
reinitializeStageModules(stage);
|
||||
focusFirstInteractive(stage);
|
||||
}
|
||||
|
||||
const stepDetail = {
|
||||
step,
|
||||
index: STEP_META[step]?.index || STEP_ORDER.indexOf(step) + 1,
|
||||
total: STEP_ORDER.length,
|
||||
pendingId: modal.dataset.pendingId || "",
|
||||
notice: payload.notice || "",
|
||||
error: payload.error || "",
|
||||
};
|
||||
dispatchWizardEvent(modal, "step", stepDetail);
|
||||
};
|
||||
|
||||
const handleWizardRedirect = (payload) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
modal.hidden = true;
|
||||
delete modal.dataset.open;
|
||||
document.body.classList.remove("modal-open");
|
||||
dispatchWizardEvent(modal, "done", { redirectUrl: payload.redirect_url });
|
||||
if (payload.redirect_url) {
|
||||
window.location.assign(payload.redirect_url);
|
||||
}
|
||||
};
|
||||
|
||||
const processResponsePayload = (payload, responseOk) => {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
if (payload.redirect_url) {
|
||||
handleWizardRedirect(payload);
|
||||
return;
|
||||
}
|
||||
if (!payload.html && !responseOk) {
|
||||
const modal = ensureModalRef();
|
||||
displayTransientError(modal, payload.error || "Something went wrong. Try again.");
|
||||
return;
|
||||
}
|
||||
applyWizardPayload(payload);
|
||||
};
|
||||
|
||||
const requestWizardStep = async (url, { method = "GET", body = undefined } = {}) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body,
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
processResponsePayload(payload, response.ok);
|
||||
} catch (error) {
|
||||
console.error("Wizard request failed", error);
|
||||
displayTransientError(modal, error?.message || "Unable to update the wizard. Try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const submitWizardForm = async (form, submitter) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
if (wizardState.submitting) {
|
||||
return;
|
||||
}
|
||||
const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || window.location.href;
|
||||
const method = (submitter?.getAttribute("formmethod") || form.getAttribute("method") || "GET").toUpperCase();
|
||||
const formData = new FormData(form);
|
||||
if (submitter && submitter.name && !formData.has(submitter.name)) {
|
||||
formData.append(submitter.name, submitter.value ?? "");
|
||||
}
|
||||
const allowValidation = !submitter?.hasAttribute("formnovalidate") && !form.noValidate;
|
||||
if (allowValidation && typeof form.reportValidity === "function" && !form.reportValidity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
destroyTransientAlerts(modal.querySelector('[data-role="wizard-stage"]'));
|
||||
setSubmitting(modal, true, submitter);
|
||||
try {
|
||||
const response = await fetch(action, {
|
||||
method,
|
||||
body: formData,
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse wizard response", parseError);
|
||||
displayTransientError(modal, "Received an invalid response. Try again.");
|
||||
return;
|
||||
}
|
||||
processResponsePayload(payload, response.ok);
|
||||
if (!response.ok && (!payload || !payload.html)) {
|
||||
displayTransientError(modal, payload?.error || `Request failed (${response.status})`);
|
||||
}
|
||||
} catch (networkError) {
|
||||
console.error("Wizard submission failed", networkError);
|
||||
displayTransientError(modal, networkError?.message || "Unable to submit form. Check your connection and try again.");
|
||||
} finally {
|
||||
setSubmitting(modal, false, submitter);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (button) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
const pendingId = button?.dataset.pendingId || modal.dataset.pendingId;
|
||||
const template = modal.dataset.cancelUrlTemplate || "";
|
||||
if (!pendingId || !template) {
|
||||
modal.hidden = true;
|
||||
delete modal.dataset.open;
|
||||
document.body.classList.remove("modal-open");
|
||||
dispatchWizardEvent(modal, "cancel", { pendingId });
|
||||
return;
|
||||
}
|
||||
const url = template.replace("__pending__", encodeURIComponent(pendingId));
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
if (payload?.redirect_url) {
|
||||
handleWizardRedirect(payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cancel request failed", error);
|
||||
}
|
||||
modal.hidden = true;
|
||||
delete modal.dataset.open;
|
||||
document.body.classList.remove("modal-open");
|
||||
dispatchWizardEvent(modal, "cancel", { pendingId });
|
||||
};
|
||||
|
||||
const handleBackToStep = (button) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
const targetStep = normalizeStep(button.dataset.targetStep || "book");
|
||||
if (targetStep === "book") {
|
||||
return; // handled by prepare.js to reopen upload modal
|
||||
}
|
||||
const pendingId = button.dataset.pendingId || modal.dataset.pendingId || "";
|
||||
const template = modal.dataset.prepareUrlTemplate || "";
|
||||
if (!pendingId || !template) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(template.replace("__pending__", encodeURIComponent(pendingId)), window.location.origin);
|
||||
url.searchParams.set("step", targetStep);
|
||||
url.searchParams.set("format", "json");
|
||||
requestWizardStep(url.toString(), { method: "GET" });
|
||||
};
|
||||
|
||||
const handleWizardClick = (event) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
const cancelButton = event.target.closest('[data-role="wizard-cancel"]');
|
||||
if (cancelButton) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancel(cancelButton);
|
||||
return;
|
||||
}
|
||||
const backButton = event.target.closest('[data-role="wizard-back"]');
|
||||
if (backButton) {
|
||||
const targetStep = normalizeStep(backButton.dataset.targetStep || "book");
|
||||
if (targetStep !== "book") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleBackToStep(backButton);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleWizardSubmit = (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
if (form.dataset.wizardForm !== "true") {
|
||||
return;
|
||||
}
|
||||
const submitter = event.submitter || form.querySelector('button[type="submit"]');
|
||||
if (!submitter) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
submitWizardForm(form, submitter);
|
||||
};
|
||||
|
||||
const initWizard = () => {
|
||||
if (wizardState.initialized) {
|
||||
return;
|
||||
}
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) {
|
||||
return;
|
||||
}
|
||||
wizardState.initialized = true;
|
||||
wizardState.modal = modal;
|
||||
wizardState.stage = modal.querySelector('[data-role="wizard-stage"]');
|
||||
modal.addEventListener("submit", handleWizardSubmit, true);
|
||||
modal.addEventListener("click", handleWizardClick);
|
||||
};
|
||||
|
||||
window.AbogenWizard = window.AbogenWizard || {};
|
||||
window.AbogenWizard.init = initWizard;
|
||||
window.AbogenWizard.requestStep = requestWizardStep;
|
||||
window.AbogenWizard.applyPayload = applyWizardPayload;
|
||||
|
||||
export { initWizard };
|
||||
@@ -19,7 +19,7 @@
|
||||
{% set endpoint = request.endpoint or '' %}
|
||||
<a href="{{ url_for('web.index') }}" class="btn{% if endpoint == 'web.index' %} is-active{% endif %}">Dashboard</a>
|
||||
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn{% if endpoint == 'web.voice_profiles_page' %} is-active{% endif %}">Voice Mixer</a>
|
||||
<a href="{{ url_for('web.speaker_configs_page') }}" class="btn{% if endpoint == 'web.speaker_configs_page' %} is-active{% endif %}">Speakers</a>
|
||||
<a href="{{ url_for('web.entities_page') }}" class="btn{% if endpoint == 'web.entities_page' %} is-active{% endif %}">Entities</a>
|
||||
<a href="{{ url_for('web.find_books_page') }}" class="btn{% if endpoint == 'web.find_books_page' %} is-active{% endif %}">Find Books</a>
|
||||
<a href="{{ url_for('web.queue_page') }}" class="btn{% if endpoint in ['web.queue_page', 'web.job_detail'] %} is-active{% endif %}">Queue</a>
|
||||
<a href="{{ url_for('web.settings_page') }}" class="btn{% if endpoint == 'web.settings_page' %} is-active{% endif %}">Settings</a>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Entities{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card card--panel">
|
||||
<h1 class="card__title">Entities & Pronunciation Overrides</h1>
|
||||
<p class="card__subtitle">Review and refine stored pronunciations so recurring names sound right in every project.</p>
|
||||
<form class="entity-filter" method="get">
|
||||
<div class="entity-filter__row">
|
||||
<label class="field">
|
||||
<span>Language</span>
|
||||
<select name="lang">
|
||||
{% for code, label in languages %}
|
||||
<option value="{{ code }}" {% if code == language %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Voice filter</span>
|
||||
<select name="voice">
|
||||
{% for option in voice_filter_options %}
|
||||
<option value="{{ option.value }}" {% if option.value == voice_filter %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Pronunciations</span>
|
||||
<select name="pronunciation">
|
||||
{% for option in pronunciation_filter_options %}
|
||||
<option value="{{ option.value }}" {% if option.value == pronunciation_filter %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Limit</span>
|
||||
<input type="number" name="limit" min="10" max="500" value="{{ limit }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="entity-filter__row entity-filter__row--actions">
|
||||
<label class="field field--grow">
|
||||
<span>Search overrides</span>
|
||||
<input type="search" name="q" value="{{ query }}" placeholder="Search by token, pronunciation, or notes">
|
||||
</label>
|
||||
<div class="entity-filter__actions">
|
||||
<button type="submit" class="button">Apply filters</button>
|
||||
<a href="{{ url_for('web.entities_page') }}" class="button button--ghost">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<p class="hint">Looking for saved speaker rosters instead? <a href="{{ url_for('web.speaker_configs_page') }}">Manage speaker presets</a>.</p>
|
||||
</section>
|
||||
|
||||
<section class="card card--panel">
|
||||
<header class="entity-summary">
|
||||
<h2 class="card__title">Overrides for {{ language_label }}</h2>
|
||||
<p class="card__subtitle">
|
||||
Showing {{ stats.filtered }} of {{ stats.total }} stored overrides ·
|
||||
{{ stats.with_pronunciation }} with pronunciations · {{ stats.with_voice }} with assigned voices
|
||||
</p>
|
||||
</header>
|
||||
{% if overrides %}
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Token</th>
|
||||
<th scope="col">Pronunciation</th>
|
||||
<th scope="col">Voice</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th scope="col">Usage</th>
|
||||
<th scope="col">Last updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for override in overrides %}
|
||||
<tr>
|
||||
<td data-label="Token">{{ override.token }}</td>
|
||||
<td data-label="Pronunciation">{{ override.pronunciation or "—" }}</td>
|
||||
<td data-label="Voice">{{ override.voice or "—" }}</td>
|
||||
<td data-label="Notes">{{ override.notes or "" }}</td>
|
||||
<td data-label="Usage">{{ override.usage_count }}</td>
|
||||
<td data-label="Last updated">{{ override.updated_at_label }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">No overrides matched your filters. Try adjusting the search or create overrides from the Entities step while preparing a job.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,306 @@
|
||||
{% set pending = pending if pending is defined else None %}
|
||||
{% set readonly = readonly if readonly is defined else False %}
|
||||
{% set settings_dict = settings if settings is defined else {} %}
|
||||
{% set options = options if options is defined else {} %}
|
||||
{% set form_values = form_values if form_values is defined and form_values else {} %}
|
||||
{% set language_value = form_values.get('language') if form_values else None %}
|
||||
{% if not language_value %}
|
||||
{% if pending and pending.language %}
|
||||
{% set language_value = pending.language %}
|
||||
{% else %}
|
||||
{% set language_value = settings_dict.get('language', '') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not language_value and options.languages %}
|
||||
{% set sorted_languages = options.languages|dictsort %}
|
||||
{% if sorted_languages %}
|
||||
{% set language_value = sorted_languages[0][0] %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set subtitle_value = form_values.get('subtitle_mode') if form_values else None %}
|
||||
{% if not subtitle_value %}
|
||||
{% if pending and pending.subtitle_mode %}
|
||||
{% set subtitle_value = pending.subtitle_mode %}
|
||||
{% else %}
|
||||
{% set subtitle_value = settings_dict.get('subtitle_mode', 'Disabled') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set generate_flag = form_values.get('generate_epub3') if form_values else None %}
|
||||
{% if generate_flag is not none %}
|
||||
{% set generate_epub3 = True %}
|
||||
{% else %}
|
||||
{% set generate_epub3 = pending.generate_epub3 if pending else settings_dict.get('generate_epub3', False) %}
|
||||
{% endif %}
|
||||
{% set speaker_mode_value = form_values.get('speaker_mode') if form_values else None %}
|
||||
{% if not speaker_mode_value %}
|
||||
{% if pending and pending.speaker_mode %}
|
||||
{% set speaker_mode_value = pending.speaker_mode %}
|
||||
{% else %}
|
||||
{% set speaker_mode_value = settings_dict.get('speaker_mode', 'single') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set chunk_level_value = form_values.get('chunk_level') if form_values else None %}
|
||||
{% if not chunk_level_value %}
|
||||
{% if pending and pending.chunk_level %}
|
||||
{% set chunk_level_value = pending.chunk_level %}
|
||||
{% else %}
|
||||
{% set chunk_level_value = settings_dict.get('chunk_level', 'paragraph') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set analysis_threshold_value = form_values.get('speaker_analysis_threshold') if form_values else None %}
|
||||
{% if not analysis_threshold_value %}
|
||||
{% if pending and pending.speaker_analysis_threshold %}
|
||||
{% set analysis_threshold_value = pending.speaker_analysis_threshold %}
|
||||
{% else %}
|
||||
{% set analysis_threshold_value = settings_dict.get('speaker_analysis_threshold', 3) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set chapter_delay_value = form_values.get('chapter_intro_delay') if form_values else None %}
|
||||
{% if not chapter_delay_value %}
|
||||
{% if pending and pending.chapter_intro_delay is not none %}
|
||||
{% set chapter_delay_value = pending.chapter_intro_delay %}
|
||||
{% else %}
|
||||
{% set chapter_delay_value = settings_dict.get('chapter_intro_delay', 0.5) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set selected_config = form_values.get('speaker_config') if form_values else None %}
|
||||
{% if selected_config is none %}
|
||||
{% if pending and pending.applied_speaker_config %}
|
||||
{% set selected_config = pending.applied_speaker_config %}
|
||||
{% else %}
|
||||
{% set selected_config = '' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set narrator_speed_value = form_values.get('speed') if form_values else None %}
|
||||
{% if narrator_speed_value is none %}
|
||||
{% if pending and pending.speed %}
|
||||
{% set narrator_speed_value = pending.speed %}
|
||||
{% else %}
|
||||
{% set narrator_speed_value = settings_dict.get('default_speed', 1.0) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set narrator_speed = narrator_speed_value|float if narrator_speed_value is not none else 1.0 %}
|
||||
{% set speed_display = '%.2f'|format(narrator_speed if narrator_speed else 1.0) %}
|
||||
{% set form_profile = form_values.get('voice_profile') if form_values else None %}
|
||||
{% set form_voice = form_values.get('voice') if form_values else None %}
|
||||
{% set form_formula = form_values.get('voice_formula') if form_values else None %}
|
||||
{% set narrator_profile = None %}
|
||||
{% if form_profile is not none and form_profile != '' %}
|
||||
{% set narrator_profile = form_profile %}
|
||||
{% elif pending and pending.voice_profile %}
|
||||
{% set narrator_profile = pending.voice_profile %}
|
||||
{% else %}
|
||||
{% set narrator_profile = '' %}
|
||||
{% endif %}
|
||||
{% set narrator_voice = None %}
|
||||
{% if form_voice %}
|
||||
{% set narrator_voice = form_voice %}
|
||||
{% elif pending and pending.voice %}
|
||||
{% set narrator_voice = pending.voice %}
|
||||
{% else %}
|
||||
{% set narrator_voice = settings_dict.get('default_voice', options.voices[0] if options.voices else '') %}
|
||||
{% endif %}
|
||||
{% set voice_formula_value = '' %}
|
||||
{% set profile_value = narrator_profile if narrator_profile else '__standard' %}
|
||||
{% if profile_value == '__formula' %}
|
||||
{% if form_formula %}
|
||||
{% set voice_formula_value = form_formula %}
|
||||
{% elif pending and pending.voice %}
|
||||
{% set voice_formula_value = pending.voice %}
|
||||
{% endif %}
|
||||
{% elif profile_value not in ['__standard', '', None] %}
|
||||
{% set voice_formula_value = '' %}
|
||||
{% else %}
|
||||
{% if form_formula %}
|
||||
{% set profile_value = '__formula' %}
|
||||
{% set voice_formula_value = form_formula %}
|
||||
{% elif 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' %}
|
||||
{% set voice_formula_value = '' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if profile_value == '__formula' and not voice_formula_value %}
|
||||
{% if pending and pending.voice %}
|
||||
{% set voice_formula_value = pending.voice %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if profile_value != '__standard' and profile_value != '__formula' %}
|
||||
{% set narrator_voice = '' %}
|
||||
{% endif %}
|
||||
{% if not narrator_voice and options.voices %}
|
||||
{% set narrator_voice = options.voices[0] %}
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="alert alert--info">{{ notice }}</div>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('web.enqueue_job') if not readonly else '#' }}"
|
||||
method="post"
|
||||
id="new-job-book-form"
|
||||
class="upload-form"
|
||||
data-role="wizard-form"
|
||||
data-wizard-form="true"
|
||||
data-step="book"
|
||||
data-pending-id="{{ pending.id if pending else '' }}"
|
||||
{% if not readonly %}enctype="multipart/form-data"{% endif %}>
|
||||
<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">Entities & 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">Entities 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|float if chapter_delay_value is not none else 0.5) }}" {{ '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">
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id if pending else '' }}">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
{% if readonly %}
|
||||
<button type="button" class="button" data-role="wizard-back" data-target-step="chapters" data-pending-id="{{ pending.id if pending else '' }}">Return to chapters</button>
|
||||
{% else %}
|
||||
<button type="submit" class="button" data-step-target="chapters">Chapter selection</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -0,0 +1,125 @@
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-role="prepare-form"
|
||||
data-wizard-form="true"
|
||||
data-step="chapters"
|
||||
data-pending-id="{{ pending.id }}"
|
||||
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-back" data-target-step="book" data-pending-id="{{ pending.id }}">Previous</button>
|
||||
<button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id }}">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit"
|
||||
class="button"
|
||||
data-role="submit-speaker-analysis"
|
||||
data-step-target="entities">
|
||||
Continue to entities
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -0,0 +1,466 @@
|
||||
{% set is_multi_speaker = pending.speaker_mode == 'multi' %}
|
||||
{% set embed_scripts = embed_scripts if embed_scripts is defined else True %}
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
id="prepare-form"
|
||||
data-role="prepare-form"
|
||||
data-wizard-form="true"
|
||||
data-step="entities"
|
||||
data-pending-id="{{ pending.id }}"
|
||||
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="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 %}
|
||||
|
||||
<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 options.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 saved from previous jobs.</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">
|
||||
<div class="entity-summary__titles">
|
||||
<h2>Detected entities</h2>
|
||||
<p class="hint">Assign pronunciations and voices for recurring names and terminology across the manuscript.</p>
|
||||
</div>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entities-refresh">Refresh</button>
|
||||
</header>
|
||||
<dl class="entity-summary__stats" data-role="entity-stats"></dl>
|
||||
<ol class="entity-summary__list" data-role="entity-list"></ol>
|
||||
<template data-role="entity-row-template">
|
||||
<li class="entity-summary__item">
|
||||
<header class="entity-summary__item-header">
|
||||
<div>
|
||||
<h3 class="entity-summary__label" data-role="entity-label"></h3>
|
||||
<p class="entity-summary__kind" data-role="entity-kind"></p>
|
||||
</div>
|
||||
<div class="entity-summary__actions">
|
||||
<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">
|
||||
<button type="button" class="button button--ghost" data-role="wizard-back" data-target-step="chapters" data-pending-id="{{ pending.id }}">Previous</button>
|
||||
<button type="button" class="button button--ghost" data-role="wizard-cancel" data-pending-id="{{ pending.id }}">Cancel</button>
|
||||
</div>
|
||||
<div class="wizard-card__footer-actions">
|
||||
<button type="submit" class="button" data-step-target="finalize">Queue conversion</button>
|
||||
</div>
|
||||
</footer>
|
||||
</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-filter" placeholder="Search by name, language, or tag">
|
||||
</label>
|
||||
<div class="voice-browser__chips" data-role="voice-filter-chips"></div>
|
||||
</aside>
|
||||
<section class="voice-browser__results" data-role="voice-results"></section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 id="voice-catalog-data" type="application/json">{{ options.voice_catalog | tojson }}</script>
|
||||
<script id="voice-language-map" type="application/json">{{ options.languages | tojson }}</script>
|
||||
{% if embed_scripts %}
|
||||
<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>
|
||||
{% endif %}
|
||||
@@ -1,235 +1,72 @@
|
||||
{% 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 total_steps = 3 if is_multi else 2 %}
|
||||
{% 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', 'entities'] else '') %}
|
||||
{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'entities' else '') %}
|
||||
{% set step3_state = 'is-active' if modal_step == 'entities' 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">
|
||||
{% set provided_step = step if step is defined else (active_step if active_step is defined else 'book') %}
|
||||
{% set current_step = provided_step %}
|
||||
{% if current_step in ['settings', 'upload', ''] %}{% set current_step = 'book' %}{% endif %}
|
||||
{% if current_step not in ['book', 'chapters', 'entities'] %}{% set current_step = 'book' %}{% endif %}
|
||||
{% set step_number = {'book': 1, 'chapters': 2, 'entities': 3} %}
|
||||
{% set step_titles = {
|
||||
'book': 'Book parameters',
|
||||
'chapters': 'Select chapters',
|
||||
'entities': 'Review entities'
|
||||
} %}
|
||||
{% set step_hints = {
|
||||
'book': 'Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.',
|
||||
'chapters': "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
|
||||
'entities': 'Assign pronunciations, voices, and manual overrides before queueing the conversion.'
|
||||
} %}
|
||||
{% set navigation_labels = {
|
||||
'book': 'Book parameters',
|
||||
'chapters': 'Chapters',
|
||||
'entities': 'Entities'
|
||||
} %}
|
||||
{% set total_steps = 3 %}
|
||||
{% set current_index = step_number[current_step] %}
|
||||
{% set is_open = open if open is defined else False %}
|
||||
{% set prepare_url_template = url_for('web.prepare_job', pending_id='__pending__') %}
|
||||
{% set cancel_url_template = url_for('web.cancel_pending_job', pending_id='__pending__') %}
|
||||
{% set analyze_url_template = url_for('web.analyze_pending_job', pending_id='__pending__') %}
|
||||
<div class="modal"
|
||||
data-role="new-job-modal"
|
||||
data-step="{{ current_step }}"
|
||||
data-pending-id="{{ pending.id if pending else '' }}"
|
||||
data-prepare-url-template="{{ prepare_url_template }}"
|
||||
data-cancel-url-template="{{ cancel_url_template }}"
|
||||
data-analyze-url-template="{{ analyze_url_template }}"
|
||||
{% if is_open %}data-open="true"{% else %}hidden{% endif %}>
|
||||
<div class="modal__overlay" data-role="new-job-modal-close" tabindex="-1"></div>
|
||||
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="new-job-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>
|
||||
<nav class="step-indicator" aria-label="New job workflow">
|
||||
{% for slug, label in navigation_labels.items() %}
|
||||
{% set item_index = step_number[slug] %}
|
||||
{% set state = 'is-active' if slug == current_step else ('is-complete' if item_index < current_index else '') %}
|
||||
<span class="step-indicator__item {{ state }}" data-role="wizard-step-indicator" data-step="{{ slug }}">
|
||||
<span class="step-indicator__index">{{ item_index }}</span>
|
||||
<span class="step-indicator__label">{{ label }}</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">Entities</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<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>
|
||||
<h2 class="modal__title" id="new-job-modal-title">{{ step_titles[current_step] }}</h2>
|
||||
<p class="hint" data-role="wizard-hint">{{ step_hints[current_step] }}</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>
|
||||
<button type="button" class="icon-button wizard-card__close" data-role="new-job-modal-close" aria-label="Close new job wizard">✕</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">Entities & 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">Entities 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>
|
||||
<div class="wizard-card__stage" data-role="wizard-stage">
|
||||
{% if current_step == 'book' %}
|
||||
{% include 'partials/new_job_step_book.html' %}
|
||||
{% elif current_step == 'chapters' %}
|
||||
{% include 'partials/new_job_step_chapters.html' %}
|
||||
{% else %}
|
||||
<button type="submit" class="button">Chapter Selection</button>
|
||||
{% include 'partials/new_job_step_entities.html' %}
|
||||
{% endif %}
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user