# Renders a sizzle reel: the menu's intro with its sound, then a run of short shots of games and # scenes, each with a caption, cut together as one H.264 file. Every shot is the engine itself # running a script headless and photographing every frame, the way util/renderMenuAd.py records # the intro, so what the reel shows is what the engine draws and nothing is mocked up. # # The shots are a list at the top of this file: a caption, a script (in the repo's testScripts, # or a lesson from docs/learn), how many frames, and whether it needs the disc. Add a line to # add a shot. # # The intro keeps its own sound. Nothing in the tree loops under the rest -- the backdrop # recording has no audio and the menu's intro fades out by design -- so the shots are silent # unless --music names a track. The engine's offscreen video driver stops at 1024x768, so anything larger records # under a virtual X display (xvfb-run) on the software rasteriser, which is slow -- a frame is a # second or two at 1080p -- and the same picture; a quick look at 1024x576 records on the real # GPU in a fraction of the time. # # Usage: python3 util/renderReel.py (1920x1080 at 50 fps) # python3 util/renderReel.py --width 1024 --height 576 --step 33 --out quick.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 AUDIO_BITRATE = "320k" FONT = os.path.join(REPO, "assets", "FreeSansBold.ttf") VIDEO = "Singe/menuBackground.mkv" # The reel: what to show, in order. frames is how long each shot runs at the chosen step; at # 20 ms a shot of 150 frames is three seconds. A shot with disc = True runs over the menu's # backdrop video, which every install has. A shot names a script in testScripts or a lesson in # docs/learn (the lesson folder layout the book teaches is built for it). SHOTS = [ # The engine's own test scenes make the pictures; they write what they are checking over the # top, which "clean" wipes away (see RUNNER). A 2D shot must not be cleaned: it draws into # the overlay, and cleaning would wipe the game itself. Several scenes put the disc picture on # a material and will not start without one, so they are given the menu's backdrop, which is # the one video every install has. { "caption": "Sponza, lit and shadowed", "script": "testScripts/scene27.singe", "frames": 150, "clean": True }, { "caption": "Sky, fog, and a sun that casts", "script": "testScripts/scene30.singe", "frames": 150, "clean": True }, { "caption": "Bloom", "script": "testScripts/scene32.singe", "frames": 150, "clean": True }, { "caption": "Terrain", "script": "testScripts/scene34.singe", "frames": 150, "clean": True }, { "caption": "Water", "script": "testScripts/scene24.singe", "frames": 150, "clean": True, "disc": True }, { "caption": "A bulb in a room", "script": "testScripts/scene21.singe", "frames": 150, "clean": True, "disc": True }, { "caption": "Materials", "script": "testScripts/scene28.singe", "frames": 150, "clean": True }, { "caption": "Physics", "script": "testScripts/scene9.singe", "frames": 150, "clean": True, "disc": True }, { "caption": "Ragdolls", "script": "testScripts/scene25.singe", "frames": 150, "clean": True, "disc": True }, { "caption": "Vehicles", "script": "testScripts/scene23.singe", "frames": 150, "clean": True, "disc": True }, { "caption": "Particles", "script": "testScripts/scene18.singe", "frames": 150, "clean": True, "disc": True }, { "caption": "Characters that find their own way","script": "testScripts/scene33.singe", "frames": 150, "clean": True }, { "caption": "Animation, blended", "script": "testScripts/scene29.singe", "frames": 150, "clean": True }, # And what somebody makes with it: the lessons of the beginner's book, which are real games # and are ours. These draw into the overlay, so they are never cleaned. { "caption": "Lesson seven: a game from nothing","lesson": "07-a-game", "frames": 150 }, { "caption": "Lesson eleven: sprites and sound", "lesson": "11-hitting-things", "frames": 150 }, { "caption": "Thirty lessons from nothing", "lesson": "22-3d", "frames": 150 }, ] # The runner every shot goes through: the shot's own script, untouched, with a frame counter that # photographs each frame and quits when the shot is over. RUNNER = '''-- Written by util/renderReel.py: runs the shot's own script and photographs every frame. dofile(%(script)s) local shotUpdate = onOverlayUpdate local shotFrames = 0 function onOverlayUpdate() local result = nil if shotUpdate then result = shotUpdate() end -- A test scene writes what it is checking over its own picture, which is right for a test and -- wrong for a reel. A 3D scene is drawn by the engine underneath the overlay, so wiping the -- overlay after the scene has had its turn leaves the picture and takes the notes away. Only -- for shots that say so: a 2D scene draws INTO the overlay and would be wiped out with them. if %(clean)s then colorBackground(0, 0, 0, 0) overlayClear() end singeScreenshot() shotFrames = shotFrames + 1 if shotFrames >= %(frames)d then singeQuit() end return OVERLAY_UPDATED end ''' # Where a shot's pictures actually landed. The engine files screenshots under # // when the script it was launched with sits in a folder of its own, # and straight into the data directory when it does not; and it numbers from the first free name, # which is not always zero. Answers the directory and the number to start at, or None. def framesIn(folder): places = [folder] + [os.path.join(folder, name) for name in sorted(os.listdir(folder)) if os.path.isdir(os.path.join(folder, name))] for place in places: numbers = sorted(int(m.group(1)) for m in (re.match(r"^singe(\d+)\.png$", f) for f in os.listdir(place)) if m) if numbers: return place, numbers[0], len(numbers) return None, 0, 0 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 engine(binary, work, frames, width, height, step, script, disc): 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] if disc: command += ["-v", VIDEO] command.append(script) 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, timeout=3600) return result.returncode, result.stdout.decode("utf-8", "replace") # The intro, exactly as util/renderMenuAd.py records it, borrowed rather than copied. def renderIntro(binary, work, frames, width, height, step): sys.path.insert(0, os.path.join(REPO, "util")) import renderMenuAd with open(os.path.join(work, "render.singe"), "w") as out: out.write(renderMenuAd.DRIVER % {"step": step, "width": width, "height": height}) text = renderMenuAd.render(binary, work, frames, width, height, 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") return int(found.group(1)) def setUpShot(work, shot, runnerName): """Lays the shot out as the engine expects and answers (what to dofile, what to launch), both relative to the work directory. A lesson's runner goes INSIDE the lesson's folder: DIR is the folder of the script the engine was launched with, and the lesson loads its art through DIR.""" if "lesson" in shot: # A lesson runs from its own folder in a work folder, with the art kit beside it. source = os.path.join(REPO, "docs", "learn") folder = os.path.join(work, shot["lesson"]) shutil.rmtree(folder, ignore_errors=True) os.makedirs(folder) shutil.copyfile(os.path.join(source, shot["lesson"] + ".singe"), os.path.join(folder, shot["lesson"] + ".singe")) if os.path.isdir(os.path.join(source, "art")): shutil.copytree(os.path.join(source, "art"), os.path.join(folder, "art")) for extra in os.listdir(source): if os.path.isfile(os.path.join(source, extra)) and not extra.endswith(".singe"): shutil.copyfile(os.path.join(source, extra), os.path.join(folder, extra)) return (shot["lesson"] + "/" + shot["lesson"] + ".singe", shot["lesson"] + "/" + runnerName) link = os.path.join(work, "testScripts") if not os.path.islink(link): os.symlink(os.path.join(REPO, "testScripts"), link) return shot["script"], runnerName def main(): parser = argparse.ArgumentParser(description="render the sizzle reel") parser.add_argument("--out", default=os.path.join(REPO, ".builddir", "SingeReel.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("--music", help="a track to play quietly under the shots; without one they are silent") parser.add_argument("--musicLevel", type=float, default=0.25, help="how loud that track is, 1.0 being as recorded") parser.add_argument("--only", type=int, help="record only this many shots after the intro, for a quick look") 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", "reel") os.makedirs(work, exist_ok=True) fps = 1000.0 / args.step pieces = [] # The intro, with its own sound. frames = os.path.join(work, "intro") shutil.rmtree(frames, ignore_errors=True) os.makedirs(frames) print("intro") introFrames = renderIntro(binary, work, frames, args.width, args.height, args.step) where, first, got = framesIn(frames) pieces.append({ "frames": where or frames, "first": first, "count": got or introFrames, "caption": None, "sound": os.path.join(REPO, "assets", "menuIntro.flac") }) # The shots. shots = SHOTS[:args.only] if args.only else SHOTS for index, shot in enumerate(shots, 1): frames = os.path.join(work, "shot%02d" % index) shutil.rmtree(frames, ignore_errors=True) os.makedirs(frames) script, runner = setUpShot(work, shot, "reel%02d.singe" % index) with open(os.path.join(work, runner), "w") as out: out.write(RUNNER % {"script": '"%s"' % script, "frames": shot["frames"], "clean": "true" if shot.get("clean") else "false"}) print("shot %d of %d: %s" % (index, len(shots), shot["caption"])) code, text = engine(binary, work, frames, args.width, args.height, args.step, runner, shot.get("disc", False)) where, first, got = framesIn(frames) if got == 0: sys.stdout.write(text[-2000:]) raise SystemExit("shot %d recorded nothing" % index) pieces.append({ "frames": where, "first": first, "count": got, "caption": shot["caption"], "sound": None }) # Each piece becomes a clip with its caption burnt in, then the clips are joined. The intro # keeps its sound; the shots run under the intro's own tail, looped quietly, so the reel is # never silent. clips = [] for index, piece in enumerate(pieces): clip = os.path.join(work, "clip%02d.mp4" % index) command = ["ffmpeg", "-y", "-loglevel", "error", "-framerate", "1000/%d" % args.step, "-start_number", str(piece["first"]), "-i", os.path.join(piece["frames"], "singe%03d.png")] filters = ["format=yuv420p"] if piece["caption"]: text = piece["caption"].replace("'", "\\'").replace(":", "\\:") filters.append("drawtext=fontfile='%s':text='%s':fontsize=%d:fontcolor=white:borderw=3:bordercolor=black@0.8:x=(w-text_w)/2:y=h-text_h-%d" % (FONT, text, args.height // 18, args.height // 12)) command += ["-vf", ",".join(filters), "-c:v", "libx264", "-preset", "slow", "-crf", str(args.crf), "-profile:v", "high", "-an", clip] subprocess.run(command, check=True) clips.append(clip) listing = os.path.join(work, "clips.txt") with open(listing, "w") as out: for clip in clips: out.write("file '%s'\n" % clip) silent = os.path.join(work, "reel-silent.mp4") subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", listing, "-c", "copy", silent], check=True) # Sound: the intro's own, then whatever --music names, quietly, under the shots. Nothing in # the tree loops: the backdrop recording has no audio at all, and the menu's intro fades out # by design, so the shots are silent unless a track is given. total = sum(p["count"] for p in pieces) * args.step / 1000.0 introSeconds = pieces[0]["count"] * args.step / 1000.0 bed = os.path.join(work, "bed.wav") tail = max(0.0, total - introSeconds) if args.music: subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-stream_loop", "-1", "-i", args.music, "-vn", "-t", "%.3f" % tail, "-af", "volume=%g,afade=t=in:d=1,afade=t=out:st=%.3f:d=2" % (args.musicLevel, max(0.0, tail - 2.0)), "-ar", "48000", "-ac", "2", bed], check=True) else: subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", "%.3f" % tail, bed], check=True) sound = os.path.join(work, "sound.wav") subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", pieces[0]["sound"], "-i", bed, "-filter_complex", "[0:a]aformat=sample_rates=48000:channel_layouts=stereo[i];[1:a]adelay=%d|%d[b];[i][b]amix=inputs=2:duration=longest:dropout_transition=0,volume=2.0[a]" % (int(introSeconds * 1000), int(introSeconds * 1000)), "-map", "[a]", "-t", "%.3f" % total, sound], check=True) subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", silent, "-i", sound, "-c:v", "copy", "-c:a", "aac", "-b:a", AUDIO_BITRATE, "-movflags", "+faststart", "-shortest", args.out], check=True) if not args.keep: for piece in pieces: shutil.rmtree(piece["frames"], ignore_errors=True) print("%s: %d shots, %dx%d at %g fps, %.1f seconds" % (args.out, len(pieces) - 1, args.width, args.height, fps, total)) if __name__ == "__main__": main()