From eaf2f29d62ed94e676d6bc5b99047d756451f693 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 12:01:38 +0200 Subject: [PATCH] refactor: extract rehearser io --- CHANGELOG.md | 2 + TODO.md | 2 +- VERSION | 2 +- docs/ARCHITECTURE.md | 5 +- static/index.html | 16 +- static/js/rehearser-io.js | 457 ++++++++++++++++++++++++++++++++++++ static/js/rehearser.js | 440 ---------------------------------- static/module-manifest.json | 8 +- tests/lazy-loading.spec.js | 4 +- 9 files changed, 479 insertions(+), 457 deletions(-) create mode 100644 static/js/rehearser-io.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ed60db9..daaee0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Rehearser I/O extraction.** Session serialization, library save/load/autosave, REH/Fountain/FDX/OSF export, and + PDF/session import now live in `rehearser-io.js`. - **Rehearser playback extraction.** WebAudio playback, transport controls, bounded decoded-audio prefetch, persistent line-audio caching, and synth-all controls now live in `rehearser-playback.js`. - **Audiobook namespace wiring.** Network helpers and public casting, recast, export, and save actions now register diff --git a/TODO.md b/TODO.md index 39b1ced..df705a5 100644 --- a/TODO.md +++ b/TODO.md @@ -121,7 +121,7 @@ architecture change: the larger Audiobook/Rehearser/style split. ## P1 — Modular frontend architecture - [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; Rehearser casting, playback, training, - voice design, library, and stage blocks are now isolated, with import/event wiring and remaining Audiobook orchestration + voice design, I/O, library, and stage blocks are now isolated, with import/event wiring and remaining Audiobook orchestration still in the monolith. - [x] Extract Rehearser stage display and cast-sidebar controls into `rehearser-stage.js`. - [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`; a compatibility namespace bridge now diff --git a/VERSION b/VERSION index e607a19..9be6d61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.50 +1.24.51 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c7aebd3..34b4763 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,6 +34,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, and character research. - ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering. - ✅ `rehearser-playback.js` — WebAudio playback, transport state, bounded decoded-audio prefetch, and persistent line-audio cache/synthesis controls. +- ✅ `rehearser-io.js` — rehearsal serialization, library save/load, autosave, session/file export, and screenplay/PDF imports. - ✅ `rehearser-voice-picker.js` — voice preview state, local matching, fish.audio search/import, and candidate assignment; DOM event registration remains in `rehearser-cast.js`. - ✅ `rehearser-cast.js` — cast view, full cast-card rendering, sort/filter state handlers, and delegated cast toolbar wiring. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. @@ -46,8 +47,8 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `static/css/rehearser.css` — isolated Rehearser modal, cast, stage, training, bookshelf, and script-view styles. - ✅ `static/css/audiobook.css` — isolated Reader/Audiobook controls, casting feed, page layout, character overlays, and review styles. - ✅ `rehearser-train.js` — transcript normalization, LCS comparison, and word-level training feedback; microphone/state wiring remains in `rehearser.js`. -- ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain - in `rehearser.js` until their UI/event-handler dependencies are isolated. +- ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; library rendering remains ordered with + the main module because it owns the phase UI. **Pattern (proven):** extract a cohesive block whose only cross-file references resolve at *call time* (event handlers), not at module load. Top-level `const rehState` and top-level `addEventListener` registrations must stay ordered — extract those last and load them after their dependencies. Re-run `npx playwright test` after each extraction. diff --git a/static/index.html b/static/index.html index 577fd77..3782835 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-io.js b/static/js/rehearser-io.js new file mode 100644 index 0000000..25bf77e --- /dev/null +++ b/static/js/rehearser-io.js @@ -0,0 +1,457 @@ +// Rehearser serialization, library I/O, and import/export helpers. +// Loaded before rehearser.js; dependencies resolve when user actions invoke them. + +// ── Serialization ───────────────────────────────────────────────────────── + +async function clipsToJson(clips) { + return Promise.all(clips.map(async c => { + if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type }; + const ab = await c.blob.arrayBuffer(); + const u8 = new Uint8Array(ab); + let bin = ''; + const CHUNK = 8192; + for (let i = 0; i < u8.length; i += CHUNK) + bin += String.fromCharCode(...u8.subarray(i, i + CHUNK)); + return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) }; + })); +} + +function clipsFromJson(clips) { + return clips.map(c => { + if (!c.b64) return c; + const bin = atob(c.b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined }; + }); +} + +function rehCurrentRecord() { + const cast = {}; + Object.entries(rehState.cast).forEach(([sp, c]) => { + cast[sp] = { + voice: c.voice, color: c.color, instruct: c.instruct || '', + lang: c.lang || '', gender: c.gender || '', tags: c.tags || '', soul: c.soul || '', + ignored: !!c.ignored, hidden: !!c.hidden, + }; + }); + const emotions = {}; + const notes = {}; + const ignored = {}; + const hidden = {}; + rehState.lines.forEach((l, i) => { + if (l.type === 'dialog' && l.emotion) emotions[i] = l.emotion; + if (l.type === 'dialog' && l.note) notes[i] = l.note; + if (l.ignored) ignored[i] = true; + if (l.hidden) hidden[i] = true; + }); + return { + title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled', + // Reconstruct from parsed lines so structural edits (deletes) persist; fall back to the + // raw textarea before the script has been parsed into lines. + script: rehState.lines.length ? linesToScriptText() : ($('reh-script-text')?.value.trim() || ''), + cast, emotions, notes, ignored, hidden, + backend: rehState.backend, + narratorVoice: rehState.narratorVoice, + lineIndex: rehState.lineIndex, + clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })), + updated: new Date(), + }; +} + +// Reconstruct script text from current parsed lines (preserves inline edits) +function linesToScriptText() { + return rehState.lines.map(line => { + switch (line.type) { + case 'act': + case 'transition': return '\n' + line.text + '\n'; + case 'scene': return '\n' + line.text + '\n'; + case 'action': return '\n' + line.text + '\n'; + case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n'; + case 'direction': + return line.speaker ? line.text + '\n' : '\n' + line.text + '\n'; + case 'pagebreak': return line.page ? `\n\f${line.page}\n` : '\n\f\n'; + default: return ''; + } + }).join('').trim(); +} + +async function saveToLibrary() { + const rec = rehCurrentRecord(); + if (rehState.savedId) { + rec.id = rehState.savedId; + if (!rec.created) rec.created = new Date(); + await rehDbPut(rec); + } else { + rec.created = new Date(); + const newId = await rehDbAdd(rec); + rehState.savedId = newId; + } + toast('Saved to library', 'success'); + rehState._keepSavedIdOnce = true; + // Sync cast voice choices into the shared character roster (existing chars only) + if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} } + await renderLibraryList(); +} + +// Auto-save a freshly imported/pasted script as its own library entry so it shows up +// in the Library and can be reopened. Returns the new id (or null on failure). +async function rehAutoSaveImport(title) { + const text = ($('reh-script-text')?.value || '').trim(); + if (!text) return null; + if (title && $('reh-script-title') && !$('reh-script-title').value.trim()) $('reh-script-title').value = title; + const rec = { + title: $('reh-script-title')?.value.trim() || title || 'Untitled', + script: text, + cast: {}, emotions: {}, notes: {}, ignored: {}, hidden: {}, + backend: rehState.backend || '', narratorVoice: rehState.narratorVoice || '', + lineIndex: 0, clips: [], created: new Date(), updated: new Date(), + }; + try { + const id = await rehDbAdd(rec); + rehState.savedId = id; + rehState._keepSavedIdOnce = true; // keep this entry through the next Parse & cast + await renderLibraryList(); + return id; + } catch (e) { return null; } +} + +async function exportToFile() { + const rec = rehCurrentRecord(); + rec.version = 1; + rec.created = rec.created || new Date(); + rec.clips = await clipsToJson(rec.clips); + const json = JSON.stringify(rec, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const a = document.createElement('a'); + const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40); + a.href = URL.createObjectURL(blob); + a.download = title + '.reh'; + a.click(); +} + +function exportFountain() { + const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled'; + let out = `Title: ${title}\n\n`; + rehState.lines.forEach(line => { + const t = stripMarkdown(line.text); + switch (line.type) { + case 'act': out += '\n' + t + '\n'; break; + case 'scene': out += '\n' + t + '\n'; break; + case 'action': out += '\n' + t + '\n'; break; + case 'transition': out += '\n' + t + '\n'; break; + case 'dialog': out += '\n' + line.speaker + '\n' + t + '\n'; break; + case 'direction': + out += (line.speaker ? '' : '\n') + t + '\n'; break; + } + }); + const blob = new Blob([out.trim()], { type: 'text/plain' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fountain'; + a.click(); + toast('Exported as .fountain', 'success'); +} + +// ── FDX (Final Draft XML) ────────────────────────────────────────────────── + +function exportFDX() { + const title = $('reh-script-title')?.value.trim() || 'Untitled'; + const x = s => (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + let xml = `\n`; + xml += `\n\n`; + rehState.lines.forEach(line => { + const t = x(stripMarkdown(line.text)); + switch (line.type) { + case 'act': + case 'scene': xml += ` ${t}\n`; break; + case 'action': xml += ` ${t}\n`; break; + case 'transition': xml += ` ${t}\n`; break; + case 'direction': xml += ` ${t}\n`; break; + case 'dialog': + xml += ` ${x(line.speaker)}\n`; + xml += ` ${t}\n`; + break; + } + }); + xml += `\n`; + const blob = new Blob([xml], { type: 'text/xml' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fdx'; + a.click(); + toast('Exported as .fdx (Final Draft XML)', 'success'); +} + +async function importFDX(file) { + const text = await file.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(text, 'text/xml'); + const paras = doc.querySelectorAll('Paragraph'); + let result = ''; + paras.forEach(para => { + const type = para.getAttribute('Type') || ''; + const t = [...para.querySelectorAll('Text')].map(el => el.textContent).join(''); + if (!t.trim()) return; + switch (type) { + case 'Scene Heading': result += '\n' + t + '\n'; break; + case 'Action': result += '\n' + t + '\n'; break; + case 'Character': result += '\n' + t + '\n'; break; + case 'Dialogue': result += t + '\n'; break; + case 'Parenthetical': result += t + '\n'; break; + case 'Transition': result += '\n' + t + '\n'; break; + } + }); + return result.trim(); +} + +// OSF (Open Screenplay Format — application-agnostic XML) +function exportOSF() { + const title = $('reh-script-title')?.value.trim() || 'Untitled'; + const x = s => (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + let xml = `\n\n`; + xml += ` ${x(title)}\n\n`; + rehState.lines.forEach(line => { + const t = x(stripMarkdown(line.text)); + switch (line.type) { + case 'act': + case 'scene': xml += ` ${t}\n`; break; + case 'action': xml += ` ${t}\n`; break; + case 'transition': xml += ` ${t}\n`; break; + case 'direction': xml += ` ${t}\n`; break; + case 'dialog': + xml += ` \n ${x(line.speaker)}\n ${t}\n \n`; + break; + } + }); + xml += `\n`; + const blob = new Blob([xml], { type: 'text/xml' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.osf'; + a.click(); + toast('Exported as .osf (Open Screenplay Format)', 'success'); +} + +// ── PDF import (via pdf.js from CDN) ────────────────────────────────────── + +async function loadPdfJs() { + if (window.pdfjsLib) return; + await new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = '/static/js/pdf/pdf.min.js'; + s.onload = resolve; s.onerror = reject; + document.head.appendChild(s); + }); + pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js'; +} + +// Extract a screenplay from a PDF, reconstructing element types from the +// horizontal indentation that screenplays use (the layout *is* the meaning): +// action/scene ~ left margin character ~ deeply indented (caps) +// dialogue ~ medium indent parenthetical ~ indented "(...)" +// transition ~ right-aligned +async function importPDFScript(file) { + toast('Loading PDF reader…', 'success'); + await loadPdfJs(); + + const ab = await file.arrayBuffer(); + const pdf = await pdfjsLib.getDocument({ data: ab }).promise; + + // First pass: collect every line {x, right, text} per page + const allLinesByPage = []; + const allLines = []; // flat list for margin/width detection + for (let p = 1; p <= pdf.numPages; p++) { + const page = await pdf.getPage(p); + const content = await page.getTextContent(); + const viewport = page.getViewport({ scale: 1 }); + const pageW = viewport.width; + + // Group items into visual rows by Y (PDF Y is bottom-up) + const rows = new Map(); + content.items.forEach(item => { + if (!item.str) return; + const y = Math.round(item.transform[5] / 3) * 3; + if (!rows.has(y)) rows.set(y, []); + rows.get(y).push({ x: item.transform[4], w: item.width || 0, str: item.str }); + }); + + const pageLines = []; + [...rows.entries()].sort((a, b) => b[0] - a[0]).forEach(([, items]) => { + items.sort((a, b) => a.x - b.x); + const text = items.map(i => i.str).join('').replace(/\s+/g, ' ').trim(); + if (!text) return; + const x = items[0].x; + const last = items[items.length - 1]; + const right = last.x + (last.w || 0); + pageLines.push({ x, right, pageW, text }); + allLines.push({ x, right, pageW, text }); + }); + allLinesByPage.push(pageLines); + } + if (!allLines.length) return ''; + + // Detect the left margin (the most common / smallest x of body lines) + const leftMargin = Math.min(...allLines.map(l => l.x)); + const pageW = allLines[0].pageW || 612; + + // Noise filter: lone page numbers, scene numbers, "CONTINUED", revision marks + const isNoise = t => { + const s = t.trim(); + if (/^\d{1,4}\.?$/.test(s)) return true; // bare page/scene number + if (/^\(?CONTINUED\)?:?$/i.test(s)) return true; + if (/^\d+\.$/.test(s)) return true; + if (/^(rev\.|revised|draft)\b/i.test(s) && s.length < 24) return true; + return false; + }; + + const out = []; + let prevBlank = true; + let prevWasAction = false; // track consecutive action lines so we don't split paragraphs + const push = (line, blankBefore) => { + if (blankBefore && !prevBlank) { out.push(''); } + out.push(line); + prevBlank = false; + }; + const blank = () => { if (!prevBlank) { out.push(''); prevBlank = true; } }; + const nonAction = () => { prevWasAction = false; }; + + for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) { + // Emit a page-break marker between PDF pages so the rehearser can paginate exactly + if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); } + + const pageLns = allLinesByPage[pageIdx] || []; + for (const ln of pageLns) { + const t = ln.text.trim(); + if (!t || isNoise(t)) continue; + + const indent = (ln.x - leftMargin) / pageW; // fraction of page width + const rightFrac = ln.right / pageW; + const isCaps = t === t.toUpperCase() && /[A-Z]/.test(t); + const wordCount = t.split(/\s+/).length; + + // Scene heading (slug line) + if (/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)) { + blank(); push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/, '')); blank(); nonAction(); + continue; + } + // Act / scene number headings (theatre) + if (/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)) { + blank(); push(t.toUpperCase()); blank(); nonAction(); + continue; + } + // Transition (right-aligned, caps, ends with TO: / IN / OUT) + if (isCaps && (rightFrac > 0.72 || indent > 0.45) && + /(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)) { + blank(); push(t); blank(); nonAction(); + continue; + } + // Parenthetical + if (/^\(.*\)?$/.test(t) || (indent > 0.18 && indent < 0.30 && /^\(/.test(t))) { + push(t.startsWith('(') ? t : '(' + t + ')', false); nonAction(); + continue; + } + // Character cue: deeply indented + caps + short (often has (CONT'D)/(V.O.)) + const nameCore = t.replace(/\s*\([^)]*\)\s*$/, '').trim(); + if (indent > 0.22 && isCaps && wordCount <= 6 && nameCore.length <= 40 && + /^[A-Z0-9 .'#\-]+$/.test(nameCore)) { + blank(); push(t.toUpperCase()); prevBlank = false; nonAction(); // name; dialog follows next line + continue; + } + // Dialogue: medium indent (under a character) + if (indent > 0.10 && indent < 0.34) { + push(t, false); nonAction(); + continue; + } + // Default: action / description at the left margin. + // Consecutive action lines join into one paragraph (no blank between them); + // only start a new paragraph when coming from a non-action line. + if (prevWasAction) { + out.push(t); // continue same paragraph + } else { + blank(); push(t); // new paragraph + } + prevWasAction = true; + } // end per-page line loop + } // end page loop + + return out.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +async function importFromFile(file) { + try { + const text = await file.text(); + const data = JSON.parse(text); + data.clips = clipsFromJson(data.clips || []); + data.created = data.created ? new Date(data.created) : new Date(); + data.updated = new Date(); + delete data.id; + const newId = await rehDbAdd(data); + toast('Imported: ' + (data.title || 'Untitled'), 'success'); + await renderLibraryList(); + loadRecord({ ...data, id: newId }); + } catch(e) { toast('Import failed: ' + e.message, 'error'); } +} + +function loadRecord(rec) { + // Opening a record from the Library (s-library) must bring the Rehearser + // section into view — without this, showPhase(2) below just swaps a phase + // div inside #s-rehearser while that whole section stays display:none, + // so nothing visibly happens and the click looks like it did nothing. + if (typeof navTo === 'function') navTo('s-rehearser'); + if ($('reh-script-text')) $('reh-script-text').value = rec.script || ''; + if ($('reh-script-title')) $('reh-script-title').value = rec.title || ''; + rehState.savedId = rec.id || null; + rehState._keepSavedIdOnce = true; // re-parsing a loaded entry keeps updating it + rehState.backend = rec.backend || ''; + rehState.lineIndex = rec.lineIndex || 0; + rehState.clips = (rec.clips || []).map(c => ({ ...c })); + rehState.synthCache.clear(); rehDecodedBuffers.clear(); + rehState.narratorVoice = rec.narratorVoice || ''; + const lines = parseScript(rec.script || ''); + if (rec.emotions) { + lines.forEach((l, i) => { if (l.type === 'dialog' && rec.emotions[i]) l.emotion = rec.emotions[i]; }); + } + if (rec.notes) { + lines.forEach((l, i) => { if (l.type === 'dialog' && rec.notes[i]) l.note = rec.notes[i]; }); + } + if (rec.ignored) lines.forEach((l, i) => { if (rec.ignored[i]) l.ignored = true; }); + if (rec.hidden) lines.forEach((l, i) => { if (rec.hidden[i]) l.hidden = true; }); + rehState.lines = lines; + rehState.cast = {}; + const detected = detectCharacters(lines); + Object.entries(detected).forEach(([sp, def]) => { + const saved = rec.cast?.[sp]; + rehState.cast[sp] = { + voice: saved?.voice ?? def.voice, + color: saved?.color ?? def.color, + instruct: saved?.instruct || '', + lang: saved?.lang || '', gender: saved?.gender || '', + tags: saved?.tags || '', soul: saved?.soul || '', + ignored: !!saved?.ignored, hidden: !!saved?.hidden, + voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null, + }; + }); + renderCastList(); + rehApplySharedCast(rec.title); + refreshRehBackends().then(() => { + if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend; + if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice; + }); + showPhase(2); +} + + +window.TTVC=window.TTVC||{}; +window.TTVC.rehearser=window.TTVC.rehearser||{}; +Object.assign(window.TTVC.rehearser,{ + io:{ + currentRecord:rehCurrentRecord, + save:saveToLibrary, + autoSaveImport:rehAutoSaveImport, + exportSession:exportToFile, + importSession:importFromFile, + loadRecord, + }, +}); + diff --git a/static/js/rehearser.js b/static/js/rehearser.js index f4566ee..44fe9ab 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -122,446 +122,6 @@ window.rehState = rehState; window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly -// ── Serialization ───────────────────────────────────────────────────────── - -async function clipsToJson(clips) { - return Promise.all(clips.map(async c => { - if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type }; - const ab = await c.blob.arrayBuffer(); - const u8 = new Uint8Array(ab); - let bin = ''; - const CHUNK = 8192; - for (let i = 0; i < u8.length; i += CHUNK) - bin += String.fromCharCode(...u8.subarray(i, i + CHUNK)); - return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) }; - })); -} - -function clipsFromJson(clips) { - return clips.map(c => { - if (!c.b64) return c; - const bin = atob(c.b64); - const u8 = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); - return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined }; - }); -} - -function rehCurrentRecord() { - const cast = {}; - Object.entries(rehState.cast).forEach(([sp, c]) => { - cast[sp] = { - voice: c.voice, color: c.color, instruct: c.instruct || '', - lang: c.lang || '', gender: c.gender || '', tags: c.tags || '', soul: c.soul || '', - ignored: !!c.ignored, hidden: !!c.hidden, - }; - }); - const emotions = {}; - const notes = {}; - const ignored = {}; - const hidden = {}; - rehState.lines.forEach((l, i) => { - if (l.type === 'dialog' && l.emotion) emotions[i] = l.emotion; - if (l.type === 'dialog' && l.note) notes[i] = l.note; - if (l.ignored) ignored[i] = true; - if (l.hidden) hidden[i] = true; - }); - return { - title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled', - // Reconstruct from parsed lines so structural edits (deletes) persist; fall back to the - // raw textarea before the script has been parsed into lines. - script: rehState.lines.length ? linesToScriptText() : ($('reh-script-text')?.value.trim() || ''), - cast, emotions, notes, ignored, hidden, - backend: rehState.backend, - narratorVoice: rehState.narratorVoice, - lineIndex: rehState.lineIndex, - clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })), - updated: new Date(), - }; -} - -// Reconstruct script text from current parsed lines (preserves inline edits) -function linesToScriptText() { - return rehState.lines.map(line => { - switch (line.type) { - case 'act': - case 'transition': return '\n' + line.text + '\n'; - case 'scene': return '\n' + line.text + '\n'; - case 'action': return '\n' + line.text + '\n'; - case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n'; - case 'direction': - return line.speaker ? line.text + '\n' : '\n' + line.text + '\n'; - case 'pagebreak': return line.page ? `\n\f${line.page}\n` : '\n\f\n'; - default: return ''; - } - }).join('').trim(); -} - -async function saveToLibrary() { - const rec = rehCurrentRecord(); - if (rehState.savedId) { - rec.id = rehState.savedId; - if (!rec.created) rec.created = new Date(); - await rehDbPut(rec); - } else { - rec.created = new Date(); - const newId = await rehDbAdd(rec); - rehState.savedId = newId; - } - toast('Saved to library', 'success'); - rehState._keepSavedIdOnce = true; - // Sync cast voice choices into the shared character roster (existing chars only) - if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} } - await renderLibraryList(); -} - -// Auto-save a freshly imported/pasted script as its own library entry so it shows up -// in the Library and can be reopened. Returns the new id (or null on failure). -async function rehAutoSaveImport(title) { - const text = ($('reh-script-text')?.value || '').trim(); - if (!text) return null; - if (title && $('reh-script-title') && !$('reh-script-title').value.trim()) $('reh-script-title').value = title; - const rec = { - title: $('reh-script-title')?.value.trim() || title || 'Untitled', - script: text, - cast: {}, emotions: {}, notes: {}, ignored: {}, hidden: {}, - backend: rehState.backend || '', narratorVoice: rehState.narratorVoice || '', - lineIndex: 0, clips: [], created: new Date(), updated: new Date(), - }; - try { - const id = await rehDbAdd(rec); - rehState.savedId = id; - rehState._keepSavedIdOnce = true; // keep this entry through the next Parse & cast - await renderLibraryList(); - return id; - } catch (e) { return null; } -} - -async function exportToFile() { - const rec = rehCurrentRecord(); - rec.version = 1; - rec.created = rec.created || new Date(); - rec.clips = await clipsToJson(rec.clips); - const json = JSON.stringify(rec, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const a = document.createElement('a'); - const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40); - a.href = URL.createObjectURL(blob); - a.download = title + '.reh'; - a.click(); -} - -function exportFountain() { - const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled'; - let out = `Title: ${title}\n\n`; - rehState.lines.forEach(line => { - const t = stripMarkdown(line.text); - switch (line.type) { - case 'act': out += '\n' + t + '\n'; break; - case 'scene': out += '\n' + t + '\n'; break; - case 'action': out += '\n' + t + '\n'; break; - case 'transition': out += '\n' + t + '\n'; break; - case 'dialog': out += '\n' + line.speaker + '\n' + t + '\n'; break; - case 'direction': - out += (line.speaker ? '' : '\n') + t + '\n'; break; - } - }); - const blob = new Blob([out.trim()], { type: 'text/plain' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fountain'; - a.click(); - toast('Exported as .fountain', 'success'); -} - -// ── FDX (Final Draft XML) ────────────────────────────────────────────────── - -function exportFDX() { - const title = $('reh-script-title')?.value.trim() || 'Untitled'; - const x = s => (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); - let xml = `\n`; - xml += `\n\n`; - rehState.lines.forEach(line => { - const t = x(stripMarkdown(line.text)); - switch (line.type) { - case 'act': - case 'scene': xml += ` ${t}\n`; break; - case 'action': xml += ` ${t}\n`; break; - case 'transition': xml += ` ${t}\n`; break; - case 'direction': xml += ` ${t}\n`; break; - case 'dialog': - xml += ` ${x(line.speaker)}\n`; - xml += ` ${t}\n`; - break; - } - }); - xml += `\n`; - const blob = new Blob([xml], { type: 'text/xml' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fdx'; - a.click(); - toast('Exported as .fdx (Final Draft XML)', 'success'); -} - -async function importFDX(file) { - const text = await file.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(text, 'text/xml'); - const paras = doc.querySelectorAll('Paragraph'); - let result = ''; - paras.forEach(para => { - const type = para.getAttribute('Type') || ''; - const t = [...para.querySelectorAll('Text')].map(el => el.textContent).join(''); - if (!t.trim()) return; - switch (type) { - case 'Scene Heading': result += '\n' + t + '\n'; break; - case 'Action': result += '\n' + t + '\n'; break; - case 'Character': result += '\n' + t + '\n'; break; - case 'Dialogue': result += t + '\n'; break; - case 'Parenthetical': result += t + '\n'; break; - case 'Transition': result += '\n' + t + '\n'; break; - } - }); - return result.trim(); -} - -// OSF (Open Screenplay Format — application-agnostic XML) -function exportOSF() { - const title = $('reh-script-title')?.value.trim() || 'Untitled'; - const x = s => (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); - let xml = `\n\n`; - xml += ` ${x(title)}\n\n`; - rehState.lines.forEach(line => { - const t = x(stripMarkdown(line.text)); - switch (line.type) { - case 'act': - case 'scene': xml += ` ${t}\n`; break; - case 'action': xml += ` ${t}\n`; break; - case 'transition': xml += ` ${t}\n`; break; - case 'direction': xml += ` ${t}\n`; break; - case 'dialog': - xml += ` \n ${x(line.speaker)}\n ${t}\n \n`; - break; - } - }); - xml += `\n`; - const blob = new Blob([xml], { type: 'text/xml' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.osf'; - a.click(); - toast('Exported as .osf (Open Screenplay Format)', 'success'); -} - -// ── PDF import (via pdf.js from CDN) ────────────────────────────────────── - -async function loadPdfJs() { - if (window.pdfjsLib) return; - await new Promise((resolve, reject) => { - const s = document.createElement('script'); - s.src = '/static/js/pdf/pdf.min.js'; - s.onload = resolve; s.onerror = reject; - document.head.appendChild(s); - }); - pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js'; -} - -// Extract a screenplay from a PDF, reconstructing element types from the -// horizontal indentation that screenplays use (the layout *is* the meaning): -// action/scene ~ left margin character ~ deeply indented (caps) -// dialogue ~ medium indent parenthetical ~ indented "(...)" -// transition ~ right-aligned -async function importPDFScript(file) { - toast('Loading PDF reader…', 'success'); - await loadPdfJs(); - - const ab = await file.arrayBuffer(); - const pdf = await pdfjsLib.getDocument({ data: ab }).promise; - - // First pass: collect every line {x, right, text} per page - const allLinesByPage = []; - const allLines = []; // flat list for margin/width detection - for (let p = 1; p <= pdf.numPages; p++) { - const page = await pdf.getPage(p); - const content = await page.getTextContent(); - const viewport = page.getViewport({ scale: 1 }); - const pageW = viewport.width; - - // Group items into visual rows by Y (PDF Y is bottom-up) - const rows = new Map(); - content.items.forEach(item => { - if (!item.str) return; - const y = Math.round(item.transform[5] / 3) * 3; - if (!rows.has(y)) rows.set(y, []); - rows.get(y).push({ x: item.transform[4], w: item.width || 0, str: item.str }); - }); - - const pageLines = []; - [...rows.entries()].sort((a, b) => b[0] - a[0]).forEach(([, items]) => { - items.sort((a, b) => a.x - b.x); - const text = items.map(i => i.str).join('').replace(/\s+/g, ' ').trim(); - if (!text) return; - const x = items[0].x; - const last = items[items.length - 1]; - const right = last.x + (last.w || 0); - pageLines.push({ x, right, pageW, text }); - allLines.push({ x, right, pageW, text }); - }); - allLinesByPage.push(pageLines); - } - if (!allLines.length) return ''; - - // Detect the left margin (the most common / smallest x of body lines) - const leftMargin = Math.min(...allLines.map(l => l.x)); - const pageW = allLines[0].pageW || 612; - - // Noise filter: lone page numbers, scene numbers, "CONTINUED", revision marks - const isNoise = t => { - const s = t.trim(); - if (/^\d{1,4}\.?$/.test(s)) return true; // bare page/scene number - if (/^\(?CONTINUED\)?:?$/i.test(s)) return true; - if (/^\d+\.$/.test(s)) return true; - if (/^(rev\.|revised|draft)\b/i.test(s) && s.length < 24) return true; - return false; - }; - - const out = []; - let prevBlank = true; - let prevWasAction = false; // track consecutive action lines so we don't split paragraphs - const push = (line, blankBefore) => { - if (blankBefore && !prevBlank) { out.push(''); } - out.push(line); - prevBlank = false; - }; - const blank = () => { if (!prevBlank) { out.push(''); prevBlank = true; } }; - const nonAction = () => { prevWasAction = false; }; - - for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) { - // Emit a page-break marker between PDF pages so the rehearser can paginate exactly - if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); } - - const pageLns = allLinesByPage[pageIdx] || []; - for (const ln of pageLns) { - const t = ln.text.trim(); - if (!t || isNoise(t)) continue; - - const indent = (ln.x - leftMargin) / pageW; // fraction of page width - const rightFrac = ln.right / pageW; - const isCaps = t === t.toUpperCase() && /[A-Z]/.test(t); - const wordCount = t.split(/\s+/).length; - - // Scene heading (slug line) - if (/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)) { - blank(); push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/, '')); blank(); nonAction(); - continue; - } - // Act / scene number headings (theatre) - if (/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)) { - blank(); push(t.toUpperCase()); blank(); nonAction(); - continue; - } - // Transition (right-aligned, caps, ends with TO: / IN / OUT) - if (isCaps && (rightFrac > 0.72 || indent > 0.45) && - /(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)) { - blank(); push(t); blank(); nonAction(); - continue; - } - // Parenthetical - if (/^\(.*\)?$/.test(t) || (indent > 0.18 && indent < 0.30 && /^\(/.test(t))) { - push(t.startsWith('(') ? t : '(' + t + ')', false); nonAction(); - continue; - } - // Character cue: deeply indented + caps + short (often has (CONT'D)/(V.O.)) - const nameCore = t.replace(/\s*\([^)]*\)\s*$/, '').trim(); - if (indent > 0.22 && isCaps && wordCount <= 6 && nameCore.length <= 40 && - /^[A-Z0-9 .'#\-]+$/.test(nameCore)) { - blank(); push(t.toUpperCase()); prevBlank = false; nonAction(); // name; dialog follows next line - continue; - } - // Dialogue: medium indent (under a character) - if (indent > 0.10 && indent < 0.34) { - push(t, false); nonAction(); - continue; - } - // Default: action / description at the left margin. - // Consecutive action lines join into one paragraph (no blank between them); - // only start a new paragraph when coming from a non-action line. - if (prevWasAction) { - out.push(t); // continue same paragraph - } else { - blank(); push(t); // new paragraph - } - prevWasAction = true; - } // end per-page line loop - } // end page loop - - return out.join('\n').replace(/\n{3,}/g, '\n\n').trim(); -} - -async function importFromFile(file) { - try { - const text = await file.text(); - const data = JSON.parse(text); - data.clips = clipsFromJson(data.clips || []); - data.created = data.created ? new Date(data.created) : new Date(); - data.updated = new Date(); - delete data.id; - const newId = await rehDbAdd(data); - toast('Imported: ' + (data.title || 'Untitled'), 'success'); - await renderLibraryList(); - loadRecord({ ...data, id: newId }); - } catch(e) { toast('Import failed: ' + e.message, 'error'); } -} - -function loadRecord(rec) { - // Opening a record from the Library (s-library) must bring the Rehearser - // section into view — without this, showPhase(2) below just swaps a phase - // div inside #s-rehearser while that whole section stays display:none, - // so nothing visibly happens and the click looks like it did nothing. - if (typeof navTo === 'function') navTo('s-rehearser'); - if ($('reh-script-text')) $('reh-script-text').value = rec.script || ''; - if ($('reh-script-title')) $('reh-script-title').value = rec.title || ''; - rehState.savedId = rec.id || null; - rehState._keepSavedIdOnce = true; // re-parsing a loaded entry keeps updating it - rehState.backend = rec.backend || ''; - rehState.lineIndex = rec.lineIndex || 0; - rehState.clips = (rec.clips || []).map(c => ({ ...c })); - rehState.synthCache.clear(); rehDecodedBuffers.clear(); - rehState.narratorVoice = rec.narratorVoice || ''; - const lines = parseScript(rec.script || ''); - if (rec.emotions) { - lines.forEach((l, i) => { if (l.type === 'dialog' && rec.emotions[i]) l.emotion = rec.emotions[i]; }); - } - if (rec.notes) { - lines.forEach((l, i) => { if (l.type === 'dialog' && rec.notes[i]) l.note = rec.notes[i]; }); - } - if (rec.ignored) lines.forEach((l, i) => { if (rec.ignored[i]) l.ignored = true; }); - if (rec.hidden) lines.forEach((l, i) => { if (rec.hidden[i]) l.hidden = true; }); - rehState.lines = lines; - rehState.cast = {}; - const detected = detectCharacters(lines); - Object.entries(detected).forEach(([sp, def]) => { - const saved = rec.cast?.[sp]; - rehState.cast[sp] = { - voice: saved?.voice ?? def.voice, - color: saved?.color ?? def.color, - instruct: saved?.instruct || '', - lang: saved?.lang || '', gender: saved?.gender || '', - tags: saved?.tags || '', soul: saved?.soul || '', - ignored: !!saved?.ignored, hidden: !!saved?.hidden, - voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null, - }; - }); - renderCastList(); - rehApplySharedCast(rec.title); - refreshRehBackends().then(() => { - if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend; - if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice; - }); - showPhase(2); -} - // Fill any empty cast slots from the production's shared character roster // (character library, matched by book OR tag). Only blanks are filled — an // explicit voice/soul already on the rehearsal always wins. Re-renders if it diff --git a/static/module-manifest.json b/static/module-manifest.json index 971da8d..3022999 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -4,9 +4,9 @@ "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" ], "lazy": { - "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], + "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } } diff --git a/tests/lazy-loading.spec.js b/tests/lazy-loading.spec.js index 3d68943..037473c 100644 --- a/tests/lazy-loading.spec.js +++ b/tests/lazy-loading.spec.js @@ -47,5 +47,7 @@ test('Rehearser playback module publishes transport APIs after loading', async ( start: typeof window.TTVC?.rehearser?.playback?.start, stop: typeof window.TTVC?.rehearser?.playback?.stop, synthAll: typeof window.TTVC?.rehearser?.playback?.synthAll, - }))).toEqual({ start: 'function', stop: 'function', synthAll: 'function' }); + loadRecord: typeof window.TTVC?.rehearser?.io?.loadRecord, + exportSession: typeof window.TTVC?.rehearser?.io?.exportSession, + }))).toEqual({ start: 'function', stop: 'function', synthAll: 'function', loadRecord: 'function', exportSession: 'function' }); });