"""Screenplay Parser — Gradio demo for Final Draft / Fountain → JSON.""" import gradio as gr import json import re import xml.etree.ElementTree as ET from dataclasses import dataclass, field, asdict SCENE_HEADING_RE = re.compile( r"^\s*(INT\.?|EXT\.?|INT/EXT\.?|EXT/INT\.?|I/E\.?)[\s/]", re.IGNORECASE ) @dataclass class Scene: id: int heading: str = "" location_type: str = "" location: str = "" time_of_day: str = "" action: str = "" characters: list = field(default_factory=list) dialogue_count: int = 0 shot_estimate: int = 0 def _parse_heading(heading): h = heading.strip().upper() m = re.match(r"^(INT\.?|EXT\.?|INT/EXT\.?|EXT/INT\.?|I/E\.?)\s+(.*)", h) if not m: return "", heading, "" loc_type = m.group(1).rstrip(".").rstrip("/") rest = m.group(2) if " - " in rest: loc, tod = rest.rsplit(" - ", 1) return loc_type, loc.strip(), tod.strip() return loc_type, rest.strip(), "" def _shot_estimate(action_words, dialogue_count): return max(action_words // 40, 1) + dialogue_count // 2 def parse_fdx(content): root = ET.fromstring(content) scenes = [] current = None all_chars = {} def commit(): if current is None: return aw = len(current.action.split()) current.shot_estimate = _shot_estimate(aw, current.dialogue_count) lt, loc, tod = _parse_heading(current.heading) current.location_type, current.location, current.time_of_day = lt, loc, tod current.characters = sorted(set(current.characters)) scenes.append(current) for para in root.iter("Paragraph"): ptype = (para.get("Type") or "").strip() text = "".join(t.text or "" for t in para.iter("Text")).strip() if not text: continue if ptype == "Scene Heading": commit() current = Scene(id=len(scenes) + 1, heading=text) elif ptype == "Action" and current: current.action = (current.action + "\n" + text).strip() elif ptype == "Character" and current: name = re.sub(r"\s*\([^)]*\)\s*$", "", text).strip().upper() current.characters.append(name) all_chars[name] = all_chars.get(name, 0) + 1 elif ptype == "Dialogue" and current: current.dialogue_count += 1 commit() return scenes, all_chars def parse_fountain(content): scenes = [] current = None in_dialogue = False all_chars = {} def commit(): if current is None: return aw = len(current.action.split()) current.shot_estimate = _shot_estimate(aw, current.dialogue_count) lt, loc, tod = _parse_heading(current.heading) current.location_type, current.location, current.time_of_day = lt, loc, tod current.characters = sorted(set(current.characters)) scenes.append(current) for raw in content.splitlines(): line = raw.rstrip() if SCENE_HEADING_RE.match(line) or line.startswith("."): commit() current = Scene(id=len(scenes) + 1, heading=line.lstrip(".").strip()) in_dialogue = False continue if current is None: continue stripped = line.strip() if (stripped and stripped == stripped.upper() and not stripped.startswith("(") and not stripped.endswith(".") and len(stripped) < 50 and not SCENE_HEADING_RE.match(stripped)): name = re.sub(r"\s*\([^)]*\)\s*$", "", stripped).strip().upper() current.characters.append(name) all_chars[name] = all_chars.get(name, 0) + 1 in_dialogue = True continue if in_dialogue and stripped: if not stripped.startswith("("): current.dialogue_count += 1 continue if not stripped: in_dialogue = False continue current.action = (current.action + "\n" + stripped).strip() commit() return scenes, all_chars def process(text_input, file_input): """Main Gradio handler.""" content = "" if file_input is not None: with open(file_input.name if hasattr(file_input, "name") else file_input, "r", encoding="utf-8") as f: content = f.read() elif text_input: content = text_input if not content.strip(): return "Paste a Fountain screenplay or upload a .fdx file." is_fdx = content.lstrip().startswith("<") try: if is_fdx: scenes, chars = parse_fdx(content) else: scenes, chars = parse_fountain(content) except Exception as e: return f"Parse error: {e}" result = { "scenes": [asdict(s) for s in scenes], "total_scenes": len(scenes), "main_characters": [c for c, _ in sorted(chars.items(), key=lambda kv: -kv[1])][:8], "estimated_pages": max(1, sum(len(s.action.split()) for s in scenes) // 200), } return json.dumps(result, indent=2, ensure_ascii=False) FOUNTAIN_SAMPLE = """INT. NIGHTSHIFT DINER - 3 AM The diner is empty except for MARIA, late 30s, hunched over a coffee. MARIA Why am I still here? EXT. STREET - CONTINUOUS A black sedan rolls past, slows, stops. DETECTIVE COLE (V.O.) That was the last time she was seen alive.""" with gr.Blocks(title="Screenplay Parser") as demo: gr.Markdown("""# Screenplay Parser Paste a Fountain-format screenplay or upload a `.fdx` file. Get structured JSON. Built from open-source [screenplay-parser](https://github.com/mcqx4/screenplay-parser). Maintained by the team behind [STORYLINER](https://www.storyliner.online) — AI storyboard generator from script in 2 min.""") with gr.Row(): with gr.Column(): text_in = gr.Textbox(lines=15, label="Fountain screenplay (paste)", value=FOUNTAIN_SAMPLE) file_in = gr.File(label=".fdx file (optional)", file_types=[".fdx", ".txt"]) btn = gr.Button("Parse", variant="primary") with gr.Column(): output = gr.Code(label="Structured JSON output", language="json", lines=20) btn.click(process, [text_in, file_in], output) demo.load(process, [text_in, file_in], output) if __name__ == "__main__": demo.launch()