Phase 1: playable engine shell — QTE player, debug mode, placeholder clip generator
Verified end-to-end in browser: attract, QTE pass/fail, death/retry, lives, win screen. QTE clock driven by rAF + timeupdate + interval so background-tab throttling can't starve the too-late check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c4ec145455
commit
17a0bb07ed
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "rsg-engine",
|
||||
"runtimeExecutable": "python3",
|
||||
"runtimeArgs": ["-m", "http.server", "8123", "-d", "engine"],
|
||||
"port": 8123
|
||||
}
|
||||
]
|
||||
}
|
||||
37
engine/index.html
Normal file
37
engine/index.html
Normal file
@ -0,0 +1,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>RECORD STORE GUY</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<video id="vidA" playsinline preload="auto"></video>
|
||||
<video id="vidB" playsinline preload="auto"></video>
|
||||
<div id="scanlines"></div>
|
||||
<div id="cue"></div>
|
||||
<div id="hud">
|
||||
<div id="lives"></div>
|
||||
<div id="score">0</div>
|
||||
</div>
|
||||
<div id="attract" class="screen">
|
||||
<h1>RECORD STORE GUY</h1>
|
||||
<p class="sub">wrong move — you're dead</p>
|
||||
<p class="blink">PRESS ANY KEY</p>
|
||||
<p id="hiscore"></p>
|
||||
</div>
|
||||
<div id="msg" class="screen hidden"></div>
|
||||
<div id="debug" class="hidden">
|
||||
<div id="dbg-timeline"><div id="dbg-playhead"></div></div>
|
||||
<div id="dbg-row">
|
||||
<select id="dbg-scene"></select>
|
||||
<span id="dbg-time"></span>
|
||||
<span id="dbg-state"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
241
engine/player.js
Normal file
241
engine/player.js
Normal file
@ -0,0 +1,241 @@
|
||||
/* RECORD STORE GUY — FMV QTE player. Vanilla ES6, no deps. Spec: ENGINE.md */
|
||||
"use strict";
|
||||
|
||||
// feel-tuning constants (see ENGINE.md)
|
||||
const WINDOW_DEFAULT = 0.7; // s
|
||||
const EARLY_GRACE = 0.25; // s before window open where input = fail
|
||||
const DEATH_HOLD = 600; // ms on last death frame before retry
|
||||
const CUE_LEAD = 0.0; // s cue shows before window opens
|
||||
|
||||
const GLYPH = { left: "◀", right: "▶", up: "▲", down: "▼", action: "●" };
|
||||
const KEYMAP = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down",
|
||||
" ": "action", Enter: "action" };
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const vids = [$("vidA"), $("vidB")];
|
||||
let cur = 0; // index of active video
|
||||
|
||||
const G = {
|
||||
data: null, state: "ATTRACT", scene: null, sceneId: null,
|
||||
wi: 0, windows: [], lives: 0, score: 0, checkpoint: null,
|
||||
debug: new URLSearchParams(location.search).has("debug"),
|
||||
blobs: new Map(), // url -> objectURL
|
||||
};
|
||||
|
||||
// ---------- boot ----------
|
||||
async function boot() {
|
||||
G.data = await (await fetch("scenes.json?v=" + Date.now())).json();
|
||||
$("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0);
|
||||
if (G.debug) { initDebug(); window.RSG = { G, input, vid }; }
|
||||
preload(G.data.scenes[G.data.start].clip);
|
||||
preload(G.data.scenes[G.data.start].death);
|
||||
addEventListener("keydown", onKey);
|
||||
initTouch();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
async function preload(url) {
|
||||
if (!url || G.blobs.has(url)) return G.blobs.get(url);
|
||||
try {
|
||||
const b = await (await fetch(url)).blob();
|
||||
const o = URL.createObjectURL(b);
|
||||
G.blobs.set(url, o);
|
||||
return o;
|
||||
} catch { return url; } // fall back to streaming
|
||||
}
|
||||
|
||||
// ---------- clip playback (A/B swap) ----------
|
||||
function playClip(url, onEnded) {
|
||||
const next = vids[1 - cur];
|
||||
next.src = G.blobs.get(url) || url;
|
||||
next.onended = null;
|
||||
next.currentTime = 0;
|
||||
const p = next.play();
|
||||
if (p) p.catch(() => {}); // pre-gesture autoplay rejection is fine
|
||||
next.onended = onEnded;
|
||||
next.classList.add("active");
|
||||
vids[cur].classList.remove("active");
|
||||
vids[cur].pause();
|
||||
cur = 1 - cur;
|
||||
return next;
|
||||
}
|
||||
const vid = () => vids[cur];
|
||||
|
||||
// ---------- scene flow ----------
|
||||
function startGame() {
|
||||
G.lives = G.data.meta.lives;
|
||||
G.score = 0;
|
||||
G.checkpoint = G.data.start;
|
||||
$("attract").classList.add("hidden");
|
||||
playScene(G.data.start);
|
||||
}
|
||||
|
||||
function playScene(id) {
|
||||
const s = G.data.scenes[id];
|
||||
G.state = "PLAYING"; G.sceneId = id; G.scene = s; G.wi = 0;
|
||||
if (s.checkpoint) G.checkpoint = id;
|
||||
G.windows = s.windows.map((w) => ({
|
||||
t: w.t[1] != null ? w.t : [w.t[0], w.t[0] + WINDOW_DEFAULT],
|
||||
input: w.input, cue: w.cue !== false, cued: false, done: false,
|
||||
}));
|
||||
hud();
|
||||
playClip(s.clip, onSceneClipEnd);
|
||||
// preload likely-next clips: this death, then success path
|
||||
preload(s.death).then(() => {
|
||||
const n = s.onSuccess;
|
||||
if (n && n !== "WIN") { preload(G.data.scenes[n].clip); preload(G.data.scenes[n].death); }
|
||||
else preload("clips/win.mp4");
|
||||
});
|
||||
if (G.debug) debugScene();
|
||||
}
|
||||
|
||||
function onSceneClipEnd() {
|
||||
if (G.state !== "PLAYING") return;
|
||||
if (G.wi < G.windows.length) return fail("clip ended mid-window"); // safety net
|
||||
G.score += 1000; hud();
|
||||
const n = G.scene.onSuccess;
|
||||
if (n === "WIN") return win();
|
||||
playScene(n);
|
||||
}
|
||||
|
||||
function fail(why) {
|
||||
if (G.state !== "PLAYING") return;
|
||||
G.state = "DEATH";
|
||||
cueHide();
|
||||
if (G.debug) $("dbg-state").textContent = "DEATH (" + why + ")";
|
||||
playClip(G.scene.death, () => {
|
||||
setTimeout(() => {
|
||||
G.lives--; G.score = Math.max(0, G.score - 100); hud();
|
||||
if (G.lives <= 0) return gameOver();
|
||||
playScene(G.sceneId); // retry the scene (checkpoints reserved for act restarts)
|
||||
}, DEATH_HOLD);
|
||||
});
|
||||
}
|
||||
|
||||
function win() {
|
||||
G.state = "WIN";
|
||||
saveHi();
|
||||
playClip("clips/win.mp4", () => showEnd("YOU SURVIVED", "score " + G.score));
|
||||
}
|
||||
function gameOver() {
|
||||
G.state = "GAMEOVER";
|
||||
saveHi();
|
||||
playClip("clips/gameover.mp4", () => showEnd("GAME OVER", "score " + G.score));
|
||||
}
|
||||
function showEnd(title, sub) {
|
||||
const m = $("msg");
|
||||
m.innerHTML = "<h2>" + title + "</h2><p class='sub'>" + sub + "</p><p class='blink'>PRESS ANY KEY</p>";
|
||||
m.classList.remove("hidden");
|
||||
G.state = "END";
|
||||
}
|
||||
function toAttract() {
|
||||
$("msg").classList.add("hidden");
|
||||
$("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0);
|
||||
$("attract").classList.remove("hidden");
|
||||
vid().pause();
|
||||
G.state = "ATTRACT";
|
||||
}
|
||||
function saveHi() {
|
||||
if (G.score > (+localStorage.rsg_hiscore || 0)) localStorage.rsg_hiscore = G.score;
|
||||
}
|
||||
|
||||
// ---------- QTE core ----------
|
||||
// qteCheck is driven from THREE sources so throttling can't starve it:
|
||||
// rAF (smooth when focused), video timeupdate (fires while playing even
|
||||
// unfocused), and a 100ms interval backup.
|
||||
function qteCheck() {
|
||||
if (G.state !== "PLAYING") return;
|
||||
const t = vid().currentTime;
|
||||
const w = G.windows[G.wi];
|
||||
if (w) {
|
||||
if (!w.cued && w.cue && t >= w.t[0] - CUE_LEAD) { w.cued = true; cueShow(w.input); }
|
||||
if (t >= w.t[1]) { w.done = "failed"; fail("too late"); }
|
||||
}
|
||||
if (G.debug) debugTick(t);
|
||||
}
|
||||
function tick() { qteCheck(); requestAnimationFrame(tick); }
|
||||
setInterval(qteCheck, 100);
|
||||
vids.forEach((v) => v.addEventListener("timeupdate", qteCheck));
|
||||
|
||||
function input(dir) {
|
||||
if (G.state === "ATTRACT") return startGame();
|
||||
if (G.state === "END") return toAttract();
|
||||
if (G.state !== "PLAYING") return;
|
||||
const w = G.windows[G.wi];
|
||||
if (!w) return; // windows all cleared, clip finishing
|
||||
const t = vid().currentTime;
|
||||
if (t < w.t[0] - EARLY_GRACE) return; // idle fidget: ignore
|
||||
if (t < w.t[0]) { w.done = "failed"; return fail("too early"); } // Dragon's Lair rule
|
||||
if (dir !== w.input) { w.done = "failed"; return fail("wrong input"); }
|
||||
// pass
|
||||
w.done = "passed"; G.wi++; G.score += 100; hud();
|
||||
cueGood(dir);
|
||||
if (G.debug) debugScene();
|
||||
}
|
||||
|
||||
// ---------- input sources ----------
|
||||
function onKey(e) {
|
||||
if (e.repeat) return;
|
||||
if (G.state === "ATTRACT" || G.state === "END") return input("action");
|
||||
const d = KEYMAP[e.key];
|
||||
if (d) { e.preventDefault(); input(d); }
|
||||
if (G.debug) debugKeys(e);
|
||||
}
|
||||
|
||||
function initTouch() {
|
||||
let sx, sy, st;
|
||||
addEventListener("touchstart", (e) => {
|
||||
sx = e.touches[0].clientX; sy = e.touches[0].clientY; st = Date.now();
|
||||
}, { passive: true });
|
||||
addEventListener("touchend", (e) => {
|
||||
const dx = e.changedTouches[0].clientX - sx, dy = e.changedTouches[0].clientY - sy;
|
||||
if (Math.hypot(dx, dy) < 30 && Date.now() - st < 400) return input("action"); // tap
|
||||
input(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up"));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// ---------- HUD + cue ----------
|
||||
function hud() {
|
||||
$("lives").textContent = "●".repeat(Math.max(0, G.lives));
|
||||
$("score").textContent = G.score;
|
||||
}
|
||||
function cueShow(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "show"; }
|
||||
function cueHide() { $("cue").className = ""; }
|
||||
function cueGood(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "good"; }
|
||||
|
||||
// ---------- debug mode (?debug=1) ----------
|
||||
function initDebug() {
|
||||
$("debug").classList.remove("hidden");
|
||||
const sel = $("dbg-scene");
|
||||
for (const id of Object.keys(G.data.scenes)) {
|
||||
const o = document.createElement("option"); o.value = o.textContent = id; sel.appendChild(o);
|
||||
}
|
||||
sel.onchange = () => { $("attract").classList.add("hidden"); $("msg").classList.add("hidden");
|
||||
if (!G.lives) G.lives = G.data.meta.lives; playScene(sel.value); };
|
||||
}
|
||||
function debugScene() {
|
||||
const tl = $("dbg-timeline");
|
||||
tl.querySelectorAll(".dbg-win").forEach((n) => n.remove());
|
||||
const d = vid().duration || 5;
|
||||
G.windows.forEach((w) => {
|
||||
const b = document.createElement("div");
|
||||
b.className = "dbg-win" + (w.done ? " " + w.done : "");
|
||||
b.style.left = (w.t[0] / d) * 100 + "%";
|
||||
b.style.width = ((w.t[1] - w.t[0]) / d) * 100 + "%";
|
||||
tl.appendChild(b);
|
||||
});
|
||||
$("dbg-scene").value = G.sceneId;
|
||||
}
|
||||
function debugTick(t) {
|
||||
const d = vid().duration || 5;
|
||||
$("dbg-playhead").style.left = (t / d) * 100 + "%";
|
||||
$("dbg-time").textContent = t.toFixed(2) + "s / " + d.toFixed(2) + "s [" + G.sceneId + " w" + G.wi + "]";
|
||||
$("dbg-state").textContent = G.state;
|
||||
}
|
||||
function debugKeys(e) { // , . frame-step while paused
|
||||
if (e.key === ",") { vid().pause(); vid().currentTime -= 1 / 24; }
|
||||
if (e.key === ".") { vid().pause(); vid().currentTime += 1 / 24; }
|
||||
if (e.key === "p") vid().paused ? vid().play() : vid().pause();
|
||||
}
|
||||
|
||||
boot();
|
||||
30
engine/scenes.json
Normal file
30
engine/scenes.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"meta": { "title": "RECORD STORE GUY", "lives": 5, "version": 1 },
|
||||
"start": "s01",
|
||||
"scenes": {
|
||||
"s01": {
|
||||
"clip": "clips/s01_action.mp4",
|
||||
"death": "clips/s01_death.mp4",
|
||||
"windows": [{ "t": [3.2, 3.9], "input": "right", "cue": true }],
|
||||
"onSuccess": "s02",
|
||||
"checkpoint": true
|
||||
},
|
||||
"s02": {
|
||||
"clip": "clips/s02_action.mp4",
|
||||
"death": "clips/s02_death.mp4",
|
||||
"windows": [
|
||||
{ "t": [2.0, 2.7], "input": "down", "cue": true },
|
||||
{ "t": [3.5, 4.2], "input": "up", "cue": true }
|
||||
],
|
||||
"onSuccess": "s03",
|
||||
"checkpoint": false
|
||||
},
|
||||
"s03": {
|
||||
"clip": "clips/s03_action.mp4",
|
||||
"death": "clips/s03_death.mp4",
|
||||
"windows": [{ "t": [3.0, 3.6], "input": "action", "cue": true }],
|
||||
"onSuccess": "WIN",
|
||||
"checkpoint": false
|
||||
}
|
||||
}
|
||||
}
|
||||
50
engine/styles.css
Normal file
50
engine/styles.css
Normal file
@ -0,0 +1,50 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { height: 100%; background: #000; overflow: hidden; font-family: "Courier New", monospace; }
|
||||
|
||||
#stage { position: relative; width: 100vw; height: 100vh; display: flex; align-items: center; justify-content: center; }
|
||||
video { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
max-width: 100vw; max-height: 100vh; aspect-ratio: 16/9; width: 100%;
|
||||
opacity: 0; pointer-events: none; }
|
||||
video.active { opacity: 1; }
|
||||
|
||||
#scanlines { position: absolute; inset: 0; pointer-events: none; z-index: 5;
|
||||
background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.22) 2px 4px);
|
||||
box-shadow: inset 0 0 18vmin rgba(0,0,0,.85); }
|
||||
|
||||
/* QTE cue */
|
||||
#cue { position: absolute; bottom: 16%; left: 50%; transform: translateX(-50%);
|
||||
font-size: 14vmin; font-weight: bold; color: #0ff; z-index: 10; opacity: 0;
|
||||
text-shadow: 0 0 2vmin #0ff, 0 0 6vmin #08f; pointer-events: none; }
|
||||
#cue.show { opacity: 1; animation: pulse .3s ease-in-out infinite alternate; }
|
||||
#cue.good { color: #0f4; text-shadow: 0 0 2vmin #0f4; animation: pop .25s ease-out; }
|
||||
@keyframes pulse { from { transform: translateX(-50%) scale(1); } to { transform: translateX(-50%) scale(1.18); } }
|
||||
@keyframes pop { from { transform: translateX(-50%) scale(1.4); opacity: 1; } to { transform: translateX(-50%) scale(1); opacity: 0; } }
|
||||
|
||||
/* HUD */
|
||||
#hud { position: absolute; top: 2vmin; left: 0; right: 0; display: flex;
|
||||
justify-content: space-between; padding: 0 3vmin; z-index: 10; pointer-events: none; }
|
||||
#lives { color: #f33; font-size: 4vmin; letter-spacing: .8vmin; text-shadow: 0 0 1.5vmin #f33; }
|
||||
#score { color: #ff0; font-size: 4vmin; text-shadow: 0 0 1.5vmin #f80; }
|
||||
|
||||
/* full screens */
|
||||
.screen { position: absolute; inset: 0; z-index: 20; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 3vmin; text-align: center;
|
||||
background: radial-gradient(ellipse at center, #180828 0%, #000 75%); }
|
||||
.screen.hidden { display: none; }
|
||||
.screen h1 { color: #f0f; font-size: 9vmin; text-shadow: 0 0 2vmin #f0f, 0 0 8vmin #90f; }
|
||||
.screen h2 { color: #fff; font-size: 7vmin; text-shadow: 0 0 2vmin #0ff; }
|
||||
.screen .sub { color: #0ff; font-size: 3vmin; }
|
||||
.screen .blink { color: #fff; font-size: 4vmin; animation: blink 1s steps(2) infinite; }
|
||||
#hiscore { color: #ff0; font-size: 3vmin; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* debug */
|
||||
#debug { position: absolute; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||
background: rgba(0,0,0,.8); padding: 1vmin 2vmin; font-size: 1.8vmin; color: #0f0; }
|
||||
#debug.hidden { display: none; }
|
||||
#dbg-timeline { position: relative; height: 2.4vmin; background: #222; margin-bottom: .8vmin; }
|
||||
.dbg-win { position: absolute; top: 0; bottom: 0; background: #08f; opacity: .8; }
|
||||
.dbg-win.passed { background: #0f4; } .dbg-win.failed { background: #f33; }
|
||||
#dbg-playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: #fff; z-index: 2; }
|
||||
#dbg-row { display: flex; gap: 2vmin; align-items: center; }
|
||||
#dbg-row select { background: #111; color: #0f0; border: 1px solid #0f0; font: inherit; }
|
||||
96
tools/make_placeholders.py
Normal file
96
tools/make_placeholders.py
Normal file
@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate placeholder clips for every scene in engine/scenes.json (plus
|
||||
attract/win/gameover) so the whole game is playable before any Flow credits are
|
||||
spent. 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. Deaths: red 'DEATH' cards.
|
||||
|
||||
If Pillow is missing from the running interpreter, re-execs into a MODELBEAST
|
||||
venv python that has it."""
|
||||
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"
|
||||
DUR = 5.0
|
||||
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):
|
||||
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
f = ImageFont.truetype(FONT, 200) if FONT else ImageFont.load_default()
|
||||
d.text((W / 2, H - 220), text, font=f, fill="#ffee00", anchor="mm")
|
||||
img.save(path)
|
||||
|
||||
|
||||
def clip(out, base_png, dur, windows=(), tmp=None):
|
||||
"""base card + per-window white flash, glyph overlay, and beep."""
|
||||
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 = [], "0:v", "0"
|
||||
for i, w in enumerate(windows):
|
||||
t0, t1 = w["t"]
|
||||
g = Path(tmp) / f"g{i}.png"
|
||||
png_glyph(g, GLYPH[w["input"]])
|
||||
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}][{i + 2}:v]overlay=enable='between(t,{t0},{t1})'[{nxt}]")
|
||||
last = nxt
|
||||
vol = f"if(between(t,{t0},{t1}),1,{vol})"
|
||||
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())
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
def make(name, color, text, dur, windows=()):
|
||||
base = Path(tmp) / (Path(name).stem + ".png")
|
||||
png_card(base, color, text)
|
||||
clip(CLIPS / name, base, dur, windows, tmp)
|
||||
|
||||
for i, (sid, s) in enumerate(data["scenes"].items()):
|
||||
make(Path(s["clip"]).name, COLORS[i % len(COLORS)], sid.upper(), DUR, s["windows"])
|
||||
make(Path(s["death"]).name, "#550000", f"DEATH {sid.upper()}", 2.5)
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user