# 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()