CoolFace
Apppublic

Rattata/screenplay-parser-demo

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
app.py187 linesDownload Raw Back to root
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