224 lines
12 KiB
Python
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()
|