277 lines
15 KiB
Python
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()
|