// Rehearser serialization, library I/O, and import/export helpers. // Loaded before rehearser.js; dependencies resolve when user actions invoke them. (function (root) { // ── 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); } // Preserve the classic-script surface for existing handlers while keeping I/O // implementation details private to this module. Object.assign(root, { clipsToJson, clipsFromJson, rehCurrentRecord, linesToScriptText, saveToLibrary, rehAutoSaveImport, exportToFile, exportFountain, exportFDX, importFDX, exportOSF, loadPdfJs, importPDFScript, importFromFile, loadRecord, }); root.TTVC=root.TTVC||{}; root.TTVC.rehearser=root.TTVC.rehearser||{}; Object.assign(root.TTVC.rehearser,{ io:{ currentRecord:rehCurrentRecord, save:saveToLibrary, autoSaveImport:rehAutoSaveImport, exportSession:exportToFile, importSession:importFromFile, loadRecord, }, }); })(window);