singe/util/renderReel.py

277 lines
15 KiB
Python

# 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
# <data directory>/<game name>/ 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()