singe/util/demoVideo.py

144 lines
5.6 KiB
Python

# Precalculates the demos' per-pixel effects as videos: the oldest trick in the demoscene, done with
# numpy instead of lookup tables in assembler. Singe then plays them, scrubs them, turns them, and
# puts them on 3D surfaces.
#
# python3 util/demoVideo.py tunnel out.mp4 [palette] [frames]
# python3 util/demoVideo.py plasma out.mp4 [palette] [frames] [fps]
# python3 util/demoVideo.py noise out.mp4 [frames]
#
# tunnel draws exactly one period of travel down a texture-mapped tunnel in frames frames, every one a
# keyframe, so a demo can seek to any frame at once and move through the tunnel at whatever speed the
# music says; the last frame leads back into the first. plasma is an ordinary looping video whose
# every term repeats a whole number of times in its length, so it loops without a seam. noise is a
# tape's snow and tracking bands, also looping.
import math
import subprocess
import sys
import numpy as np
W = 640
H = 480
PALETTES = {
# Neon: black, violet, magenta, orange, and a cyan accent.
"neon": [(0.00, (4, 0, 16)), (0.25, (60, 10, 120)), (0.50, (255, 30, 170)), (0.70, (255, 140, 60)), (0.85, (255, 230, 150)), (1.00, (40, 230, 255))],
# Disco: deep red, gold, and white.
"disco": [(0.00, (20, 0, 10)), (0.30, (150, 20, 40)), (0.55, (255, 150, 30)), (0.80, (255, 230, 140)), (1.00, (255, 255, 255))],
# Arcade: blue, green, and yellow.
"arcade": [(0.00, (0, 0, 20)), (0.30, (10, 40, 160)), (0.55, (20, 200, 120)), (0.80, (240, 240, 60)), (1.00, (255, 255, 255))],
}
def ramp(name, t):
stops = PALETTES[name]
t = np.clip(t, 0, 1)
out = np.zeros(t.shape + (3,))
for (t0, c0), (t1, c1) in zip(stops[:-1], stops[1:]):
m = (t >= t0) & (t <= t1)
k = ((t - t0) / (t1 - t0))[m][:, None]
out[m] = np.array(c0) * (1 - k) + np.array(c1) * k
return out
def encoder(out, fps, intra):
args = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", "%dx%d" % (W, H), "-r", str(fps), "-i", "-",
"-c:v", "libx264", "-preset", "slow", "-crf", "18", "-pix_fmt", "yuv420p", "-an"]
if intra:
args += ["-g", "1", "-keyint_min", "1"]
else:
args += ["-g", str(int(float(fps)))]
return subprocess.Popen(args + [out], stdin=subprocess.PIPE)
# Down a tunnel: each pixel's angle round the axis and its depth (inverse distance from the centre)
# index a texture of neon rings and spokes. Depth runs one texture period over the frames.
def tunnel(out, palette="neon", frames=120):
y, x = np.mgrid[0:H, 0:W].astype(np.float64)
x = (x - W / 2 + 0.5) / (H / 2)
y = (y - H / 2 + 0.5) / (H / 2)
r = np.sqrt(x * x + y * y) + 1e-6
a = np.arctan2(y, x) / (2 * math.pi)
depth = 0.35 / r
shade = np.clip(r * 1.6, 0, 1) ** 0.8
pipe = encoder(out, 50, True)
RINGS = 4
SPOKES = 16
for f in range(frames):
p = f / frames
u = depth + p / RINGS
v = a + depth * 0.15
ring = 0.5 + 0.5 * np.cos(u * RINGS * 2 * math.pi * RINGS)
edge = np.abs(np.mod(u * RINGS * RINGS, 1.0) - 0.5) * 2
spoke = np.abs(np.mod(v * SPOKES, 1.0) - 0.5) * 2
check = ((np.floor(u * RINGS * RINGS) + np.floor(v * SPOKES)) % 2)
glowRing = np.exp(-(1 - edge) * 9)
glowSpoke = np.exp(-(1 - spoke) * 12)
value = 0.18 + 0.22 * check + 0.75 * glowRing + 0.45 * glowSpoke * ring
col = ramp(palette, np.clip(value * shade, 0, 1))
# Far away the tunnel fades into a hot white core.
core = np.exp(-r * 9)[..., None]
col = col * (1 - core) + core * np.array([255, 235, 255])
pipe.stdin.write(np.clip(col, 0, 255).astype(np.uint8).tobytes())
pipe.stdin.close()
pipe.wait()
# Plasma: four interfering waves, each a whole number of turns over the loop, run through a palette
# that itself cycles a whole number of times.
def plasma(out, palette="neon", frames=400, fps=50):
y, x = np.mgrid[0:H, 0:W].astype(np.float64)
x /= W
y /= H
pipe = encoder(out, fps, False)
for f in range(frames):
p = f / frames * 2 * math.pi
v = np.sin(x * 9 + p)
v += np.sin((y * 7 + p * 2) * 1.0)
v += np.sin((x * 6 + y * 5) + p * 3)
cx = x - 0.5 + 0.3 * math.sin(p)
cy = y - 0.5 + 0.3 * math.cos(p * 2)
v += np.sin(np.sqrt(cx * cx + cy * cy) * 18 - p * 4)
t = (np.sin(v * math.pi * 0.5 + p) + 1) / 2
col = ramp(palette, t)
pipe.stdin.write(np.clip(col, 0, 255).astype(np.uint8).tobytes())
pipe.stdin.close()
pipe.wait()
# A tape's snow, with a couple of tracking bands rolling up the picture, at a quarter of the size
# and scaled up by the player.
def noise(out, frames=100):
global W, H
W, H = 320, 240
rng = np.random.default_rng(11)
pipe = encoder(out, 25, False)
rows = np.arange(H)[:, None]
for f in range(frames):
snow = rng.random((H, W)) ** 1.6 * 200
# Grain in streaks: each row's noise smeared sideways a little.
snow = (snow + np.roll(snow, 1, axis=1) + np.roll(snow, 2, axis=1)) / 3
p = f / frames
band = np.exp(-((rows - (H * (1 - p) * 2) % (H + 40) + 20) ** 2) / 60.0)
band2 = np.exp(-((rows - (H * (1 - p) * 2 + H / 2) % (H + 40) + 20) ** 2) / 20.0)
v = snow * (0.55 + 0.45 * band + 0.6 * band2) + band * 60
col = np.stack([v * 0.95, v * 0.9, v * 1.05], -1)
pipe.stdin.write(np.clip(col, 0, 255).astype(np.uint8).tobytes())
pipe.stdin.close()
pipe.wait()
def main():
kind = sys.argv[1]
out = sys.argv[2]
if kind == "tunnel":
tunnel(out, sys.argv[3] if len(sys.argv) > 3 else "neon", int(sys.argv[4]) if len(sys.argv) > 4 else 120)
elif kind == "plasma":
plasma(out, sys.argv[3] if len(sys.argv) > 3 else "neon", int(sys.argv[4]) if len(sys.argv) > 4 else 400, sys.argv[5] if len(sys.argv) > 5 else 50)
elif kind == "noise":
noise(out, int(sys.argv[3]) if len(sys.argv) > 3 else 100)
print("wrote", out)
if __name__ == "__main__":
main()