Rattata/screenplay-parser-demo
0
1"""Screenplay Parser — Gradio demo for Final Draft / Fountain → JSON."""2import gradio as gr3import json4import re5import xml.etree.ElementTree as ET6from dataclasses import dataclass, field, asdict7 8 9SCENE_HEADING_RE = re.compile(10 r"^\s*(INT\.?|EXT\.?|INT/EXT\.?|EXT/INT\.?|I/E\.?)[\s/]",11 re.IGNORECASE12)13 14 15@dataclass16class Scene:17 id: int18 heading: str = ""19 location_type: str = ""20 location: str = ""21 time_of_day: str = ""22 action: str = ""23 characters: list = field(default_factory=list)24 dialogue_count: int = 025 shot_estimate: int = 026 27 28def _parse_heading(heading):29 h = heading.strip().upper()30 m = re.match(r"^(INT\.?|EXT\.?|INT/EXT\.?|EXT/INT\.?|I/E\.?)\s+(.*)", h)31 if not m:32 return "", heading, ""33 loc_type = m.group(1).rstrip(".").rstrip("/")34 rest = m.group(2)35 if " - " in rest:36 loc, tod = rest.rsplit(" - ", 1)37 return loc_type, loc.strip(), tod.strip()38 return loc_type, rest.strip(), ""39 40 41def _shot_estimate(action_words, dialogue_count):42 return max(action_words // 40, 1) + dialogue_count // 243 44 45def parse_fdx(content):46 root = ET.fromstring(content)47 scenes = []48 current = None49 all_chars = {}50 51 def commit():52 if current is None: return53 aw = len(current.action.split())54 current.shot_estimate = _shot_estimate(aw, current.dialogue_count)55 lt, loc, tod = _parse_heading(current.heading)56 current.location_type, current.location, current.time_of_day = lt, loc, tod57 current.characters = sorted(set(current.characters))58 scenes.append(current)59 60 for para in root.iter("Paragraph"):61 ptype = (para.get("Type") or "").strip()62 text = "".join(t.text or "" for t in para.iter("Text")).strip()63 if not text: continue64 if ptype == "Scene Heading":65 commit()66 current = Scene(id=len(scenes) + 1, heading=text)67 elif ptype == "Action" and current:68 current.action = (current.action + "\n" + text).strip()69 elif ptype == "Character" and current:70 name = re.sub(r"\s*\([^)]*\)\s*$", "", text).strip().upper()71 current.characters.append(name)72 all_chars[name] = all_chars.get(name, 0) + 173 elif ptype == "Dialogue" and current:74 current.dialogue_count += 175 commit()76 return scenes, all_chars77 78 79def parse_fountain(content):80 scenes = []81 current = None82 in_dialogue = False83 all_chars = {}84 85 def commit():86 if current is None: return87 aw = len(current.action.split())88 current.shot_estimate = _shot_estimate(aw, current.dialogue_count)89 lt, loc, tod = _parse_heading(current.heading)90 current.location_type, current.location, current.time_of_day = lt, loc, tod91 current.characters = sorted(set(current.characters))92 scenes.append(current)93 94 for raw in content.splitlines():95 line = raw.rstrip()96 if SCENE_HEADING_RE.match(line) or line.startswith("."):97 commit()98 current = Scene(id=len(scenes) + 1, heading=line.lstrip(".").strip())99 in_dialogue = False100 continue101 if current is None: continue102 stripped = line.strip()103 if (stripped and stripped == stripped.upper() and not stripped.startswith("(")104 and not stripped.endswith(".") and len(stripped) < 50105 and not SCENE_HEADING_RE.match(stripped)):106 name = re.sub(r"\s*\([^)]*\)\s*$", "", stripped).strip().upper()107 current.characters.append(name)108 all_chars[name] = all_chars.get(name, 0) + 1109 in_dialogue = True110 continue111 if in_dialogue and stripped:112 if not stripped.startswith("("):113 current.dialogue_count += 1114 continue115 if not stripped:116 in_dialogue = False117 continue118 current.action = (current.action + "\n" + stripped).strip()119 commit()120 return scenes, all_chars121 122 123def process(text_input, file_input):124 """Main Gradio handler."""125 content = ""126 if file_input is not None:127 with open(file_input.name if hasattr(file_input, "name") else file_input, "r", encoding="utf-8") as f:128 content = f.read()129 elif text_input:130 content = text_input131 if not content.strip():132 return "Paste a Fountain screenplay or upload a .fdx file."133 134 is_fdx = content.lstrip().startswith("<")135 try:136 if is_fdx:137 scenes, chars = parse_fdx(content)138 else:139 scenes, chars = parse_fountain(content)140 except Exception as e:141 return f"Parse error: {e}"142 143 result = {144 "scenes": [asdict(s) for s in scenes],145 "total_scenes": len(scenes),146 "main_characters": [c for c, _ in sorted(chars.items(), key=lambda kv: -kv[1])][:8],147 "estimated_pages": max(1, sum(len(s.action.split()) for s in scenes) // 200),148 }149 return json.dumps(result, indent=2, ensure_ascii=False)150 151 152FOUNTAIN_SAMPLE = """INT. NIGHTSHIFT DINER - 3 AM153 154The diner is empty except for MARIA, late 30s, hunched over a coffee.155 156MARIA157Why am I still here?158 159EXT. STREET - CONTINUOUS160 161A black sedan rolls past, slows, stops.162 163DETECTIVE COLE (V.O.)164That was the last time she was seen alive."""165 166 167with gr.Blocks(title="Screenplay Parser") as demo:168 gr.Markdown("""# Screenplay Parser169 170Paste a Fountain-format screenplay or upload a `.fdx` file. Get structured JSON.171 172Built 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.""")173 with gr.Row():174 with gr.Column():175 text_in = gr.Textbox(lines=15, label="Fountain screenplay (paste)",176 value=FOUNTAIN_SAMPLE)177 file_in = gr.File(label=".fdx file (optional)", file_types=[".fdx", ".txt"])178 btn = gr.Button("Parse", variant="primary")179 with gr.Column():180 output = gr.Code(label="Structured JSON output", language="json", lines=20)181 btn.click(process, [text_in, file_in], output)182 demo.load(process, [text_in, file_in], output)183 184 185if __name__ == "__main__":186 demo.launch()187 