H3-ScriptGen / scripts /build_sft_from_scriptlib.py
woodfireind's picture
H3-ScriptGen: MiniMax-H3 FL2VA scriptwriting LoRA (Qwen3.5-0.8B base, continue-trained from final adapter)
7dbeac1 verified
Raw
History Blame Contribute Delete
13.2 kB
#!/usr/bin/env python3
"""Build chat SFT JSONL from scriptlib (+ optional TVTropes) for H3 script-LoRA.
Reads numbered screenplays under ../scriptlib/*.txt, splits them into short
scene-ish chunks, and emits two kinds of training rows:
1. **format** — classic slugline/action/dialogue → MiniMax FL2VA scene block
2. **premise** — short premise derived from the chunk → full H3 scene beat
Optionally samples TVTropes titles/tropes as extra premise seeds (no script body).
Output: train_dataset.full.jsonl (append or overwrite).
Example:
python build_sft_from_scriptlib.py --max-scripts 102 --chunks-per-script 4
"""
from __future__ import annotations
import argparse
import json
import random
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SCRIPTLIB = ROOT.parent / "scriptlib"
TROPES_TV = ROOT.parent / "TVTropesData" / "tv_tropes.csv"
TROPES_MASTER = ROOT.parent / "TVTropesData" / "tropes.csv"
OUT_DEFAULT = ROOT / "train_dataset.full.jsonl"
H3_SYSTEM = (
"You write ONE MiniMax-H3 FL2VA scene beat for Backlot. "
"Output fields: ACTION, SHOT, STORYBOARD_PROMPT, H3_MODE, H3_VIDEO_PROMPT "
"(alignment line + integrated_multimodal_description + overall_soundscape + "
"non_diegetic_music), LORA, AUDIO, DURATION 5. Prefer a single continuous shot "
"from Picture 1 to Picture 2. Dialogue only inside <d>[Language] ...</d>."
)
SLUGLINE_RE = re.compile(
r"^\s*(INT\.|EXT\.|INT/EXT\.|I/E\.|EST\.)\s+.+$",
re.I | re.M,
)
CHAR_RE = re.compile(r"^\s{10,}([A-Z][A-Z0-9 .'\-]{1,40})\s*(\(.*\))?\s*$")
def split_scenes(text: str, max_chars: int = 1800) -> list[str]:
"""Split screenplay into chunks on sluglines, then size-cap."""
text = text.replace("\r\n", "\n").replace("\r", "\n")
# Prefer slugline splits
parts: list[str] = []
matches = list(SLUGLINE_RE.finditer(text))
if matches:
for i, m in enumerate(matches):
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
chunk = text[start:end].strip()
if len(chunk) > 80:
parts.append(chunk)
else:
# Seinfeld-style parenthetical locations: (Comedy club)
blocks = re.split(r"\n\s*\([^)\n]{3,80}\)\s*\n", text)
parts = [b.strip() for b in blocks if len(b.strip()) > 120]
# Size-cap / merge
out: list[str] = []
for p in parts:
if len(p) <= max_chars:
out.append(p)
else:
# take head of long scene (opening beat)
out.append(p[:max_chars].rsplit("\n", 1)[0])
return out
def extract_title(text: str) -> str:
for line in text.splitlines()[:25]:
s = line.strip()
if len(s) > 2 and s.isupper() and not s.startswith("WRITTEN"):
return s.title()
return "Untitled"
def extract_dialogue_pairs(chunk: str, limit: int = 2) -> list[tuple[str, str]]:
lines = chunk.splitlines()
pairs: list[tuple[str, str]] = []
i = 0
while i < len(lines) and len(pairs) < limit:
m = CHAR_RE.match(lines[i])
if not m:
i += 1
continue
name = m.group(1).strip().title()
i += 1
dial: list[str] = []
while i < len(lines):
L = lines[i]
if CHAR_RE.match(L) or SLUGLINE_RE.match(L):
break
if L.strip().startswith("(") and L.strip().endswith(")"):
i += 1
continue
if L.strip():
dial.append(L.strip())
elif dial:
break
i += 1
if dial:
pairs.append((name, " ".join(dial)))
return pairs
def extract_action_lines(chunk: str, max_sents: int = 2) -> str:
acts: list[str] = []
for line in chunk.splitlines():
s = line.strip()
if not s or CHAR_RE.match(line) or SLUGLINE_RE.match(line):
continue
if s.startswith("(") and s.endswith(")"):
continue
# skip all-caps character names
if s.isupper() and len(s) < 40:
continue
if len(s) > 20:
acts.append(s)
if len(acts) >= max_sents:
break
return " ".join(acts) if acts else "The scene plays out continuously."
def slugline_from_chunk(chunk: str) -> str:
m = SLUGLINE_RE.search(chunk)
if m:
return m.group(0).strip().upper()
# parenthetical location
m2 = re.search(r"\(([^)\n]{3,60})\)", chunk)
if m2:
return f"INT. {m2.group(1).upper()}"
return "INT. LOCATION - DAY"
def chunk_to_h3_assistant(chunk: str, title: str) -> str:
slug = slugline_from_chunk(chunk)
action = extract_action_lines(chunk)
dialogue = extract_dialogue_pairs(chunk, limit=1)
# Compress action for ~5s beat
action_short = action
if len(action_short) > 280:
action_short = action_short[:280].rsplit(" ", 1)[0] + "."
dial_line = ""
multimodal_extra = ""
if dialogue:
name, line = dialogue[0]
# keep dialogue short
if len(line) > 160:
line = line[:160].rsplit(" ", 1)[0] + "..."
dial_line = f"DIALOGUE — {name}: {line}\n"
multimodal_extra = (
f" {name} (S1) says: <d>[English] {line}</d>"
)
storyboard = (
f"cinematic still from {title}: {action_short[:200]}, "
f"detailed environment, live-action, film lighting"
)
body = (
f"[Shot 1] Live-action, cinematic, medium shot establishing the scene. "
f"{action_short} The camera pushes in with small amplitude at slow speed."
f"{multimodal_extra} "
f"The framing begins on the composition of Picture 1 and continuously "
f"evolves until it settles into the composition of Picture 2."
)
return (
f"## SCENE 1 — {slug}\n"
f"ACTION: {action_short}\n"
f"{dial_line}"
f"SHOT: medium shot, push in with small amplitude at slow speed\n"
f"STORYBOARD_PROMPT: {storyboard}\n"
f"H3_MODE: FL2VA\n"
f"H3_VIDEO_PROMPT:\n"
f"How the reference pictures align with the target video — Picture 1 "
f"(from Shot 1) aligns with the 0.00-second mark of the target video; "
f"Picture 2 (from Shot 1) aligns with the 5.00-second mark of the target video.\n\n"
f"integrated_multimodal_description: {body}\n\n"
f"overall_soundscape: Room tone and soft environmental ambience matching the location.\n\n"
f"non_diegetic_music: N/A\n"
f"LORA: none\n"
f"AUDIO: none\n"
f"DURATION: 5"
)
def row(messages: list[dict]) -> str:
return json.dumps({"messages": messages}, ensure_ascii=False)
def build_from_scripts(
script_dir: Path,
*,
max_scripts: int,
chunks_per_script: int,
rng: random.Random,
) -> list[str]:
files = sorted(script_dir.glob("*.txt"), key=lambda p: int(p.stem) if p.stem.isdigit() else p.stem)
if max_scripts > 0:
files = files[:max_scripts]
rows: list[str] = []
for path in files:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
title = extract_title(text)
chunks = split_scenes(text)
if not chunks:
continue
rng.shuffle(chunks)
for chunk in chunks[:chunks_per_script]:
assistant = chunk_to_h3_assistant(chunk, title)
# Format transfer: classic excerpt → H3 beat
rows.append(
row(
[
{"role": "system", "content": H3_SYSTEM},
{
"role": "user",
"content": (
f"Rewrite this screenplay beat as a single ~5s MiniMax-H3 "
f"FL2VA scene for Backlot (first storyboard panel → last panel).\n\n"
f"SOURCE TITLE: {title}\n\n"
f"SCREENPLAY EXCERPT:\n{chunk[:1600]}"
),
},
{"role": "assistant", "content": assistant},
]
)
)
# Premise → scene (trope-style)
premise = extract_action_lines(chunk, max_sents=1)
rows.append(
row(
[
{"role": "system", "content": H3_SYSTEM},
{
"role": "user",
"content": (
f"Premise: {premise}\n"
f"Setting: derived from {title}\n"
f"Tone: cinematic\n"
f"Write SCENE 1 now (H3 FL2VA, DURATION 5)."
),
},
{"role": "assistant", "content": assistant},
]
)
)
return rows
def build_from_tropes(path: Path, *, n: int, rng: random.Random) -> list[str]:
if not path.exists() or n <= 0:
return []
# Stream a sample of lines (file is large)
import csv
rows_out: list[str] = []
with path.open(encoding="utf-8", errors="replace", newline="") as f:
reader = csv.DictReader(f)
# sample reservoir
reservoir: list[dict] = []
for i, rec in enumerate(reader):
if i < 5000:
reservoir.append(rec)
else:
j = rng.randint(0, i)
if j < 5000:
reservoir[j] = rec
if i > 200_000: # don't scan entire multi-hundred-MB file
break
rng.shuffle(reservoir)
for rec in reservoir[:n]:
title = (rec.get("Title") or rec.get("title") or "Untitled").strip()
trope = (rec.get("Trope") or rec.get("trope") or "PlotTwist").strip()
example = (rec.get("Example") or rec.get("Description") or "").strip()
if len(example) > 400:
example = example[:400] + "..."
premise = f"A scene in {title} illustrating the trope '{trope}'. {example}"
# Lightweight target (model will learn shape from script rows primarily)
assistant = (
f"## SCENE 1 — INT. SETTING - DAY\n"
f"ACTION: Characters enact a brief beat embodying {trope}.\n"
f"SHOT: medium shot, static shot\n"
f"STORYBOARD_PROMPT: cinematic still for {title}, {trope}, live-action\n"
f"H3_MODE: FL2VA\n"
f"H3_VIDEO_PROMPT:\n"
f"How the reference pictures align with the target video — Picture 1 "
f"(from Shot 1) aligns with the 0.00-second mark of the target video; "
f"Picture 2 (from Shot 1) aligns with the 5.00-second mark of the target video.\n\n"
f"integrated_multimodal_description: [Shot 1] Live-action, cinematic, a medium "
f"shot introduces the situation for {trope}. The camera holds a static shot as "
f"the beat resolves into the final composition of Picture 2.\n\n"
f"overall_soundscape: Soft room tone.\n\n"
f"non_diegetic_music: N/A\n"
f"LORA: none\n"
f"AUDIO: none\n"
f"DURATION: 5"
)
rows_out.append(
row(
[
{"role": "system", "content": H3_SYSTEM},
{
"role": "user",
"content": f"Premise: {premise}\nWrite SCENE 1 now (H3 FL2VA, DURATION 5).",
},
{"role": "assistant", "content": assistant},
]
)
)
return rows_out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--scriptlib", type=Path, default=SCRIPTLIB)
ap.add_argument("--out", type=Path, default=OUT_DEFAULT)
ap.add_argument("--max-scripts", type=int, default=0, help="0 = all")
ap.add_argument("--chunks-per-script", type=int, default=4)
ap.add_argument("--tropes", type=int, default=80, help="extra TVTropes premise rows")
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--include-seed", action="store_true", help="prepend train_dataset.jsonl")
args = ap.parse_args()
rng = random.Random(args.seed)
if not args.scriptlib.is_dir():
raise SystemExit(f"scriptlib not found: {args.scriptlib}")
rows = build_from_scripts(
args.scriptlib,
max_scripts=args.max_scripts,
chunks_per_script=args.chunks_per_script,
rng=rng,
)
rows += build_from_tropes(TROPES_TV, n=args.tropes, rng=rng)
seed_path = ROOT / "train_dataset.jsonl"
if args.include_seed and seed_path.exists():
seed_rows = [ln for ln in seed_path.read_text().splitlines() if ln.strip()]
rows = seed_rows + rows
rng.shuffle(rows)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text("\n".join(rows) + "\n", encoding="utf-8")
print(f"wrote {len(rows)} rows → {args.out}")
if __name__ == "__main__":
main()