mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Implement entity recognition settings and UI updates across multiple components
This commit is contained in:
@@ -1107,6 +1107,13 @@ def _sync_pronunciation_overrides(pending: PendingJob) -> None:
|
||||
|
||||
|
||||
def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None:
|
||||
settings = _load_settings()
|
||||
if not bool(settings.get("enable_entity_recognition", True)):
|
||||
pending.entity_summary = {}
|
||||
pending.entity_cache_key = ""
|
||||
pending.pronunciation_overrides = pending.pronunciation_overrides or []
|
||||
return
|
||||
|
||||
language = pending.language or "en"
|
||||
result = extract_entities(chapters, language=language)
|
||||
summary = dict(result.summary)
|
||||
@@ -1289,12 +1296,15 @@ def _search_manual_override_candidates(pending: PendingJob, query: str, *, limit
|
||||
|
||||
|
||||
def _pending_entities_payload(pending: PendingJob) -> Dict[str, Any]:
|
||||
settings = _load_settings()
|
||||
recognition_enabled = bool(settings.get("enable_entity_recognition", True))
|
||||
return {
|
||||
"summary": pending.entity_summary or {},
|
||||
"manual_overrides": pending.manual_overrides or [],
|
||||
"pronunciation_overrides": pending.pronunciation_overrides or [],
|
||||
"cache_key": pending.entity_cache_key,
|
||||
"language": pending.language or "en",
|
||||
"recognition_enabled": recognition_enabled,
|
||||
}
|
||||
|
||||
|
||||
@@ -1681,6 +1691,7 @@ BOOLEAN_SETTINGS = {
|
||||
"merge_chapters_at_end",
|
||||
"save_as_project",
|
||||
"generate_epub3",
|
||||
"enable_entity_recognition",
|
||||
}
|
||||
|
||||
FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"}
|
||||
@@ -1707,6 +1718,7 @@ def _settings_defaults() -> Dict[str, Any]:
|
||||
"chapter_intro_delay": 0.5,
|
||||
"max_subtitle_words": 50,
|
||||
"chunk_level": "paragraph",
|
||||
"enable_entity_recognition": True,
|
||||
"generate_epub3": False,
|
||||
"speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
|
||||
"speaker_pronunciation_sentence": "This is {{name}} speaking.",
|
||||
|
||||
@@ -15,9 +15,6 @@ const initPrepare = (root = document) => {
|
||||
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 = () => {
|
||||
@@ -59,22 +56,6 @@ const initPrepare = (root = document) => {
|
||||
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();
|
||||
triggerUploadModal();
|
||||
});
|
||||
});
|
||||
|
||||
const parseJSONScript = (id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
@@ -1083,6 +1064,7 @@ const initPrepare = (root = document) => {
|
||||
const languageCode = form.dataset.language || "en";
|
||||
const defaultSpeed = form.dataset.speed || "1.0";
|
||||
const useGpuDefault = form.dataset.useGpu || "true";
|
||||
let entitiesEnabled = form.dataset.entitiesEnabled !== "false";
|
||||
|
||||
const entityState = {
|
||||
summary: entitySummaryData && typeof entitySummaryData === "object" ? entitySummaryData : {},
|
||||
@@ -1177,6 +1159,18 @@ const initPrepare = (root = document) => {
|
||||
|
||||
function renderEntitySummary() {
|
||||
if (!entityListNode) return;
|
||||
if (!entitiesEnabled) {
|
||||
if (entityStatsNode) {
|
||||
entityStatsNode.textContent = "Entity recognition is turned off in Settings.";
|
||||
}
|
||||
entityListNode.innerHTML = "";
|
||||
const emptyItem = document.createElement("li");
|
||||
emptyItem.className = "entity-summary__empty";
|
||||
emptyItem.textContent = "Enable entity recognition to populate detected entities.";
|
||||
entityListNode.appendChild(emptyItem);
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = entityState.summary || {};
|
||||
const stats = summary.stats || {};
|
||||
const errors = Array.isArray(summary.errors) ? summary.errors : [];
|
||||
@@ -1207,7 +1201,7 @@ const initPrepare = (root = document) => {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.slice(0, 120).forEach((entity) => {
|
||||
entries.forEach((entity) => {
|
||||
const item = cloneTemplate(entityRowTemplate);
|
||||
if (!item) return;
|
||||
const normalized = entity.normalized || entity.label || entity.token || "";
|
||||
@@ -1385,6 +1379,14 @@ const initPrepare = (root = document) => {
|
||||
if (typeof payload.cache_key === "string") {
|
||||
entityState.cacheKey = payload.cache_key;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "recognition_enabled")) {
|
||||
entitiesEnabled = payload.recognition_enabled !== false;
|
||||
form.dataset.entitiesEnabled = entitiesEnabled ? "true" : "false";
|
||||
if (entitiesRefreshButton) {
|
||||
entitiesRefreshButton.disabled = !entitiesEnabled;
|
||||
entitiesRefreshButton.setAttribute("aria-disabled", entitiesEnabled ? "false" : "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.highlightId) {
|
||||
highlightedOverrideId = options.highlightId;
|
||||
|
||||
+42
-12
@@ -120,10 +120,18 @@ const updateStepIndicators = (modal, activeStep) => {
|
||||
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 isComplete = index > -1 && index < activeIndex;
|
||||
const isActive = index === activeIndex;
|
||||
indicator.classList.toggle("is-complete", isComplete);
|
||||
indicator.classList.toggle("is-active", isActive);
|
||||
if (indicator instanceof HTMLButtonElement) {
|
||||
indicator.disabled = !(isComplete);
|
||||
indicator.setAttribute("aria-current", isActive ? "step" : "false");
|
||||
if (isComplete) {
|
||||
indicator.dataset.state = "clickable";
|
||||
} else {
|
||||
delete indicator.dataset.state;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -359,27 +367,42 @@ const handleCancel = async (button) => {
|
||||
dispatchWizardEvent(modal, "cancel", { pendingId });
|
||||
};
|
||||
|
||||
const handleBackToStep = (button) => {
|
||||
const navigateToWizardStep = (targetStep, pendingOverride) => {
|
||||
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
|
||||
if (!modal || wizardState.submitting) {
|
||||
return;
|
||||
}
|
||||
const pendingId = button.dataset.pendingId || modal.dataset.pendingId || "";
|
||||
const normalizedTarget = normalizeStep(targetStep || "book");
|
||||
const currentStep = modal.dataset.step ? normalizeStep(modal.dataset.step) : "book";
|
||||
if (normalizedTarget === currentStep) {
|
||||
return;
|
||||
}
|
||||
const pendingId = pendingOverride || 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("step", normalizedTarget);
|
||||
url.searchParams.set("format", "json");
|
||||
requestWizardStep(url.toString(), { method: "GET" });
|
||||
};
|
||||
|
||||
const handleBackToStep = (button) => {
|
||||
const targetStep = normalizeStep(button.dataset.targetStep || "book");
|
||||
navigateToWizardStep(targetStep, button.dataset.pendingId);
|
||||
};
|
||||
|
||||
const handleWizardClick = (event) => {
|
||||
const modal = ensureModalRef();
|
||||
if (!modal) return;
|
||||
const closeTarget = event.target.closest('[data-role="new-job-modal-close"]');
|
||||
if (closeTarget) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancel(closeTarget);
|
||||
return;
|
||||
}
|
||||
const cancelButton = event.target.closest('[data-role="wizard-cancel"]');
|
||||
if (cancelButton) {
|
||||
event.preventDefault();
|
||||
@@ -390,10 +413,17 @@ const handleWizardClick = (event) => {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const indicator = event.target.closest('[data-role="wizard-step-indicator"]');
|
||||
if (indicator instanceof HTMLButtonElement) {
|
||||
if (indicator.classList.contains("is-complete")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigateToWizardStep(indicator.dataset.step || "book");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,9 +144,8 @@
|
||||
<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">
|
||||
<div class="field-grid field-grid--compact">
|
||||
<div class="field field--file field--span-2">
|
||||
<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 %}
|
||||
@@ -161,8 +160,6 @@
|
||||
{% 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 '' }}>
|
||||
@@ -176,7 +173,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<div class="field field--stack field--span-2">
|
||||
<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 '' }}>
|
||||
@@ -185,7 +182,6 @@
|
||||
<p class="hint">Creates a synchronized EPUB alongside audio output.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
@@ -238,8 +234,8 @@
|
||||
|
||||
<section class="form-section">
|
||||
<h3 class="form-section__title">Entities & casting</h3>
|
||||
<div class="form-section__layout">
|
||||
<div class="field field--stack">
|
||||
<div class="field-grid field-grid--compact">
|
||||
<div class="field field--stack field--span-2">
|
||||
<label for="speaker_config">Speaker preset</label>
|
||||
<select id="speaker_config" name="speaker_config" {{ 'disabled' if readonly else '' }}>
|
||||
<option value="">None</option>
|
||||
@@ -249,7 +245,6 @@
|
||||
</select>
|
||||
<p class="hint">Reuse a saved roster to keep character voices consistent.</p>
|
||||
</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 '' }}>
|
||||
@@ -264,7 +259,6 @@
|
||||
<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 '' }}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% set embed_scripts = embed_scripts if embed_scripts is defined else True %}
|
||||
{% set recognition_enabled = settings.enable_entity_recognition if settings is defined and settings.enable_entity_recognition is not none else True %}
|
||||
<form method="post"
|
||||
action="{{ url_for('web.finalize_job', pending_id=pending.id) }}"
|
||||
class="prepare-form"
|
||||
@@ -16,7 +17,8 @@
|
||||
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' }}">
|
||||
data-use-gpu="{{ 'true' if pending.use_gpu else 'false' }}"
|
||||
data-entities-enabled="{{ 'true' if recognition_enabled else 'false' }}">
|
||||
<input type="hidden" name="active_step" value="entities" data-role="active-step-input">
|
||||
|
||||
<div class="modal__body wizard-card__body">
|
||||
@@ -28,6 +30,11 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="entity-tabs" data-role="entity-tabs">
|
||||
{% if not recognition_enabled %}
|
||||
<div class="alert alert--info entity-tabs__notice">
|
||||
Entity recognition is disabled in Settings. Enable it to populate detected people and entities automatically.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="entity-tabs__nav" role="tablist" aria-label="Entity categories">
|
||||
<button type="button"
|
||||
class="entity-tabs__tab is-active"
|
||||
@@ -95,7 +102,7 @@
|
||||
formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}"
|
||||
formmethod="post"
|
||||
formnovalidate
|
||||
{% if not speaker_configs %}disabled{% endif %}>
|
||||
{% if not options.speaker_configs %}disabled{% endif %}>
|
||||
Apply preset
|
||||
</button>
|
||||
<label class="toggle-pill">
|
||||
@@ -340,7 +347,7 @@
|
||||
<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>
|
||||
<button type="button" class="button button--ghost button--small" data-role="entities-refresh" {% if not recognition_enabled %}disabled aria-disabled="true"{% endif %}>Refresh</button>
|
||||
</header>
|
||||
<dl class="entity-summary__stats" data-role="entity-stats"></dl>
|
||||
<ol class="entity-summary__list" data-role="entity-list"></ol>
|
||||
|
||||
@@ -43,10 +43,14 @@
|
||||
{% 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 }}">
|
||||
<button type="button"
|
||||
class="step-indicator__item {{ state }}"
|
||||
data-role="wizard-step-indicator"
|
||||
data-step="{{ slug }}"
|
||||
aria-current="{{ 'step' if slug == current_step else 'false' }}">
|
||||
<span class="step-indicator__index">{{ item_index }}</span>
|
||||
<span class="step-indicator__label">{{ label }}</span>
|
||||
</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<h2 class="modal__title" id="new-job-modal-title">{{ step_titles[current_step] }}</h2>
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
<input type="text" id="speaker_pronunciation_sentence" name="speaker_pronunciation_sentence" value="{{ settings.speaker_pronunciation_sentence }}" placeholder="This is {{ '{{name}}' }} speaking.">
|
||||
<p class="hint">Include <code>{{ '{{name}}' }}</code> where the speaker name should be inserted.</p>
|
||||
</div>
|
||||
<div class="field field--stack">
|
||||
<label class="toggle-pill">
|
||||
<input type="checkbox" name="enable_entity_recognition" value="true" {% if settings.enable_entity_recognition %}checked{% endif %}>
|
||||
<span>Automatically detect entities for new jobs</span>
|
||||
</label>
|
||||
<p class="hint">Disable if you prefer to skip entity extraction in the job wizard.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speaker_random_languages">Randomizer Languages</label>
|
||||
{% set selected_languages = settings.speaker_random_languages or [] %}
|
||||
|
||||
Reference in New Issue
Block a user