diff --git a/patch_changelog.py b/patch_changelog.py new file mode 100644 index 0000000..27a21d8 --- /dev/null +++ b/patch_changelog.py @@ -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) + diff --git a/scratch/patch_audiobook.py b/scratch/patch_audiobook.py new file mode 100644 index 0000000..c88973b --- /dev/null +++ b/scratch/patch_audiobook.py @@ -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) diff --git a/scratch/patch_sync_dots.py b/scratch/patch_sync_dots.py new file mode 100644 index 0000000..4a5736c --- /dev/null +++ b/scratch/patch_sync_dots.py @@ -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) diff --git a/scratch/patch_sync_dots2.py b/scratch/patch_sync_dots2.py new file mode 100644 index 0000000..e0d76a4 --- /dev/null +++ b/scratch/patch_sync_dots2.py @@ -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) diff --git a/scratch/test_bundle.js b/scratch/test_bundle.js new file mode 100644 index 0000000..c3dc76a --- /dev/null +++ b/scratch/test_bundle.js @@ -0,0 +1,22 @@ +const { JSDOM } = require("jsdom"); +const dom = new JSDOM(`
`); +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); +} diff --git a/scratch/test_cache.py b/scratch/test_cache.py new file mode 100644 index 0000000..8ff7f92 --- /dev/null +++ b/scratch/test_cache.py @@ -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) diff --git a/scratch_paginate.js b/scratch_paginate.js new file mode 100644 index 0000000..e3b6d47 --- /dev/null +++ b/scratch_paginate.js @@ -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; + } diff --git a/scratchpad_prefetch.js b/scratchpad_prefetch.js new file mode 100644 index 0000000..558a953 --- /dev/null +++ b/scratchpad_prefetch.js @@ -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; + } +} diff --git a/static/js/dump_state.js b/static/js/dump_state.js new file mode 100644 index 0000000..acaf38c --- /dev/null +++ b/static/js/dump_state.js @@ -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)); +})(); diff --git a/workflows/comfyui-character-portrait.json.bak b/workflows/comfyui-character-portrait.json.bak new file mode 100644 index 0000000..3895f09 --- /dev/null +++ b/workflows/comfyui-character-portrait.json.bak @@ -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]} + } +}