Spaces:
Runtime error
Runtime error
| """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 | |
| ) | |
| 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() | |