137 lines
6.2 KiB
Python
137 lines
6.2 KiB
Python
# 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()
|