Introduces the new Studio section (Source -> Characters -> Voices -> Perform & Export) that reuses the existing Read Aloud/Library/Script Rehearsal code via DOM reparenting instead of duplicating it, and rolls up a long tail of bugs found while producing a real audiobook through it: umlaut-eating name sanitizers, a voice picker that mispositioned itself and capped results at 60, PDF pagination silently breaking on trimmed \f markers, a race letting stale audio keep playing after a new line was clicked, an alias-overlap bug that could silently redirect a voice/image save onto the wrong character, voice design failing outright during brief TTS backend restarts instead of retrying, sparse cast entries defaulting to English/wrong gender, and a reassigned voice never reaching an already-open Stage session or invalidating its cached audio. Also adds a persistent per-line audio cache, audiobook export browsing/download, and an inline voice-design prompt editor. Full details in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
195 lines
8.6 KiB
JavaScript
195 lines
8.6 KiB
JavaScript
// ── 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) {
|
|
// \f (form feed) is one of the characters JS's own .trim() strips as
|
|
// whitespace — so the page-break check further down (which tests the
|
|
// ALREADY-trimmed `line`) could never actually match, silently eating
|
|
// every "\f<pageNum>" marker before it was ever recognized. Confirmed
|
|
// live: a real book with 232 page marks in its source text produced
|
|
// zero pagebreak lines after parsing, and "PDF pages" mode (which only
|
|
// ever breaks at pagebreak markers, never by content height) rendered
|
|
// the entire book as a single page as a result. Check the ORIGINAL,
|
|
// untrimmed line for the marker before trimming destroys it.
|
|
const isPageBreak = rawLine.startsWith('\f');
|
|
const line = isPageBreak ? rawLine.slice(1).trim() : rawLine.trim();
|
|
|
|
if (isPageBreak) {
|
|
flushDialog(); flushAction();
|
|
const pageNum = line && /^\d+$/.test(line) ? parseInt(line, 10) : null;
|
|
result.push({ type: 'pagebreak', speaker: '', text: '', page: pageNum, isDirection: true });
|
|
state = 'action'; currentSpeaker = null;
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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(/^([\p{Lu}][\p{Lu}0-9 _\-ß]{0,39}):\s+(.+)$/u);
|
|
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)
|
|
// The name regex used to be ASCII-only ([A-Z...]) — any speaker name
|
|
// containing a German umlaut or ß (e.g. "Turmwächter", "Freischärler",
|
|
// "Mädchen") uppercases to a string the regex couldn't match, so the cue
|
|
// was silently missed. That dropped the whole SPEAKER/text pair here
|
|
// (both fell through to plain narration), desyncing the emotions array —
|
|
// built with one entry per dialogue segment — from the actual parsed
|
|
// dialog lines from that point on, confirmed live via a 6-line drift
|
|
// partway through a 1958-segment script that shifted every emotion (and,
|
|
// once the shifted array ran out of alignment with speakers, every
|
|
// visible speaker/text pairing) for everything after it. \p{Lu} (Unicode
|
|
// uppercase letter) covers Ä/Ö/Ü and other accented capitals; ß has no
|
|
// widely-used uppercase form so it's allowed explicitly.
|
|
const nameRaw = line.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
if (
|
|
nameRaw.length >= 2 && nameRaw.length <= 42 &&
|
|
nameRaw === nameRaw.toUpperCase() &&
|
|
/^[\p{Lu}][\p{Lu}0-9 '.\-ß]+$/u.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;
|
|
}
|
|
|