# Renders the menu's intro -- Singe/Backdrop.singe from the charge to the moment the music has # gone -- as one high-quality H.264 file with its sound, for use outside the engine: an advert, a # trailer, a page. The same headless recording util/renderMenuVideo.py makes for the engine's own # copy, at whatever size and rate is asked for, with the tagline laid out at that size rather than # scaled up from the menu's, and assets/menuIntro.flac muxed in as AAC. # # The engine's offscreen video driver stops at 1024x768, so anything larger is recorded under a # virtual X display (xvfb-run), where the hardware Vulkan device cannot present and SDL falls back # to the software rasteriser. That is slow -- a frame is a second or two at 1080p -- and it is the # same picture, so it is worth the wait. # # The rate has to divide the intro into whole frames: the intro ends at 9.90 seconds, so a 20 ms # step (50 fps) or a 33 ms step (30.3 fps) works and a 17 ms step does not; the script says so. # # Usage: python3 util/renderMenuAd.py (1920x1080 at 50 fps) # python3 util/renderMenuAd.py --width 3840 --height 2160 --step 20 --out intro4k.mp4 import argparse import os import re import shutil import subprocess import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) OFFSCREEN_W = 1024 # The biggest the offscreen video driver gives; larger goes under Xvfb. OFFSCREEN_H = 768 CRF = 15 # Near transparent for flat colour and neon, the file stays small anyway. AUDIO_BITRATE = "320k" DRIVER = '''-- Written by util/renderMenuAd.py. Records Singe/Backdrop.singe's intro frame by frame. dofile("Singe/Backdrop.singe") local STEP = %(step)d / 1000.0 local introFrames = backdropLoopAt() / STEP if math.abs(introFrames - math.floor(introFrames + 0.5)) > 0.0001 then error(string.format("the intro ends at %%g seconds, which is not a frame at %%d ms; try 20 or 33", backdropLoopAt(), %(step)d)) end introFrames = math.floor(introFrames + 0.5) local frame = 0 debugPrint(string.format("RENDER intro=%%d step=%%d", introFrames, %(step)d)) overlaySetResolution(%(width)d, %(height)d) backdropBegin(true) function onOverlayUpdate() colorBackground(0, 0, 0, 0) overlayClear() backdropFrame() singeScreenshot() frame = frame + 1 if frame >= introFrames 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, width, height, step): env = dict(os.environ) env["SDL_AUDIO_DRIVER"] = "dummy" env["SDL_AUDIODRIVER"] = "dummy" command = [binary, "-k", "-s", "-C", "%dx%d" % (width, height), "-x", str(width), "-y", str(height), "--deterministic=%d" % step, "-d", frames + os.sep, "render.singe"] if (width > OFFSCREEN_W) or (height > OFFSCREEN_H): command = ["xvfb-run", "-a", "-s", "-screen 0 %dx%dx24" % (width, height)] + command else: env["SDL_VIDEODRIVER"] = "offscreen" 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="render the menu intro with its sound as one H.264 file") parser.add_argument("--out", default=os.path.join(REPO, ".builddir", "SingeIntro.mp4")) parser.add_argument("--width", type=int, default=1920) parser.add_argument("--height", type=int, default=1080) parser.add_argument("--step", type=int, default=20, help="milliseconds a frame: 20 is 50 fps, 33 is 30.3") parser.add_argument("--crf", type=int, default=CRF) 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", "menuAd") frames = os.path.join(work, "frames") shutil.rmtree(frames, ignore_errors=True) os.makedirs(frames) with open(os.path.join(work, "render.singe"), "w") as out: out.write(DRIVER % {"step": args.step, "width": args.width, "height": args.height}) print("recording %dx%d at %g fps with %s" % (args.width, args.height, 1000.0 / args.step, os.path.basename(binary))) text = render(binary, work, frames, args.width, args.height, args.step) found = re.search(r"RENDER intro=(\d+) step=(\d+)", text) if not found: sys.stdout.write(text) raise SystemExit("the engine never said how long the intro is") intro = int(found.group(1)) 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) != intro: raise SystemExit("wanted %d frames and got %d" % (intro, len(shots))) # H.264 High at a near-transparent quality, 4:2:0 so anything plays it, the sound as AAC, and # the index at the front so it streams. -shortest: the sound is written to the intro's length, # but the frames are the authority. command = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", "1000/%d" % args.step, "-i", os.path.join(frames, "singe%03d.png"), "-i", os.path.join(REPO, "assets", "menuIntro.flac"), "-c:v", "libx264", "-preset", "slow", "-crf", str(args.crf), "-profile:v", "high", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", AUDIO_BITRATE, "-movflags", "+faststart", "-shortest", args.out] subprocess.run(command, check=True) if not args.keep: shutil.rmtree(frames, ignore_errors=True) print("%s: %d frames, %dx%d at %g fps, %.2f seconds" % (args.out, intro, args.width, args.height, 1000.0 / args.step, intro * args.step / 1000.0)) main()