singe/util/renderMenuAd.py
2026-09-22 18:32:07 -05:00

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()