Rattata's picture
Add Gradio screenplay parser demo
0512e76 verified
Raw
History Blame Contribute Delete
6.3 kB
"""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()