chore: add local maintenance utilities
Some checks are pending
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-09 07:53:13 +02:00
parent 037f3dd74d
commit b43bacc275
10 changed files with 504 additions and 0 deletions

24
patch_changelog.py Normal file
View File

@ -0,0 +1,24 @@
import re
with open('CHANGELOG.md', 'r') as f:
content = f.read()
new_entry = """## [1.22.0] — 2026-08-23
### Added
- **Audiobook Export Modal.** Introduced a comprehensive GUI for exporting audiobooks directly from the Rehearser.
- **Granular Export Controls.** Users can now select specific chapters to export, define the output format (one MP3 per chapter vs. single book file), and configure custom server-side save locations.
- **Pacing Configuration.** Added UI settings to adjust pacing with precise pause durations between paragraphs and chapters.
- **Tone Warning Integration.** Warnings for out-of-bounds tone settings are now displayed within the export modal for safety before batch generation.
### Fixed
- **UI Synchronization.** Fixed an issue where the Rehearser's green dots for cached audio lines would not synchronize reliably during navigation updates.
- **Bundle Stability.** Addressed a script hoisting issue in the production bundle that caused silent initialization failures and a "Loading..." stall.
"""
content = content.replace("---\n\n## [1.21.1]", "---\n\n" + new_entry + "## [1.21.1]")
with open('CHANGELOG.md', 'w') as f:
f.write(content)

185
scratch/patch_audiobook.py Normal file
View File

@ -0,0 +1,185 @@
import re
with open('static/js/audiobook.js', 'r') as f:
content = f.read()
# 1. Change listener
content = content.replace(
"$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);",
"$('reh-tb-audiobook')?.addEventListener('click', audiobookExportDialog);"
)
# 2. Rename audiobookExport to accept opts
content = content.replace(
"async function audiobookExport() {",
"async function audiobookExport(opts = {}) {"
)
# 3. Handle activeBuckets inside audiobookExport
content = content.replace(
"const allIdx = buckets.flatMap(b => b.idx);",
"""let activeBuckets = buckets;
if (opts.selectedChapterIndices) {
activeBuckets = buckets.filter((_, i) => opts.selectedChapterIndices.includes(i));
}
const allIdx = activeBuckets.flatMap(b => b.idx);"""
)
# 4. Modify the loop that creates files
loop_find = """ for (let c = 0; c < buckets.length; c++) {
const blobs = buckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);"""
loop_replace = """ if (opts.format === 'single') {
let masterBlobs = [];
for (let c = 0; c < activeBuckets.length; c++) {
const blobs = activeBuckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);
if (!blobs.length) continue;
if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${activeBuckets.length}…`;
let mergedWav = await mergeWavBlobs(blobs, pauses.paragraphMs);
if (c > 0 && activeBuckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {
const ref = _parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt;
const lead = [];
const sfx = await _abChapterSfxWavBlob(ref);
if (sfx) lead.push(sfx);
if (pauses.chapterMs > 0) lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs, ref), ref));
if (lead.length) mergedWav = await mergeWavBlobs([...lead, mergedWav]);
}
masterBlobs.push(mergedWav);
}
if (masterBlobs.length) {
if (encMsg) encMsg.textContent = `Encoding full book to MP3…`;
let finalWav = await mergeWavBlobs(masterBlobs, 0);
let blob = finalWav;
try {
const encResp = await fetch('/api/audio/encode-mp3', { method: 'POST', body: finalWav });
if (encResp.ok) blob = await encResp.blob();
} catch (e) {}
const ext = blob === finalWav ? 'wav' : 'mp3';
const name = `${title}.${ext}`;
if (typeof readerDownload === 'function') readerDownload(blob, name);
try {
let exportUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}`;
if (opts.folder) exportUrl += `?folder=${encodeURIComponent(opts.folder)}`;
const r = await fetch(exportUrl, { method: 'POST', body: blob });
if (r.ok) savedFiles.push({ name, url: exportUrl });
} catch (e) {}
files++;
}
} else {
for (let c = 0; c < activeBuckets.length; c++) {
const blobs = activeBuckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);"""
content = content.replace(loop_find, loop_replace)
# 5. Fix references inside the else block
content = content.replace(
"encMsg.textContent = `Merging chapter ${c + 1} / ${buckets.length}…`;",
"encMsg.textContent = `Merging chapter ${c + 1} / ${activeBuckets.length}…`;"
)
content = content.replace(
"encMsg.textContent = `Encoding chapter ${c + 1} / ${buckets.length}…`;",
"encMsg.textContent = `Encoding chapter ${c + 1} / ${activeBuckets.length}…`;"
)
content = content.replace(
"if (c > 0 && buckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {",
"if (c > 0 && activeBuckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {"
)
content = content.replace(
"const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';\n const name = (realChapters || buckets.length > 1) ? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.${ext}` : `${title}.${ext}`;",
"const ch = activeBuckets[c].title ? ' ' + readerSafeName(activeBuckets[c].title) : '';\n const name = (realChapters || activeBuckets.length > 1) ? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.${ext}` : `${title}.${ext}`;"
)
content = content.replace(
"if (r.ok) savedFiles.push({ name, url: `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}` });",
"if (r.ok) savedFiles.push({ name, url: exportUrl });"
)
# add folder query param in else block
content = content.replace(
"const r = await fetch(`/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}`, { method: 'POST', body: blob });",
"let exportUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}`;\n if (opts.folder) exportUrl += `?folder=${encodeURIComponent(opts.folder)}`;\n const r = await fetch(exportUrl, { method: 'POST', body: blob });"
)
# close the else block
close_else = """ files++;
await new Promise(r => setTimeout(r, 400)); // breathing room between chunk encode/uploads
}
} // end else
"""
content = content.replace(
"await new Promise(r => setTimeout(r, 400)); // breathing room between chunk encode/uploads\n }",
close_else
)
# 6. Add audiobookExportDialog
dialog_code = """
window.audiobookExportDialog = function() {
if (_audiobook.running) return;
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
if (!rehState.backend) { toast('Select a TTS backend in the rehearser first', 'error'); return; }
const speakable = i => {
const l = rehState.lines[i];
if (!l || l.ignored || l.hidden) return false;
if (l.type === 'dialog') { const c = rehState.cast[l.speaker]; return !!(c && c.voice && c.voice !== 'me'); }
return !!(rehState.narratorVoice && (l.text || '').trim());
};
const buckets = [];
let cur = null;
rehState.lines.forEach((l, i) => {
if (audiobookIsChapter(l)) { cur = { title: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text).trim().slice(0, 50), idx: [] }; buckets.push(cur); }
if (speakable(i)) { if (!cur) { cur = { title: '', idx: [] }; buckets.push(cur); } cur.idx.push(i); }
});
const listEl = document.getElementById('ab-export-chapter-list');
if (listEl) {
listEl.innerHTML = '';
const countEl = document.getElementById('ab-export-chapter-count');
if (countEl) countEl.textContent = buckets.length;
buckets.forEach((b, i) => {
const lbl = document.createElement('label');
lbl.style.display = 'block';
lbl.style.marginBottom = '4px';
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.checked = true;
chk.value = i;
chk.className = 'ab-export-chapter-cb';
lbl.appendChild(chk);
lbl.appendChild(document.createTextNode(' Chapter ' + (i+1) + (b.title ? ': ' + b.title : '')));
listEl.appendChild(lbl);
});
}
const warn = document.getElementById('reh-tone-warn');
const slot = document.getElementById('ab-export-tone-warn-slot');
if (warn && slot) {
warn.hidden = false;
slot.appendChild(warn);
}
if (typeof _abInitPauseUI === 'function') _abInitPauseUI();
const modal = document.getElementById('audiobook-export-modal');
if (modal) modal.hidden = false;
};
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('ab-export-modal-close')?.addEventListener('click', () => document.getElementById('audiobook-export-modal').hidden = true);
document.getElementById('ab-export-modal-cancel')?.addEventListener('click', () => document.getElementById('audiobook-export-modal').hidden = true);
document.getElementById('ab-export-modal-start')?.addEventListener('click', () => {
document.getElementById('audiobook-export-modal').hidden = true;
const formatEl = document.querySelector('input[name="ab_export_format"]:checked');
const format = formatEl ? formatEl.value : 'chapter';
const folderEl = document.getElementById('ab-export-server-folder');
const folder = folderEl ? folderEl.value : '';
const checkboxes = Array.from(document.querySelectorAll('.ab-export-chapter-cb'));
const selectedChapterIndices = checkboxes.filter(cb => cb.checked).map(cb => parseInt(cb.value, 10));
audiobookExport({ format, selectedChapterIndices, folder });
});
});
"""
content += dialog_code
with open('static/js/audiobook.js', 'w') as f:
f.write(content)

View File

@ -0,0 +1,51 @@
import re
with open('static/js/rehearser.js', 'r') as f:
content = f.read()
# 1. In buildScriptPage, use serverCachedLines
content = content.replace(
"const isCached = rehState.synthCache.has(i);",
"const isCached = rehState.synthCache.has(i) || (rehState.serverCachedLines && rehState.serverCachedLines.has(i));"
)
# 2. Modify _lineAudioSyncDots to populate serverCachedLines and remove scanId
old_sync_dots = """let _lineAudioSyncedFor = null; // `${book}::${lineCount}` — avoid re-running the same scan on every small re-render
async function _lineAudioSyncDots() {
const book = _lineAudioBookName();
const scanId = `${book}::${rehState.lines.length}`;
if (_lineAudioSyncedFor === scanId) return;
_lineAudioSyncedFor = scanId;
_ensureNarrator();"""
new_sync_dots = """let _lineAudioSyncedFor = null; // unused now, kept for compat
async function _lineAudioSyncDots() {
if (!rehState.serverCachedLines) rehState.serverCachedLines = new Set();
const book = _lineAudioBookName();
_ensureNarrator();"""
content = content.replace(old_sync_dots, new_sync_dots)
old_foreach = """ const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }"""
new_foreach = """ const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
rehState.serverCachedLines.add(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }"""
content = content.replace(old_foreach, new_foreach)
with open('static/js/rehearser.js', 'w') as f:
f.write(content)

View File

@ -0,0 +1,48 @@
import re
with open('static/js/rehearser.js', 'r') as f:
content = f.read()
# 1. Revert buildScriptPage change
content = content.replace(
"const isCached = rehState.synthCache.has(i) || (rehState.serverCachedLines && rehState.serverCachedLines.has(i));",
"const isCached = rehState.synthCache.has(i);"
)
# 2. Revert serverCachedLines in _lineAudioSyncDots
old_sync_dots = """let _lineAudioSyncedFor = null; // unused now, kept for compat
async function _lineAudioSyncDots() {
if (!rehState.serverCachedLines) rehState.serverCachedLines = new Set();
const book = _lineAudioBookName();
_ensureNarrator();"""
new_sync_dots = """let _lineAudioSyncedFor = null; // unused now, kept for compat
async function _lineAudioSyncDots() {
const book = _lineAudioBookName();
_ensureNarrator();"""
content = content.replace(old_sync_dots, new_sync_dots)
old_foreach = """ const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
rehState.serverCachedLines.add(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }"""
new_foreach = """ const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }"""
content = content.replace(old_foreach, new_foreach)
with open('static/js/rehearser.js', 'w') as f:
f.write(content)

22
scratch/test_bundle.js Normal file
View File

@ -0,0 +1,22 @@
const { JSDOM } = require("jsdom");
const dom = new JSDOM(`<!DOCTYPE html><html><body><div id="status-message"></div></body></html>`);
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
global.location = dom.window.location;
global.localStorage = { getItem: () => null, setItem: () => null, removeItem: () => null };
global.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
global.$ = (id) => document.getElementById(id) || document.createElement('div');
global.toast = () => {};
global.escapeHtml = () => '';
global.escHtml = () => '';
global.debounce = () => {};
global.audiobookIsChapter = () => false;
global.initCloneSampleText = () => {};
try {
require('../static/dist/main.min.js');
console.log("Bundle executed successfully without top-level errors!");
} catch (e) {
console.error("Bundle error:", e);
}

13
scratch/test_cache.py Normal file
View File

@ -0,0 +1,13 @@
import urllib.request
import urllib.parse
import json
book = "Hohlbein Hennen - DSA - Das Jahr des Greifen 03 - Die Amazone"
url = f"http://127.0.0.1:8010/api/line-audio/{urllib.parse.quote(book)}/check"
data = json.dumps({"keys": ["c79dbe84b9ed7b1780891ba905951bb6", "ee3559287060ae3530fb9d7f088ff9c8"]}).encode('utf-8')
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'}, method='POST')
try:
with urllib.request.urlopen(req) as f:
print(f.read().decode('utf-8'))
except Exception as e:
print(e)

67
scratch_paginate.js Normal file
View File

@ -0,0 +1,67 @@
// Printable content height inside a page (A4 minus vertical padding)
const PAGE_CONTENT = 1027; // 1123 − 56 − 40
const TITLE_SPACE = 64; // page-1 title block allowance
let pageNum = 0, page = null, used = 0;
let isFirstPage = true;
// Pre-scan for the first PDF page number to start numbering correctly
if (respectBreaks) {
for (const b of blocks) {
if (b.classList.contains('reh-block-pagebreak')) {
const i = parseInt(b.dataset.index);
const l = rehState.lines[i];
if (l && l.page) {
// If the pagebreak is for page N, the content before it is N or N-1 depending on how breaks are placed.
// Usually a page break marker MEANS "Start of Page N".
// If the first element is NOT a pagebreak, it means the content belongs to the page BEFORE the first pagebreak.
// We'll just set pageNum to l.page - 1 (or 1 if it's 1).
pageNum = Math.max(0, l.page - (b === blocks[0] ? 0 : 1));
}
break;
}
}
}
const newPage = (forcedNum) => {
if (forcedNum !== undefined) pageNum = forcedNum;
else pageNum++;
page = document.createElement('div');
page.className = 'reh-paper';
const num = document.createElement('div');
num.className = 'reh-paper-num';
num.textContent = pageNum + '.';
page.appendChild(num);
used = 0;
if (isFirstPage && titleEl) {
const t = titleEl.cloneNode(true);
t.style.display = '';
page.appendChild(t);
used += TITLE_SPACE;
}
isFirstPage = false;
wrap.appendChild(page);
};
newPage();
for (const block of blocks) {
// PDF page-break marker: always start a new page here
if (respectBreaks && block.classList.contains('reh-block-pagebreak')) {
const i = parseInt(block.dataset.index);
const l = rehState.lines[i];
newPage(l && l.page ? l.page : undefined);
continue; // don't move the marker div into the page
}
// Measure block height (must be in DOM to measure → append then check)
page.appendChild(block);
const cs = getComputedStyle(block);
const h = block.offsetHeight + (parseFloat(cs.marginTop) || 0) + (parseFloat(cs.marginBottom) || 0);
// If adding this block overflows the page (and page already has content), move to a new page
if (!respectBreaks && used + h > PAGE_CONTENT && used > (isFirstPage ? TITLE_SPACE : 0)) {
newPage();
page.appendChild(block);
}
used += h;
}

49
scratchpad_prefetch.js Normal file
View File

@ -0,0 +1,49 @@
let _rehPrefetchRunning = false;
async function _rehPrefetchLines(fromIndex) {
if (_rehPrefetchRunning) return;
_rehPrefetchRunning = true;
try {
let prefetched = 0;
for (let i = fromIndex; i < rehState.lines.length && prefetched < 2; i++) {
if (!rehState.playing) break;
const l = rehState.lines[i];
if (!l || l.ignored || l.hidden || l.type === 'pagebreak') continue;
const isDialog = l.type === 'dialog';
const cast = isDialog ? (rehState.cast[l.speaker] || { voice: '' }) : null;
if (isDialog && (!cast.voice || cast.voice === 'me')) continue;
if (!isDialog && (!rehState.narratorVoice || !(l.text || '').trim() || rehState.skipDescriptions)) continue;
if (rehState.synthCache.has(i) && !rehState.staleLines.has(i)) {
prefetched++;
continue;
}
const voice = isDialog ? cast.voice : rehState.narratorVoice;
const cleanTxt = stripMarkdown(l.text);
const instruct = isDialog ? _buildInstruct((cast.instruct || '').trim(), l.emotion, voice) : '';
const text = isDialog ? _rehInlineTone(cleanTxt, l.emotion) : cleanTxt;
const book = _lineAudioBookName();
const cacheKey = await _lineAudioCacheKey(text, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
try {
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
} catch(e) {
console.warn('Prefetch failed for line', i, e);
continue; // skip counting as prefetched if it failed
}
}
rehState.synthCache.set(i, blob);
rehState.staleLines.delete(i);
_markSynthDot(i, 'ok');
prefetched++;
}
} finally {
_rehPrefetchRunning = false;
}
}

15
static/js/dump_state.js Normal file
View File

@ -0,0 +1,15 @@
(function() {
const data = {
linesLength: window.rehState ? (window.rehState.lines || []).length : 'no rehState',
linesSample: window.rehState ? (window.rehState.lines || []).slice(0, 5) : [],
a4Display: document.getElementById('reh-a4-page') ? document.getElementById('reh-a4-page').style.display : 'no a4',
wrapChildrenCount: document.querySelector('.reh-page-wrap') ? document.querySelector('.reh-page-wrap').children.length : 'no wrap',
paperHeights: Array.from(document.querySelectorAll('.reh-paper')).map(p => p.offsetHeight),
paperChildrenCount: Array.from(document.querySelectorAll('.reh-paper')).map(p => p.children.length),
scriptLinesChildrenCount: document.getElementById('reh-script-lines') ? document.getElementById('reh-script-lines').children.length : 'no script-lines',
pageMode: window._pageMode || 'unknown',
skipDesc: window.rehState ? window.rehState.skipDescriptions : 'unknown'
};
console.log(JSON.stringify(data, null, 2));
alert(JSON.stringify(data, null, 2));
})();

View File

@ -0,0 +1,30 @@
{
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "flux1-dev-fp8.safetensors"}
},
"2": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "", "clip": ["1", 1]}
},
"3": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry, low resolution, distorted face, duplicate person, extra limbs, malformed hands, cropped head, readable text, logo, watermark", "clip": ["1", 1]}
},
"4": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 1024, "height": 1024, "batch_size": 1}
},
"5": {
"class_type": "KSampler",
"inputs": {"seed": 0, "steps": 28, "cfg": 3.5, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], "latent_image": ["4", 0]}
},
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]}
},
"7": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "voice-creator/portrait", "images": ["6", 0]}
}
}