refactor: extract rehearser io
This commit is contained in:
parent
f97fd60e43
commit
eaf2f29d62
@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### 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
|
- **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`.
|
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
|
- **Audiobook namespace wiring.** Network helpers and public casting, recast, export, and save actions now register
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@ -121,7 +121,7 @@ architecture change: the larger Audiobook/Rehearser/style split.
|
|||||||
## P1 — Modular frontend architecture
|
## P1 — Modular frontend architecture
|
||||||
|
|
||||||
- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; Rehearser casting, playback, training,
|
- [ ] 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.
|
still in the monolith.
|
||||||
- [x] Extract Rehearser stage display and cast-sidebar controls into `rehearser-stage.js`.
|
- [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
|
- [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`; a compatibility namespace bridge now
|
||||||
|
|||||||
@ -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-cast.js` — `renderCastList`, sort/filter, online voice picker, and character research.
|
||||||
- ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering.
|
- ✅ `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-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-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-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.
|
- ✅ `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/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.
|
- ✅ `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-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
|
- ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; library rendering remains ordered with
|
||||||
in `rehearser.js` until their UI/event-handler dependencies are isolated.
|
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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.24.50">
|
<meta name="app-version" content="1.24.51">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,12 +27,12 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.24.50">
|
<link rel="stylesheet" href="/static/style.css?v=1.24.51">
|
||||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.50">
|
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.51">
|
||||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.50">
|
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.51">
|
||||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.50">
|
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.51">
|
||||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.50">
|
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.51">
|
||||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.50">
|
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.51">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.24.50"></script>
|
<script src="/static/loader.js?v=1.24.51"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
457
static/js/rehearser-io.js
Normal file
457
static/js/rehearser-io.js
Normal file
@ -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,'>').replace(/"/g,'"');
|
||||||
|
let xml = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n`;
|
||||||
|
xml += `<FinalDraft DocumentType="Script" Template="No" Version="1">\n<Content>\n`;
|
||||||
|
rehState.lines.forEach(line => {
|
||||||
|
const t = x(stripMarkdown(line.text));
|
||||||
|
switch (line.type) {
|
||||||
|
case 'act':
|
||||||
|
case 'scene': xml += ` <Paragraph Type="Scene Heading"><Text>${t}</Text></Paragraph>\n`; break;
|
||||||
|
case 'action': xml += ` <Paragraph Type="Action"><Text>${t}</Text></Paragraph>\n`; break;
|
||||||
|
case 'transition': xml += ` <Paragraph Type="Transition"><Text>${t}</Text></Paragraph>\n`; break;
|
||||||
|
case 'direction': xml += ` <Paragraph Type="Parenthetical"><Text>${t}</Text></Paragraph>\n`; break;
|
||||||
|
case 'dialog':
|
||||||
|
xml += ` <Paragraph Type="Character"><Text>${x(line.speaker)}</Text></Paragraph>\n`;
|
||||||
|
xml += ` <Paragraph Type="Dialogue"><Text>${t}</Text></Paragraph>\n`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xml += `</Content>\n</FinalDraft>`;
|
||||||
|
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,'>').replace(/"/g,'"');
|
||||||
|
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<osf:script xmlns:osf="http://www.openscreenplayformat.org/ns/1.0">\n`;
|
||||||
|
xml += ` <osf:title>${x(title)}</osf:title>\n<osf:content>\n`;
|
||||||
|
rehState.lines.forEach(line => {
|
||||||
|
const t = x(stripMarkdown(line.text));
|
||||||
|
switch (line.type) {
|
||||||
|
case 'act':
|
||||||
|
case 'scene': xml += ` <osf:scene-heading>${t}</osf:scene-heading>\n`; break;
|
||||||
|
case 'action': xml += ` <osf:action>${t}</osf:action>\n`; break;
|
||||||
|
case 'transition': xml += ` <osf:transition>${t}</osf:transition>\n`; break;
|
||||||
|
case 'direction': xml += ` <osf:parenthetical speaker="${x(line.speaker)}">${t}</osf:parenthetical>\n`; break;
|
||||||
|
case 'dialog':
|
||||||
|
xml += ` <osf:dialogue>\n <osf:character>${x(line.speaker)}</osf:character>\n <osf:text>${t}</osf:text>\n </osf:dialogue>\n`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xml += `</osf:content>\n</osf:script>`;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@ -122,446 +122,6 @@ window.rehState = rehState;
|
|||||||
|
|
||||||
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
|
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,'>').replace(/"/g,'"');
|
|
||||||
let xml = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n`;
|
|
||||||
xml += `<FinalDraft DocumentType="Script" Template="No" Version="1">\n<Content>\n`;
|
|
||||||
rehState.lines.forEach(line => {
|
|
||||||
const t = x(stripMarkdown(line.text));
|
|
||||||
switch (line.type) {
|
|
||||||
case 'act':
|
|
||||||
case 'scene': xml += ` <Paragraph Type="Scene Heading"><Text>${t}</Text></Paragraph>\n`; break;
|
|
||||||
case 'action': xml += ` <Paragraph Type="Action"><Text>${t}</Text></Paragraph>\n`; break;
|
|
||||||
case 'transition': xml += ` <Paragraph Type="Transition"><Text>${t}</Text></Paragraph>\n`; break;
|
|
||||||
case 'direction': xml += ` <Paragraph Type="Parenthetical"><Text>${t}</Text></Paragraph>\n`; break;
|
|
||||||
case 'dialog':
|
|
||||||
xml += ` <Paragraph Type="Character"><Text>${x(line.speaker)}</Text></Paragraph>\n`;
|
|
||||||
xml += ` <Paragraph Type="Dialogue"><Text>${t}</Text></Paragraph>\n`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
xml += `</Content>\n</FinalDraft>`;
|
|
||||||
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,'>').replace(/"/g,'"');
|
|
||||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<osf:script xmlns:osf="http://www.openscreenplayformat.org/ns/1.0">\n`;
|
|
||||||
xml += ` <osf:title>${x(title)}</osf:title>\n<osf:content>\n`;
|
|
||||||
rehState.lines.forEach(line => {
|
|
||||||
const t = x(stripMarkdown(line.text));
|
|
||||||
switch (line.type) {
|
|
||||||
case 'act':
|
|
||||||
case 'scene': xml += ` <osf:scene-heading>${t}</osf:scene-heading>\n`; break;
|
|
||||||
case 'action': xml += ` <osf:action>${t}</osf:action>\n`; break;
|
|
||||||
case 'transition': xml += ` <osf:transition>${t}</osf:transition>\n`; break;
|
|
||||||
case 'direction': xml += ` <osf:parenthetical speaker="${x(line.speaker)}">${t}</osf:parenthetical>\n`; break;
|
|
||||||
case 'dialog':
|
|
||||||
xml += ` <osf:dialogue>\n <osf:character>${x(line.speaker)}</osf:character>\n <osf:text>${t}</osf:text>\n </osf:dialogue>\n`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
xml += `</osf:content>\n</osf:script>`;
|
|
||||||
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
|
// Fill any empty cast slots from the production's shared character roster
|
||||||
// (character library, matched by book OR tag). Only blanks are filled — an
|
// (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
|
// explicit voice/soul already on the rehearsal always wins. Re-renders if it
|
||||||
|
|||||||
@ -4,9 +4,9 @@
|
|||||||
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
|
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
|
||||||
],
|
],
|
||||||
"lazy": {
|
"lazy": {
|
||||||
"s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"],
|
"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-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"],
|
"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-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-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-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-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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,5 +47,7 @@ test('Rehearser playback module publishes transport APIs after loading', async (
|
|||||||
start: typeof window.TTVC?.rehearser?.playback?.start,
|
start: typeof window.TTVC?.rehearser?.playback?.start,
|
||||||
stop: typeof window.TTVC?.rehearser?.playback?.stop,
|
stop: typeof window.TTVC?.rehearser?.playback?.stop,
|
||||||
synthAll: typeof window.TTVC?.rehearser?.playback?.synthAll,
|
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' });
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user