// ── Script parser (extracted from rehearser.js — global scope, load before it) ── // Pure functions: parse screenplay/theatre text into typed lines, and derive the // initial cast. No load-time dependencies (SPEAKER_COLORS is read only at call time). function parseScript(text) { const rawLines = text.split('\n'); const result = []; let state = 'action', currentSpeaker = null, dialogBuffer = [], actionBuffer = []; // Screenplay title page: drop everything before the first FADE IN / scene heading // so title-page ALL-CAPS lines (movie title, revision marks, studio info) aren't // read as characters. Only kicks in when the script actually has screenplay markers // (leaves theatre / "NAME:" scripts parsed from the top). const FADE_IN_RE = /^FADE\s+IN[\s:.\-]*$/i; const FIRST_SCENE_RE = /^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?(INT\.|EXT\.|INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.)/i; let scanLines = rawLines; const firstIdx = rawLines.findIndex(l => { const s = l.trim(); return FADE_IN_RE.test(s) || FIRST_SCENE_RE.test(s); }); if (firstIdx > 0) scanLines = rawLines.slice(firstIdx); function flushDialog() { if (currentSpeaker && dialogBuffer.length) { const t = dialogBuffer.join(' ').trim(); if (t) result.push({ type:'dialog', speaker:currentSpeaker, text:t, isDirection:false, emotion:'' }); } dialogBuffer = []; } function flushAction() { if (actionBuffer.length) { const t = actionBuffer.join(' ').trim(); if (t) result.push({ type:'action', speaker:'', text:t, isDirection:true }); actionBuffer = []; } } for (const rawLine of scanLines) { const line = rawLine.trim(); if (!line) { flushDialog(); flushAction(); if (state === 'dialog') { state = 'action'; currentSpeaker = null; } continue; } // Screenplay noise — never characters or dialogue: dated page slugs/headers/footers // and revision marks, lone scene numbers, CONTINUED and OMITTED markers. if ( (/\d{1,2}\/\d{1,2}\/\d{2,4}/.test(line) && line.length <= 60) || /^[A-Z]{0,3}\d{1,4}[A-Z]?$/.test(line) || /^\(?CONTINUED\)?:?$/i.test(line) || /^[A-Z]{0,3}\d{1,4}[A-Z]?\s+CONTINUED:?(\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line) || /^(?:[A-Z]{0,3}\d{1,4}[A-Z]?\s+)?OMITTED(?:\s*[A-Z]{0,3}\d{1,4}[A-Z]?)?$/i.test(line) ) { flushDialog(); flushAction(); if (state === 'dialog') { state = 'action'; currentSpeaker = null; } continue; } // PDF page-break marker — preserved from importPDFScript if (line === '\f') { flushDialog(); flushAction(); result.push({ type: 'pagebreak', speaker: '', text: '', isDirection: true }); state = 'action'; currentSpeaker = null; continue; } if (line.startsWith('#')) { flushDialog(); flushAction(); const dir = line.slice(1).trim(); if (dir) result.push({ type:'direction', speaker:'', text:dir, isDirection:true }); state = 'action'; currentSpeaker = null; continue; } // [Bracket] theatre stage direction if (/^\[.*\]$/.test(line)) { if (state === 'dialog') { flushDialog(); state = 'character'; } result.push({ type:'direction', speaker:currentSpeaker||'', text:line.slice(1,-1), isDirection:true }); continue; } // Screenplay scene heading — tolerates flanking scene numbers ("A1 EXT. … EVENINGA1") { const m = line.match(/^(?:([A-Z]{0,3}\d{1,4}[A-Z]?)\s+)?((?:INT\.\/EXT\.|EXT\.\/INT\.|I\/E\.|INT\.|EXT\.).*)$/i); if (m) { flushDialog(); flushAction(); let head = m[2].trim(); // Trailing scene number is usually the leading one repeated (e.g. "…EVENINGA1") if (m[1] && head.toUpperCase().endsWith(m[1].toUpperCase())) { head = head.slice(0, head.length - m[1].length).trim(); } result.push({ type:'scene', speaker:'', text:head.toUpperCase(), isDirection:true }); state = 'action'; currentSpeaker = null; continue; } } // Theatre ACT heading if (/^ACT\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)) { flushDialog(); flushAction(); result.push({ type:'act', speaker:'', text:line.toUpperCase(), isDirection:true }); state = 'action'; currentSpeaker = null; continue; } // Theatre SCENE heading if (/^SCENE\s+(I{1,4}|V?I{0,3}|[1-9][0-9]?|ONE|TWO|THREE|FOUR|FIVE|SIX|SEVEN|EIGHT|NINE|TEN)(\b.*)?$/i.test(line)) { flushDialog(); flushAction(); result.push({ type:'scene', speaker:'', text:line.toUpperCase(), isDirection:true }); state = 'action'; currentSpeaker = null; continue; } // Cinematic / theatre transitions if (/^(FADE\s+(IN|OUT|TO)|CUT\s+TO|SMASH\s+CUT|MATCH\s+CUT|DISSOLVE\s+TO|BLACKOUT|LIGHTS\s+(UP|DOWN|FADE)|CURTAIN|INTERMISSION|END\s+OF\s+(PLAY|ACT))[.:]?\s*$/i.test(line)) { flushDialog(); flushAction(); result.push({ type:'transition', speaker:'', text:line, isDirection:true }); state = 'action'; currentSpeaker = null; continue; } // "CHAR: dialog" simple format — speaker must be a plausible cue (≤3 words, ≤24 chars) // so title lines / page slugs ("PIRATES OF THE CARIBBEAN: …", "POTC: …") aren't cues const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/); if (colonMatch && colonMatch[1].trim().length <= 24 && colonMatch[1].trim().split(/\s+/).length <= 3) { flushDialog(); flushAction(); currentSpeaker = colonMatch[1].trim(); dialogBuffer = [colonMatch[2].trim()]; state = 'dialog'; continue; } // Parenthetical: (text) alone on a line if (/^\(.*\)$/.test(line)) { if (state === 'dialog') { flushDialog(); state = 'character'; } result.push({ type:'direction', speaker:currentSpeaker||'', text:line, isDirection:true }); continue; } // Character name: ALL-CAPS, optionally followed by (modifier) const nameRaw = line.replace(/\s*\([^)]*\)\s*$/, '').trim(); if ( nameRaw.length >= 2 && nameRaw.length <= 42 && nameRaw === nameRaw.toUpperCase() && /^[A-Z][A-Z0-9 '.\-]+$/.test(nameRaw) && !/^\d+$/.test(nameRaw) && !/\.$/.test(nameRaw) // reject sentence fragments ("FERDINAND.", "EYES OPEN.") ) { flushDialog(); flushAction(); currentSpeaker = nameRaw; state = 'character'; continue; } if (state === 'character') { dialogBuffer = [line]; state = 'dialog'; continue; } if (state === 'dialog') { dialogBuffer.push(line); continue; } actionBuffer.push(line); state = 'action'; } flushDialog(); flushAction(); return result; } function detectCharacters(lines) { const speakers = [...new Set(lines.filter(l => l.type === 'dialog').map(l => l.speaker))]; const cast = {}; speakers.forEach((sp, i) => { cast[sp] = { voice: '', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length], instruct: '', voiceData: null }; }); return cast; }