- scenes.json: every DESIGN.md scene wired — T1-T7, SH1-6, Misfile, 4 needle-drop alts, 7 white labels, boss volleys, REPLAY queue sentinel, win+stinger gate - player.js: choice windows (branch/death-goto), random variants (sh2 tape), endurance restarts w/ tightening (sh4), hold windows (t4s5/t5s5), needledrop power-up branches, unprompted collects, mirror-on-replay, soft fail (t5s4), needle_skip transitions, stale-death-timeout guard - clips renamed to DESIGN ids (s01->t1s1 etc); placeholder gen covers all new window types and never overwrites real footage (86 clips: 8 real + 78 gen) - sheets: added missing sh4_action + t5s4_action prompts - verified in browser: choice pass/timeout, collect, needledrop, endurance tighten+death, hold pass/release-fail, soft fail, replay queue w/ mirror, win->stinger with 7 labels, live-timing run of t1s1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
138 lines
5.9 KiB
Python
138 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate placeholder clips for every clip referenced by engine/scenes.json
|
|
(actions, deaths, choice deaths, needle-drop alts, attract/win/gameover/
|
|
needle_skip/stinger) so the whole game is playable before any Flow credits are
|
|
spent. Existing files are SKIPPED — real Flow footage is never overwritten.
|
|
|
|
Text is rendered to PNG cards with Pillow (this machine's ffmpeg lacks
|
|
drawtext); ffmpeg composites flash + glyph overlays + a 1kHz beep at exactly
|
|
each QTE window so timing is verifiable by eye/ear. Choice windows show all
|
|
option glyphs; hold windows show HOLD; needle-drop flashes magenta top-right;
|
|
white-label collects flash white top-left. Deaths: red 'DEATH' cards."""
|
|
import glob
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
except ModuleNotFoundError:
|
|
for cand in glob.glob(str(Path.home() / "Documents/MODELBEAST/venvs/*/bin/python3")):
|
|
if subprocess.run([cand, "-c", "import PIL"], capture_output=True).returncode == 0:
|
|
os.execv(cand, [cand] + sys.argv)
|
|
sys.exit("Pillow not found in any interpreter; pip install pillow")
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent / "engine"
|
|
CLIPS = ROOT / "clips"
|
|
COLORS = ["#1a2a55", "#1f4d2a", "#4d3a1f", "#3a1f4d", "#1f4d4d"]
|
|
GLYPH = {"left": "<<<", "right": ">>>", "up": "^^^", "down": "vvv", "action": "(GO)"}
|
|
FONTS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
|
"/System/Library/Fonts/Supplemental/Arial.ttf"]
|
|
FONT = next((f for f in FONTS if Path(f).exists()), None)
|
|
W, H = 1280, 720
|
|
|
|
|
|
def png_card(path, color, text, size=110, fg="white"):
|
|
img = Image.new("RGB", (W, H), color)
|
|
d = ImageDraw.Draw(img)
|
|
f = ImageFont.truetype(FONT, size) if FONT else ImageFont.load_default()
|
|
d.text((W / 2, H / 2), text, font=f, fill=fg, anchor="mm")
|
|
img.save(path)
|
|
|
|
|
|
def png_glyph(path, text, size=200, fill="#ffee00"):
|
|
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
d = ImageDraw.Draw(img)
|
|
f = ImageFont.truetype(FONT, size) if FONT else ImageFont.load_default()
|
|
d.text((W / 2, H - 220), text, font=f, fill=fill, anchor="mm")
|
|
img.save(path)
|
|
|
|
|
|
def win_glyph(w):
|
|
if "choices" in w:
|
|
return " ".join(GLYPH[d] for d in w["choices"])
|
|
g = GLYPH[w["input"]]
|
|
return g + " HOLD" if w.get("hold") else g
|
|
|
|
|
|
def clip(out, base_png, dur, windows=(), flashes=(), tmp=None):
|
|
"""base card + per-window white flash, glyph overlay, beep; extra flashes
|
|
are (t0,t1,color) boxes (needle-drop magenta / white-label white)."""
|
|
args = ["ffmpeg", "-y", "-loglevel", "error",
|
|
"-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(base_png),
|
|
"-f", "lavfi", "-i", f"sine=frequency=1000:sample_rate=44100:duration={dur}"]
|
|
fc, last, vol, vi = [], "0:v", "0", 2
|
|
for i, w in enumerate(windows):
|
|
t0, t1 = w["t"]
|
|
g = Path(tmp) / f"{out.stem}_g{i}.png"
|
|
png_glyph(g, win_glyph(w), size=140 if "choices" in w or w.get("hold") else 200)
|
|
args += ["-loop", "1", "-t", str(dur), "-framerate", "24", "-i", str(g)]
|
|
nxt = f"v{i}"
|
|
fc.append(f"[{last}]drawbox=t=fill:color=white@0.35:enable='between(t,{t0},{t0 + 0.12})'"
|
|
f"[f{i}];[f{i}][{vi}:v]overlay=enable='between(t,{t0},{t1})'[{nxt}]")
|
|
last, vi = nxt, vi + 1
|
|
vol = f"if(between(t,{t0},{t1}),1,{vol})"
|
|
for j, (t0, t1, color, y) in enumerate(flashes):
|
|
nxt = f"x{j}"
|
|
fc.append(f"[{last}]drawbox=x={W - 260 if y == 'tr' else 40}:y=40:w=220:h=120:"
|
|
f"t=fill:color={color}@0.8:enable='between(t,{t0},{t1})'[{nxt}]")
|
|
last = nxt
|
|
fc.append(f"[{last}]format=yuv420p[vout]")
|
|
args += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "1:a",
|
|
"-af", f"volume='{vol}':eval=frame",
|
|
"-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-shortest",
|
|
"-movflags", "+faststart", str(out)]
|
|
subprocess.run(args, check=True)
|
|
print("wrote", out.name)
|
|
|
|
|
|
def main():
|
|
CLIPS.mkdir(exist_ok=True)
|
|
data = json.loads((ROOT / "scenes.json").read_text())
|
|
deaths, alts = set(), set()
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
def make(name, color, text, dur, windows=(), flashes=()):
|
|
out = CLIPS / name
|
|
if out.exists():
|
|
return print("skip (exists)", name)
|
|
base = Path(tmp) / (Path(name).stem + ".png")
|
|
png_card(base, color, text)
|
|
clip(out, base, dur, windows, flashes, tmp)
|
|
|
|
for i, (sid, s) in enumerate(data["scenes"].items()):
|
|
if "random" in s:
|
|
continue # variants carry their own clips
|
|
windows = s.get("windows", [])
|
|
dur = max([5.0] + [w["t"][1] + 0.8 for w in windows])
|
|
flashes = []
|
|
if "needledrop" in s:
|
|
nd = s["needledrop"]
|
|
flashes.append((nd["t"][0], nd["t"][1], "magenta", "tr"))
|
|
alts.add(nd["clip"])
|
|
if "collect" in s:
|
|
c = s["collect"]
|
|
flashes.append((c["t"][0], c["t"][1], "white", "tl"))
|
|
make(Path(s["clip"]).name, COLORS[i % len(COLORS)], sid.upper(), dur, windows, flashes)
|
|
if s.get("death"):
|
|
deaths.add(s["death"])
|
|
for w in windows:
|
|
for c in w.get("choices", {}).values():
|
|
if "death" in c:
|
|
deaths.add(c["death"])
|
|
for d in sorted(deaths):
|
|
make(Path(d).name, "#550000", f"DEATH {Path(d).stem.upper()}", 2.5)
|
|
for a in sorted(alts):
|
|
make(Path(a).name, "#0a3a5a", Path(a).stem.upper() + " !", 5.0)
|
|
make("attract.mp4", "#111111", "RECORD STORE GUY", 4.0)
|
|
make("win.mp4", "#004400", "YOU SURVIVED", 4.0)
|
|
make("gameover.mp4", "#220022", "GAME OVER", 3.0)
|
|
make("needle_skip.mp4", "#000000", "~ skip ~", 1.5)
|
|
make("stinger.mp4", "#2a1a00", "STINGER: MILDRED", 4.0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|