singe/util/renderMenuVideo.py
2026-09-13 22:06:39 -05:00

224 lines
12 KiB
Python

# Records the menu's backdrop -- Singe/Backdrop.singe, the intro and the grid behind it -- to the
# video a machine with no GPU plays in its place. The drawn backdrop needs the 3D scene, and the
# 3D scene needs a GPU device; Singe/MenuOverlay.singe runs where there is none, and what it has to
# put on the screen instead is a recording of what everybody else draws.
#
# The recording is made by the engine itself rather than by a renderer of our own, so there is one
# backdrop and not two: a headless Singe draws Backdrop.singe on a virtual clock that moves a fixed
# number of milliseconds a frame, shoots every frame, and the frames become the video. Nothing here
# knows how long the intro lasts or how fast the grid moves -- the script asks the backdrop and
# prints the answer, which is the only way those numbers cannot drift apart.
#
# The result is intro frames followed by a whole number of turns of the grid, so playing the tail
# over and over is seamless. The three frame numbers the menu needs are printed at the end; they
# go into Singe/Menu.singe as DISC_MENU_FRAME, DISC_GRID_START and DISC_LAST_FRAME.
#
# The sound comes with it: util/makeMenuSound.py writes assets/menuIntro.flac to the very length
# this measured, and Singe/Menu.singe plays that file over whichever renderer is drawing. The
# video has no audio track at all -- muxing the sound in as well would be the same nine seconds
# shipped twice in one binary -- and nothing plays over the looping section, because the music has
# faded out before the loop begins and a menu waiting for someone to choose a game should be quiet.
#
# The engine extracts its own copy of Backdrop.singe over anything in the run directory, so what is
# recorded is what is embedded in the binary: build before rendering, or the recording is of the
# last build. The script checks and says so.
#
# Usage: python3 util/renderMenuVideo.py
# python3 util/renderMenuVideo.py --periods 8 --keep
import argparse
import os
import re
import shutil
import subprocess
import sys
from makeMenuSound import makeSound
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STEP_MS = 33 # Milliseconds a frame: the virtual clock's step and the video's rate.
# 33 is a thirtieth of a second to the nearest millisecond -- the clock
# takes whole milliseconds -- and 660, one turn of the grid, divides by
# it exactly. Change one of the two and the loop stops meeting itself;
# the driver script below checks rather than trusting this comment.
KEY_EVERY = 15 # Frames between keyframes. The menu seeks to the start of the loop on
# every turn, so that frame has to be one of them; the driver script
# below checks that the backdrop put its loop on one.
PERIODS = 6 # Turns of the grid kept as the loop. One would do for the picture; the
# extra ones are room for the bed underneath to breathe in.
WIDTH = 1024 # Rendered size. Four by three, and the biggest the offscreen video
HEIGHT = 768 # driver will give: a neon line lands on the video's pixels as an
# average of the several it crossed rather than as whichever one it hit.
VIDEO_W = 720 # What the menu's overlay is laid out in, and what the old clips were:
VIDEO_H = 480 # standard definition, which is all this has to be.
CRF = 24 # How hard it is compressed. Flat colour with hard edges is easy to
# encode: at 18 the file was nearly twice this size and no different to
# look at, on the sun's edge or on the grid in motion, which is where
# ringing would show if it were going to.
CANVAS = "%dx%d" % (VIDEO_W, VIDEO_H)
DRIVER = '''-- Written by util/renderMenuVideo.py. Records Singe/Backdrop.singe frame by frame.
dofile("Singe/Backdrop.singe")
local STEP = %(step)d / 1000.0
local PERIODS = %(periods)d
local KEY = %(key)d
-- One turn of the grid has to be a whole number of frames or the loop cannot meet itself.
local perLoop = backdropLoopSeconds() / STEP
local loopFrames = math.floor(perLoop + 0.5)
if math.abs(perLoop - loopFrames) > 0.0001 then
error(string.format("the grid's %%g second turn is %%g frames at %%d ms; it has to be whole", backdropLoopSeconds(), perLoop, %(step)d))
end
loopFrames = loopFrames * PERIODS
-- Where the backdrop hands over from its intro to its loop. The backdrop decides, because the
-- sound it plays hands over at the same moment; all that is checked here is that the moment it
-- picked is a frame at all, and a keyframe, so the menu's seek back to it is instant.
local introSeconds = backdropLoopAt()
local introFrames = introSeconds / STEP
if math.abs(introFrames - math.floor(introFrames + 0.5)) > 0.0001 then
error(string.format("the loop begins at %%g seconds, which is not a frame at %%d ms", introSeconds, %(step)d))
end
introFrames = math.floor(introFrames + 0.5)
if introFrames %% KEY ~= 0 then
error(string.format("the loop begins on frame %%d, which is not one of the every %%d keyframes", introFrames, KEY))
end
if introSeconds < backdropIntroSeconds() then
error("the loop begins before the intro has finished")
end
-- And where the menu itself may take the screen, which is earlier: the last seconds of the intro
-- are the menu already up with the music leaving behind it.
local menuFrame = math.ceil(backdropIntroSeconds() / STEP)
local total = introFrames + loopFrames
local frame = 0
debugPrint(string.format("RENDER intro=%%d loop=%%d menu=%%d total=%%d step=%%d", introFrames, loopFrames, menuFrame, total, %(step)d))
-- What Singe/Menu.singe lays the overlay out in. A game without a disc gets half its canvas by
-- default, and the tagline -- measured and centred against whatever this says -- came out too wide
-- for it and was cut off at both ends.
overlaySetResolution(%(width)d, %(height)d)
backdropBegin(true)
function onOverlayUpdate()
-- The two lines Singe/MenuDocument.singe puts in front of the backdrop, so the recording is
-- made the way the menu draws it and not some other way.
colorBackground(0, 0, 0, 0)
overlayClear()
backdropFrame()
singeScreenshot()
frame = frame + 1
if frame >= total then
singeQuit()
end
return OVERLAY_UPDATED
end
'''
def findBinary():
folder = os.path.join(REPO, ".builddir", "linux-gcc", "x86_64", "singe")
for name in sorted(os.listdir(folder)):
path = os.path.join(folder, name)
if name.startswith("Singe") and os.path.isfile(path) and os.access(path, os.X_OK):
return path
raise SystemExit("no Singe binary in %s; build first" % folder)
def render(binary, work, frames, step):
# Offscreen rather than under an X server: Xvfb has no DRI3, so a hardware Vulkan device
# cannot build a swapchain there and SDL quietly falls back to the software rasteriser.
env = dict(os.environ)
env["SDL_VIDEODRIVER"] = "offscreen"
env["SDL_AUDIO_DRIVER"] = "dummy"
env["SDL_AUDIODRIVER"] = "dummy"
command = [binary, "-k", "-s", "-C", CANVAS, "-x", str(WIDTH), "-y", str(HEIGHT),
"--deterministic=%d" % step, "-d", frames + os.sep, "render.singe"]
result = subprocess.run(command, cwd=work, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
text = result.stdout.decode("utf-8", "replace")
if result.returncode != 0:
sys.stdout.write(text)
raise SystemExit("the engine failed with %d" % result.returncode)
return text
def main():
parser = argparse.ArgumentParser(description="record the menu backdrop to a video")
parser.add_argument("--out", default=os.path.join(REPO, "assets", "menuBackground.mkv"))
parser.add_argument("--periods", type=int, default=PERIODS, help="turns of the grid kept as the loop")
parser.add_argument("--step", type=int, default=STEP_MS, help="milliseconds a frame")
parser.add_argument("--binary", help="the engine to record with, in place of the built one")
parser.add_argument("--keep", action="store_true", help="leave the frames behind")
args = parser.parse_args()
binary = args.binary or findBinary()
work = os.path.join(REPO, ".builddir", "menuVideo")
# The frames are screenshots, and screenshots belong in the repository's screenshots folder.
frames = os.path.join(REPO, "screenshots", "menuBackdrop")
shutil.rmtree(frames, ignore_errors=True)
os.makedirs(frames)
os.makedirs(work, exist_ok=True)
with open(os.path.join(work, "render.singe"), "w") as out:
out.write(DRIVER % {"step": args.step, "periods": args.periods, "key": KEY_EVERY,
"width": VIDEO_W, "height": VIDEO_H})
print("recording with %s" % os.path.basename(binary))
text = render(binary, work, frames, args.step)
found = re.search(r"RENDER intro=(\d+) loop=(\d+) menu=(\d+) total=(\d+) step=(\d+)", text)
if not found:
sys.stdout.write(text)
raise SystemExit("the engine never said how long the backdrop is")
intro = int(found.group(1))
loop = int(found.group(2))
menu = int(found.group(3))
total = int(found.group(4))
# The engine writes its own Backdrop.singe over the run directory's, so a stale binary records
# a stale backdrop without any sign of it. Say so rather than shipping the wrong picture.
mine = open(os.path.join(REPO, "assets", "Backdrop.singe"), "rb").read()
theirs = open(os.path.join(work, "Singe", "Backdrop.singe"), "rb").read()
if mine != theirs:
print("WARNING: the binary's Backdrop.singe is not the one in assets; rebuild and render again")
shots = [f for f in os.listdir(frames) if f.endswith(".png")]
if len(shots) != total:
raise SystemExit("wanted %d frames and got %d" % (total, len(shots)))
# The sound, written to the length the picture just measured. It is not muxed into the video:
# Singe/Menu.singe plays this file over whichever renderer is drawing, so a copy in the video's
# audio track would be the same nine seconds of sound shipped twice inside the same binary.
sting = makeSound(intro * args.step / 1000.0, os.path.dirname(args.out))
# The frames are square pixels at four by three; the video is 720x480, which the engine shows at
# four by three as well, so this is a scale and not a crop. A keyframe every KEY_EVERY frames
# keeps the menu's seek to the top of the loop instant and the engine's keyframe warning quiet.
# No audio track: the menu plays the sound itself, over this or over the drawn backdrop.
command = ["ffmpeg", "-y", "-loglevel", "error",
"-framerate", "1000/%d" % args.step, "-i", os.path.join(frames, "singe%03d.png"),
"-an", "-vf", "scale=%d:%d:flags=lanczos" % (VIDEO_W, VIDEO_H),
"-c:v", "libx264", "-crf", str(CRF), "-pix_fmt", "yuv420p",
"-force_key_frames", "expr:eq(mod(n,%d),0)" % KEY_EVERY,
"-f", "matroska", args.out]
subprocess.run(command, check=True)
if not args.keep:
shutil.rmtree(frames, ignore_errors=True)
print("%s: %d frames at %g fps, %.2f seconds" % (os.path.relpath(args.out, REPO), total, 1000.0 / args.step, total * args.step / 1000.0))
print("intro %d frames, loop %d frames (%d turns of the grid)" % (intro, loop, args.periods))
print("sound: %s" % os.path.basename(sting))
print("Singe/Menu.singe: DISC_MENU_FRAME = %d, DISC_GRID_START = %d, DISC_LAST_FRAME = %d" % (menu, intro, total - 1))
# And whether that is what it says today. The menu cannot read these out of the video, so they
# are written down in two places and this is what stops the two from parting company.
written = open(os.path.join(REPO, "assets", "Menu.singe"), encoding="utf-8").read()
for name, value in (("DISC_MENU_FRAME", menu), ("DISC_GRID_START", intro), ("DISC_LAST_FRAME", total - 1)):
if ("%s = %d" % (name, value)) not in written:
print("WARNING: assets/Menu.singe does not say %s = %d; it has to" % (name, value))
main()