Riot block ident previews (Laughs, Stunt Night, After Hours) for review; future event identities on the roadmap
Previews only, built from the Riot v2 layers, which stay unchanged. Not in the app. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,684 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Riot block idents, Batch 1 previews for Paul's review (3 November 2026 brief).
|
||||
|
||||
Riot Laughs, Saturday Stunt Night and Riot After Hours, each a short variation of the refined Riot (v2):
|
||||
the same layers, wordmark and colours, plus a block title and its own motion. Reads orbit-riot-v2 and never
|
||||
writes to it. Writes orbit-riot-blocks/<block>/ with editable SVG sources for every new element, the rendered
|
||||
layers, a silent ident at the app's encoding, a preview with an optional quiet sting, a contact sheet,
|
||||
a clean final still and a guide card. Nothing goes into the app.
|
||||
|
||||
build-riot-blocks.py [laughs|stunt|after] ...
|
||||
"""
|
||||
from pathlib import Path
|
||||
import html, json, math, os, random, re, shutil, struct, subprocess, sys, wave
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
ENV = dict(os.environ, XDG_DATA_HOME=str(ROOT / '.tooling/font-data'))
|
||||
V2 = HERE / 'orbit-riot-v2'
|
||||
OUT = HERE / 'orbit-riot-blocks'
|
||||
FONTS = ROOT / '.tooling/font-data/fonts'
|
||||
FPS, LENGTH = 30, 4.0
|
||||
LIME, PINK, CYAN, INK, PAPER_WHITE = '#D7FF00', '#FF2EA6', '#00E5FF', '#0B0E16', '#F7F7F7'
|
||||
CAUTION = '#FFC400'
|
||||
|
||||
# The logo sits where the channel's flat layers put it: 1600x640 at 93% of 1080p, centred at (960, 495).
|
||||
LOGO_SCALE, LOGO_X, LOGO_Y = .93, 216, 197.4
|
||||
SCREEN = (570, 262, 1210, 812) # the TV's screen
|
||||
WORD_FOOT = (973, 634) # RIOT's baseline, centre: the point it lands on
|
||||
|
||||
|
||||
def svg(w, h, body):
|
||||
return f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">{body}</svg>'
|
||||
|
||||
|
||||
def text(x, y, value, size, fill, family='Anton', weight='400', extra=''):
|
||||
return f'<text x="{x}" y="{y}" font-family="{family}" font-size="{size}" font-weight="{weight}" fill="{fill}" {extra}>{html.escape(value)}</text>'
|
||||
|
||||
|
||||
def vc(src, dst):
|
||||
subprocess.run(['vectorcraft-cli', 'convert', str(src), str(dst)], env=ENV, check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def jagged(points, seed, amount=9, step=26):
|
||||
rng, out = random.Random(seed), []
|
||||
for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1]):
|
||||
n = max(1, int(math.hypot(x2 - x1, y2 - y1) / step))
|
||||
for i in range(n):
|
||||
t = i / n
|
||||
out.append((round(x1 + (x2 - x1) * t + rng.uniform(-amount, amount)), round(y1 + (y2 - y1) * t + rng.uniform(-amount, amount))))
|
||||
return ' '.join(f'{x},{y}' for x, y in out)
|
||||
|
||||
|
||||
def burst_points(cx, cy, r1, r2, points, seed=0, wobble=0):
|
||||
rng, pts = random.Random(seed), []
|
||||
for i in range(points * 2):
|
||||
r, a = (r1 if i % 2 else r2) * (1 + rng.uniform(-wobble, wobble)), math.pi * i / points - math.pi / 2
|
||||
pts.append(f'{cx + r * math.cos(a):.0f},{cy + r * math.sin(a):.0f}')
|
||||
return ' '.join(pts)
|
||||
|
||||
|
||||
# ---- Easing and keyframes -------------------------------------------------------------------------------------
|
||||
|
||||
def ease_out(u):
|
||||
return 1 - (1 - u) ** 3
|
||||
|
||||
|
||||
def ease_in(u):
|
||||
return u ** 3
|
||||
|
||||
|
||||
def ease_io(u):
|
||||
return 3 * u * u - 2 * u ** 3
|
||||
|
||||
|
||||
def kf(t, keys, ease=ease_out):
|
||||
"""Piecewise interpolation through (time, value) keys; values may be numbers or tuples."""
|
||||
if t <= keys[0][0]:
|
||||
return keys[0][1]
|
||||
for (t0, v0), (t1, v1) in zip(keys, keys[1:]):
|
||||
if t <= t1:
|
||||
u = ease((t - t0) / (t1 - t0)) if t1 > t0 else 1
|
||||
if isinstance(v0, tuple):
|
||||
return tuple(a + (b - a) * u for a, b in zip(v0, v1))
|
||||
return v0 + (v1 - v0) * u
|
||||
return keys[-1][1]
|
||||
|
||||
|
||||
# ---- Layers ---------------------------------------------------------------------------------------------------
|
||||
|
||||
class Stage:
|
||||
"""Composites full-frame RGBA layers at a working size; every coordinate is in 1080p units."""
|
||||
|
||||
def __init__(self, width):
|
||||
self.W, self.H, self.R = width, round(width * 9 / 16), width / 1920
|
||||
self.cache = {}
|
||||
|
||||
def frame_png(self, path):
|
||||
key = ('bg', str(path))
|
||||
if key not in self.cache:
|
||||
self.cache[key] = Image.open(path).convert('RGBA').resize((self.W, self.H), Image.LANCZOS)
|
||||
return self.cache[key]
|
||||
|
||||
def logo_png(self, path):
|
||||
"""A 1600x640 logo part, placed on a full frame where the channel's logo sits."""
|
||||
key = ('logo', str(path))
|
||||
if key not in self.cache:
|
||||
part = Image.open(path).convert('RGBA')
|
||||
w, h = round(part.width * LOGO_SCALE * self.R), round(part.height * LOGO_SCALE * self.R)
|
||||
canvas = Image.new('RGBA', (self.W, self.H))
|
||||
canvas.alpha_composite(part.resize((w, h), Image.LANCZOS), (round(LOGO_X * self.R), round(LOGO_Y * self.R)))
|
||||
self.cache[key] = canvas
|
||||
return self.cache[key]
|
||||
|
||||
def xf(self, layer, scale=1, rot=0, anchor=(960, 540), move=(0, 0), alpha=1):
|
||||
"""Scale (a number or (sx, sy)) and rotate (degrees) about anchor, then move; fade by alpha."""
|
||||
if alpha <= 0:
|
||||
return None
|
||||
sx, sy = scale if isinstance(scale, tuple) else (scale, scale)
|
||||
if (sx, sy, rot, move) != (1, 1, 0, (0, 0)):
|
||||
ax, ay = anchor[0] * self.R, anchor[1] * self.R
|
||||
dx, dy = move[0] * self.R, move[1] * self.R
|
||||
c, s = math.cos(math.radians(rot)), math.sin(math.radians(rot))
|
||||
# Output p' = RS(p - a) + a + d, so input p = (RS)^-1 (p' - a - d) + a.
|
||||
a, b, cc, d = c / sx, s / sx, -s / sy, c / sy
|
||||
ox, oy = ax + dx, ay + dy
|
||||
layer = layer.transform((self.W, self.H), Image.AFFINE, (a, b, ax - a * ox - b * oy, cc, d, ay - cc * ox - d * oy), resample=Image.BICUBIC)
|
||||
if alpha < 1:
|
||||
layer = layer.copy()
|
||||
layer.putalpha(layer.getchannel('A').point(lambda v: round(v * alpha)))
|
||||
return layer
|
||||
|
||||
|
||||
def fade(layer, alpha):
|
||||
layer = layer.copy()
|
||||
layer.putalpha(layer.getchannel('A').point(lambda v: round(v * alpha)))
|
||||
return layer
|
||||
|
||||
|
||||
def tint(layer, rgb):
|
||||
"""Multiply a layer's colours, keeping its alpha (for the darker After Hours grade)."""
|
||||
solid = Image.new('RGBA', layer.size, rgb + (255,))
|
||||
out = ImageChops.multiply(layer, solid)
|
||||
out.putalpha(layer.getchannel('A'))
|
||||
return out
|
||||
|
||||
|
||||
def glow(layer, radius, strength=1.0):
|
||||
blurred = layer.filter(ImageFilter.GaussianBlur(radius))
|
||||
if strength != 1:
|
||||
blurred.putalpha(blurred.getchannel('A').point(lambda v: min(255, round(v * strength))))
|
||||
return blurred
|
||||
|
||||
|
||||
# ---- New elements, written as editable SVG sources -------------------------------------------------------------
|
||||
|
||||
def caution_stripes(x, y, w, h, gap=56, angle=-35):
|
||||
"""Yellow and black hazard stripes in a band, clipped to the band."""
|
||||
stripes = ''.join(f'<rect x="{x + i * gap * 2 - h}" y="{y - 10}" width="{gap}" height="{h + 20}" fill="{INK}" transform="skewX({angle})"/>' for i in range(int((w + 2 * h) / (gap * 2)) + 2))
|
||||
return stripes
|
||||
|
||||
|
||||
def elements(block):
|
||||
"""The block's new SVG elements, each a full 1920x1080 frame (or 1600x640 for logo-space parts)."""
|
||||
e = {}
|
||||
if block == 'laughs':
|
||||
# A pink comic burst with a hard ink print shadow, HA! in Anton: loud, not cute.
|
||||
shape = burst_points(1548, 262, 118, 190, 11, seed=4, wobble=.12)
|
||||
e['ha'] = svg(1920, 1080,
|
||||
f'<polygon points="{shape}" fill="{INK}" transform="translate(12,12)"/>'
|
||||
f'<polygon points="{shape}" fill="{PINK}" stroke="{INK}" stroke-width="7" stroke-linejoin="round"/>'
|
||||
+ text(1440, 312, 'HA!', 132, INK, extra='letter-spacing="4" transform="rotate(-8 1548 262)"')
|
||||
+ text(1434, 305, 'HA!', 132, PAPER_WHITE, extra='letter-spacing="4" transform="rotate(-8 1548 262)"'))
|
||||
# A rubber stamp slapped on at an angle under RIOT: a solid pink ink block with LAUGHS knocked out in ink,
|
||||
# a hard print shadow, and uneven ink (lighter speckles) so it looks stamped, not typeset.
|
||||
rng = random.Random(17)
|
||||
wear = ''.join(f'<circle cx="{rng.randint(900, 1330)}" cy="{rng.randint(800, 900)}" r="{rng.choice((2, 3, 4, 6))}" fill="#FF8CCB" opacity="{rng.choice((.35, .5, .7))}"/>' for _ in range(70))
|
||||
e['stamp'] = svg(1920, 1080,
|
||||
'<defs><clipPath id="s"><rect x="890" y="792" width="440" height="108" rx="8"/></clipPath></defs>'
|
||||
'<g transform="rotate(-5 1110 846)">'
|
||||
f'<rect x="890" y="792" width="440" height="108" rx="8" fill="{INK}" transform="translate(9,9)"/>'
|
||||
f'<rect x="890" y="792" width="440" height="108" rx="8" fill="{PINK}"/>'
|
||||
f'<g clip-path="url(#s)">{wear}</g>'
|
||||
f'<rect x="902" y="804" width="416" height="84" rx="4" fill="none" stroke="{INK}" stroke-width="4"/>'
|
||||
+ text(1110, 880, 'LAUGHS', 78, INK, extra='letter-spacing="18" text-anchor="middle"') + '</g>')
|
||||
elif block == 'stunt':
|
||||
# Band A: full hazard stripes, whipped across behind the TV.
|
||||
band_a = jagged([(-40, 130), (1960, 50), (1960, 128), (-40, 208)], 21, 4, 40)
|
||||
e['tape_a'] = svg(1920, 1080,
|
||||
f'<defs><clipPath id="a"><polygon points="{band_a}"/></clipPath></defs>'
|
||||
f'<polygon points="{band_a}" fill="{CAUTION}"/><g clip-path="url(#a)" transform="rotate(-3 960 135)">{caution_stripes(-200, 60, 2400, 160, 48)}</g>')
|
||||
# Band B: the title band under RIOT, torn at both ends, striped edges and stencil lettering.
|
||||
band_b = jagged([(470, 800), (1500, 778), (1508, 898), (478, 918)], 22, 9, 22)
|
||||
e['tape_b'] = svg(1920, 1080,
|
||||
f'<defs><clipPath id="b"><polygon points="{band_b}"/></clipPath></defs>'
|
||||
f'<polygon points="{band_b}" fill="{INK}" transform="translate(10,10)" opacity=".55"/>'
|
||||
f'<polygon points="{band_b}" fill="{CAUTION}"/>'
|
||||
f'<g clip-path="url(#b)" transform="rotate(-1.2 990 848)">{caution_stripes(400, 900, 1200, 22, 30)}</g>'
|
||||
+ text(990, 885, 'STUNT NIGHT', 92, INK, extra='letter-spacing="14" text-anchor="middle" transform="rotate(-1.2 990 848)"')
|
||||
+ f'<rect x="700" y="850" width="580" height="5" fill="{CAUTION}" transform="rotate(-1.2 990 848)"/>')
|
||||
# The comic impact star.
|
||||
shape = burst_points(1546, 262, 138, 214, 12, seed=8, wobble=.16)
|
||||
e['crash'] = svg(1920, 1080,
|
||||
f'<polygon points="{shape}" fill="{INK}" transform="translate(12,12)"/>'
|
||||
f'<polygon points="{shape}" fill="{CAUTION}" stroke="{INK}" stroke-width="7" stroke-linejoin="round"/>'
|
||||
+ text(1546, 290, 'CRASH!', 72, INK, extra='letter-spacing="2" text-anchor="middle" transform="rotate(-7 1546 262)"'))
|
||||
# Skid marks where the pink sweep was: two tyre tracks with tread, in the logo's 1600x640 space.
|
||||
tracks = ''
|
||||
for off in (0, 18):
|
||||
tracks += f'<path d="M470 {612 + off}C760 {634 + off} 1000 {620 + off} 1280 {584 + off}" fill="none" stroke="{PAPER_WHITE}" stroke-width="9" stroke-dasharray="10 7" opacity=".7"/>'
|
||||
e['skid'] = svg(1600, 640, tracks)
|
||||
elif block == 'after':
|
||||
# The moon: Orbit's crescent, large and silver, rising behind the TV.
|
||||
e['moon'] = svg(1920, 1080,
|
||||
'<defs><linearGradient id="m" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset="1" stop-color="#C9D2F2"/></linearGradient></defs>'
|
||||
'<g transform="translate(1300,-70) scale(1.15)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#m)"/></g>')
|
||||
# Neon AFTER HOURS: a pink tube outline with a pale core; the glow is added when rendering.
|
||||
line = 'letter-spacing="10" text-anchor="middle"'
|
||||
e['neon_tube'] = svg(1920, 1080, text(1000, 888, 'AFTER HOURS', 104, 'none', 'Comfortaa', '700', f'{line} stroke="{PINK}" stroke-width="9" stroke-linejoin="round"'))
|
||||
e['neon_core'] = svg(1920, 1080, text(1000, 888, 'AFTER HOURS', 104, 'none', 'Comfortaa', '700', f'{line} stroke="#FFE3F3" stroke-width="3" stroke-linejoin="round"'))
|
||||
return e
|
||||
|
||||
|
||||
GUIDE_LABELS = {
|
||||
'laughs': (f'<g transform="rotate(-6 426 92)"><rect x="346" y="58" width="160" height="66" rx="6" fill="{INK}" transform="translate(4,4)"/>'
|
||||
f'<rect x="346" y="58" width="160" height="66" rx="6" fill="{PINK}"/><rect x="353" y="65" width="146" height="52" rx="3" fill="none" stroke="{INK}" stroke-width="2.5"/>'
|
||||
+ text(426, 108, 'LAUGHS', 40, INK, extra='letter-spacing="5" text-anchor="middle"') + '</g>'),
|
||||
'stunt': (f'<polygon points="{jagged([(340, 52), (508, 46), (510, 130), (342, 136)], 31, 3, 14)}" fill="{CAUTION}"/>'
|
||||
+ text(426, 86, 'STUNT', 36, INK, extra='letter-spacing="5" text-anchor="middle"') + text(426, 126, 'NIGHT', 36, INK, extra='letter-spacing="5" text-anchor="middle"')),
|
||||
'after': (text(428, 84, 'AFTER', 36, 'none', 'Comfortaa', '700', f'letter-spacing="3" text-anchor="middle" stroke="{PINK}" stroke-width="4"')
|
||||
+ text(428, 128, 'HOURS', 36, 'none', 'Comfortaa', '700', f'letter-spacing="3" text-anchor="middle" stroke="{PINK}" stroke-width="4"')),
|
||||
}
|
||||
|
||||
|
||||
# ---- The three idents -----------------------------------------------------------------------------------------
|
||||
|
||||
LOGO_PARTS = ['dots', 'pink', 'paper', 'crescent', 'word', 'orbit', 'accent', 'sweep']
|
||||
|
||||
|
||||
class Block:
|
||||
def __init__(self, key, stage, d):
|
||||
self.key, self.st, self.d = key, stage, d
|
||||
self.bg = {n: stage.frame_png(V2 / f'exports/bg_{n}.png') for n in ['sky', 'torn', 'city', 'burst', 'bolt_left', 'bolt_right', 'strip', 'tv', 'static']}
|
||||
self.logo = {n: stage.logo_png(V2 / f'exports/logo_{n}.png') for n in LOGO_PARTS}
|
||||
self.new = {}
|
||||
for name in elements(key):
|
||||
png = d / 'exports' / f'{name}.png'
|
||||
img = Image.open(png).convert('RGBA')
|
||||
self.new[name] = stage.logo_png(png) if img.size == (1600, 640) else stage.frame_png(png)
|
||||
|
||||
def comp(self, layers):
|
||||
out = Image.new('RGBA', (self.st.W, self.st.H), (11, 14, 42, 255))
|
||||
for layer in layers:
|
||||
if layer is not None:
|
||||
out.alpha_composite(layer)
|
||||
return out
|
||||
|
||||
def logo_reveal(self, t, start, skip=(), xform=None):
|
||||
"""The channel's own logo assembly, sped up: each part slams or fades in on a short offset."""
|
||||
style = {'dots': 'fade', 'pink': 'slam', 'paper': 'slam', 'crescent': 'fade', 'word': 'slam', 'orbit': 'fade', 'accent': 'slam', 'sweep': 'fade'}
|
||||
layers = []
|
||||
for i, name in enumerate(LOGO_PARTS):
|
||||
if name in skip:
|
||||
continue
|
||||
at = start + i * .07
|
||||
a = kf(t, [(at, 0), (at + .22, 1)])
|
||||
s = kf(t, [(at, 1.22), (at + .16, .98), (at + .26, 1)]) if style[name] == 'slam' else 1
|
||||
layer = self.st.xf(self.logo[name], s, anchor=(960, 495), alpha=a)
|
||||
layers.append(xform(name, layer) if xform and layer is not None else layer)
|
||||
return layers
|
||||
|
||||
def frame(self, t):
|
||||
return getattr(self, f'frame_{self.key}')(t)
|
||||
|
||||
# Riot Laughs: the static pops like a bubble, RIOT bounces in with squash and stretch, HA! pops and wobbles,
|
||||
# the halftone jiggles, and a pink LAUGHS stamp slaps down. Hard print shadows keep it adult, not nursery.
|
||||
def frame_laughs(self, t):
|
||||
st, bg = self.st, self.bg
|
||||
base = kf(t, [(0, 0), (.3, 1)])
|
||||
static_s = kf(t, [(.38, 1), (.58, 1.18)], ease_in)
|
||||
static_a = base * kf(t, [(.38, 1), (.58, 0)])
|
||||
layers = [st.xf(bg['sky'], alpha=base), st.xf(bg['torn'], alpha=base), st.xf(bg['city'], alpha=base),
|
||||
st.xf(bg['bolt_left'], alpha=base), st.xf(bg['strip'], alpha=base), st.xf(bg['tv'], alpha=base),
|
||||
st.xf(bg['static'], static_s, anchor=(890, 537), alpha=static_a)]
|
||||
# The bubble's rim: one thin ring that spreads and fades as the static pops.
|
||||
if .38 <= t <= .75:
|
||||
u = (t - .38) / .37
|
||||
ring = Image.new('RGBA', (st.W, st.H))
|
||||
r = (120 + 330 * ease_out(u)) * st.R
|
||||
cx, cy = 890 * st.R, 537 * st.R
|
||||
ImageDraw.Draw(ring).ellipse((cx - r, cy - r * .86, cx + r, cy + r * .86), outline=(247, 247, 247, round(200 * (1 - u))), width=max(2, round(7 * st.R)))
|
||||
layers.append(ring)
|
||||
# The dots jiggle once the gag lands, then settle.
|
||||
jig = lambda name, layer: st.xf(layer, move=(4 * math.sin(t * 38) * kf(t, [(1.5, 1), (2.7, 0)], ease_io), 3 * math.cos(t * 31) * kf(t, [(1.5, 1), (2.7, 0)], ease_io))) if name == 'dots' and 1.5 < t < 2.7 else layer
|
||||
layers += self.logo_reveal(t, .55, skip=('word',), xform=jig)
|
||||
# RIOT drops from above, squashes on landing, stretches back up and settles.
|
||||
drop = kf(t, [(.7, -520), (.95, 0)], ease_in)
|
||||
sx = kf(t, [(.95, 1), (1.02, 1.14), (1.12, .94), (1.22, 1.03), (1.32, 1)], ease_io)
|
||||
sy = kf(t, [(.95, 1), (1.02, .82), (1.12, 1.09), (1.22, .98), (1.32, 1)], ease_io)
|
||||
layers.append(st.xf(self.logo['word'], (sx, sy), anchor=WORD_FOOT, move=(0, drop), alpha=kf(t, [(.7, 0), (.8, 1)])))
|
||||
# HA! pops at the top right (where the right-hand bolt sits) and wobbles to a stop.
|
||||
pop = kf(t, [(1.3, .2), (1.5, 1.12), (1.62, 1)])
|
||||
wob = 7 * math.sin((t - 1.3) * 17) * kf(t, [(1.3, 1), (2.6, 0)], ease_io) if t > 1.3 else 0
|
||||
layers.append(st.xf(self.new['ha'], pop, wob, anchor=(1548, 262), alpha=kf(t, [(1.3, 0), (1.42, 1)])))
|
||||
# The stamp slaps down: big and light, then hard onto the page.
|
||||
layers.append(st.xf(self.new['stamp'], kf(t, [(1.9, 1.7), (2.06, .97), (2.14, 1)], ease_in), anchor=(1110, 846), alpha=kf(t, [(1.9, 0), (2.0, 1)])))
|
||||
return self.comp(layers)
|
||||
|
||||
# Saturday Stunt Night: caution tape whips across, RIOT crash-zooms in with a CRASH! star and one short shake,
|
||||
# skid marks replace the pink sweep, and STUNT NIGHT holds on the taped band under RIOT.
|
||||
def frame_stunt(self, t):
|
||||
st, bg = self.st, self.bg
|
||||
base = kf(t, [(0, 0), (.3, 1)])
|
||||
tape_a = kf(t, [(.25, -2100), (.55, 0)])
|
||||
tape_b = kf(t, [(.45, 2100), (.75, 0)])
|
||||
layers = [st.xf(bg['sky'], alpha=base), st.xf(bg['torn'], alpha=base), st.xf(bg['city'], alpha=base),
|
||||
st.xf(self.new['tape_a'], move=(tape_a, 0), alpha=1 if t > .25 else 0),
|
||||
st.xf(bg['bolt_left'], alpha=base), st.xf(bg['tv'], alpha=base), st.xf(bg['static'], alpha=base)]
|
||||
# The crash zoom: the whole logo arrives from close to camera and lands hard.
|
||||
zoom = kf(t, [(.9, 2.3), (1.1, .97), (1.2, 1)], ease_in)
|
||||
za = kf(t, [(.9, 0), (1.05, 1)])
|
||||
for name in LOGO_PARTS:
|
||||
if name != 'sweep':
|
||||
layers.append(st.xf(self.logo[name], zoom, anchor=(960, 495), alpha=za))
|
||||
# Skid marks draw on from left to right.
|
||||
if t > 1.35:
|
||||
skid = self.new['skid']
|
||||
u = kf(t, [(1.35, 0), (1.75, 1)], ease_io)
|
||||
mask = Image.new('L', (st.W, st.H), 0)
|
||||
ImageDraw.Draw(mask).rectangle((0, 0, round((620 + 900 * u) * st.R), st.H), fill=255)
|
||||
cut = skid.copy()
|
||||
cut.putalpha(ImageChops.multiply(skid.getchannel('A'), mask))
|
||||
layers.append(cut)
|
||||
layers.append(st.xf(self.new['tape_b'], move=(tape_b, 0), alpha=1 if t > .45 else 0))
|
||||
layers.append(st.xf(self.new['crash'], kf(t, [(1.12, .3), (1.3, 1.1), (1.42, 1)]), kf(t, [(1.12, -20), (1.42, 0)]), anchor=(1546, 262), alpha=kf(t, [(1.12, 0), (1.22, 1)])))
|
||||
out = self.comp(layers)
|
||||
# One short camera shake on impact: a few frames, small, decaying; no flashing.
|
||||
shake = [(1.1, (0, 0)), (1.133, (14, -10)), (1.167, (-11, 8)), (1.2, (8, -6)), (1.233, (-5, 4)), (1.267, (3, -2)), (1.3, (0, 0))]
|
||||
if 1.1 < t < 1.3:
|
||||
dx, dy = kf(t, shake, lambda u: u)
|
||||
out = st.xf(out, 1.03, anchor=(960, 540), move=(dx, dy))
|
||||
out = self.comp([out])
|
||||
return out
|
||||
|
||||
# Riot After Hours: the palette sinks to night, the city's lights go out one by one, the Orbit crescent rises
|
||||
# like a moon behind the TV, the static rolls slowly, and AFTER HOURS hums on in neon: one flicker, then steady.
|
||||
def frame_after(self, t):
|
||||
st, bg = self.st, self.bg
|
||||
base = kf(t, [(0, 0), (.3, 1)])
|
||||
night = kf(t, [(.3, 0), (1.5, 1)], ease_io)
|
||||
layers = []
|
||||
for name in ['sky', 'torn']:
|
||||
layers.append(self.graded(name, night, base))
|
||||
moon_y = kf(t, [(.6, 520), (1.9, 0)])
|
||||
moon_a = kf(t, [(.6, 0), (1.0, 1)])
|
||||
if moon_a > 0:
|
||||
moon = st.xf(self.new['moon'], move=(0, moon_y), alpha=moon_a)
|
||||
layers += [glow(moon, 30 * st.R, .7), moon]
|
||||
layers.append(self.city(t, base))
|
||||
for name in ['burst', 'bolt_left', 'bolt_right']:
|
||||
layers.append(st.xf(self.graded(name, night, base), alpha=1 - night))
|
||||
layers.append(self.graded('tv', night, base))
|
||||
layers.append(self.rolled_static(t, base))
|
||||
# The logo: lime dimmed for night, the pink print edge kept bright with a neon glow.
|
||||
def night_logo(name, layer):
|
||||
if name in ('paper', 'accent', 'dots'):
|
||||
return tint(layer, (205, 210, 190))
|
||||
return layer
|
||||
logo = self.logo_reveal(t, 1.0, xform=night_logo)
|
||||
if t > 1.0:
|
||||
pink = self.pink_edge()
|
||||
layers.append(fade(glow(pink, 14 * st.R, 1.6), kf(t, [(1.3, 0), (1.8, .9)])))
|
||||
layers += logo
|
||||
layers += self.neon(t)
|
||||
return self.comp(layers)
|
||||
|
||||
def graded(self, name, night, base):
|
||||
key = ('night', name)
|
||||
if key not in self.st.cache:
|
||||
self.st.cache[key] = tint(self.bg[name], (92, 62, 150))
|
||||
day, dark = self.bg[name], self.st.cache[key]
|
||||
layer = Image.blend(day, dark, night) if 0 < night < 1 else (dark if night >= 1 else day)
|
||||
return self.st.xf(layer, alpha=base)
|
||||
|
||||
def city(self, t, base):
|
||||
"""The skyline's silhouette, then each window, switched off one by one; a few stay on all night."""
|
||||
st = self.st
|
||||
if 'city_dark' not in st.cache:
|
||||
source = (V2 / 'sources/bg_city.svg').read_text()
|
||||
path = re.search(r'<path [^>]*/>', source).group(0)
|
||||
silhouette = self.d / 'sources' / 'city_silhouette.svg'
|
||||
silhouette.write_text(svg(1920, 1080, path))
|
||||
vc(silhouette, self.d / 'exports' / 'city_silhouette.png')
|
||||
st.cache['city_dark'] = tint(st.frame_png(self.d / 'exports' / 'city_silhouette.png'), (150, 130, 190))
|
||||
rng = random.Random(11)
|
||||
windows = [tuple(float(v) for v in m.groups()) for m in re.finditer(r'<rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)" fill="[^"]*" opacity="([\d.]+)"', source)]
|
||||
st.cache['windows'] = [(w, rng.uniform(.5, 1.7) if rng.random() > .12 else 99) for w in windows]
|
||||
out = st.cache['city_dark'].copy()
|
||||
draw = ImageDraw.Draw(out)
|
||||
R = st.R
|
||||
for (x, y, w, h, o), off in st.cache['windows']:
|
||||
a = kf(t, [(off - .08, 1), (off, 0)], lambda u: u) if off < 99 else 1
|
||||
if a > 0:
|
||||
draw.rectangle((x * R, y * R, (x + w) * R - 1, (y + h) * R - 1), fill=(255, 222, 89, round(255 * o * a)))
|
||||
return st.xf(out, alpha=base)
|
||||
|
||||
def rolled_static(self, t, base):
|
||||
"""The static rolls slowly down the screen, then stops, so the held frame is still."""
|
||||
st = self.st
|
||||
x0, y0, x1, y1 = [round(v * st.R) for v in SCREEN]
|
||||
shift = round(kf(t, [(.4, 0), (2.6, 170)], ease_io) * st.R)
|
||||
layer = self.bg['static']
|
||||
region = layer.crop((x0, y0, x1, y1))
|
||||
h = y1 - y0
|
||||
rolled = Image.new('RGBA', region.size)
|
||||
rolled.paste(region.crop((0, h - shift % h, region.width, h)), (0, 0))
|
||||
rolled.paste(region.crop((0, 0, region.width, h - shift % h)), (0, shift % h))
|
||||
out = Image.new('RGBA', (st.W, st.H))
|
||||
out.paste(rolled, (x0, y0))
|
||||
return st.xf(tint(out, (170, 150, 200)), alpha=base)
|
||||
|
||||
def pink_edge(self):
|
||||
st = self.st
|
||||
if 'pink_edge' not in st.cache:
|
||||
word = self.logo['word']
|
||||
r, g, b, a = word.split()
|
||||
# The pink print shadow: strong red, weak green.
|
||||
mask = ImageChops.subtract(r, g).point(lambda v: 255 if v > 120 else 0)
|
||||
edge = Image.new('RGBA', word.size, (255, 46, 166, 255))
|
||||
edge.putalpha(ImageChops.multiply(mask, a))
|
||||
st.cache['pink_edge'] = edge
|
||||
return st.cache['pink_edge']
|
||||
|
||||
def neon(self, t):
|
||||
"""Off tube from 1.7 s; on at 2.0, one dip at 2.07, steady from 2.13 to the end."""
|
||||
st, tube, core = self.st, self.new['neon_tube'], self.new['neon_core']
|
||||
if t < 1.7:
|
||||
return []
|
||||
off = tint(tube, (90, 60, 90))
|
||||
off_layer = fade(off, kf(t, [(1.7, 0), (1.95, .8)]))
|
||||
if t < 2.0:
|
||||
return [off_layer]
|
||||
level = 1.0
|
||||
if 2.0 <= t < 2.07:
|
||||
level = .85
|
||||
elif 2.07 <= t < 2.13:
|
||||
level = .25
|
||||
elif 2.13 <= t < 2.4:
|
||||
level = kf(t, [(2.13, .7), (2.4, 1)])
|
||||
return [off_layer, fade(glow(tube, 22 * st.R, 1.4), level), fade(glow(tube, 7 * st.R, 1.2), level), fade(tube, level), fade(core, level)]
|
||||
|
||||
|
||||
# ---- Sound: quiet, original, synthesised here -----------------------------------------------------------------
|
||||
|
||||
RATE = 44100
|
||||
# About 7 LU under a typical -23 LUFS programme, and never above -15 dBTP, so a sting never jumps out.
|
||||
STING_LUFS, STING_PEAK = -30.0, -15.0
|
||||
|
||||
|
||||
def onepole(samples, a):
|
||||
out, y = [], 0.0
|
||||
for s in samples:
|
||||
y += a * (s - y)
|
||||
out.append(y)
|
||||
return out
|
||||
|
||||
|
||||
def env(i, n, attack=.01, release=.15):
|
||||
t, length = i / RATE, n / RATE
|
||||
return min(1, t / attack) * min(1, max(0, (length - t) / release))
|
||||
|
||||
|
||||
def boing(n_sec=.55):
|
||||
n, ph, out = int(n_sec * RATE), 0.0, []
|
||||
for i in range(n):
|
||||
t = i / RATE
|
||||
f = 150 + 70 * math.sin(2 * math.pi * 11 * t) * math.exp(-4 * t) + 40 * math.exp(-6 * t)
|
||||
ph += 2 * math.pi * f / RATE
|
||||
out.append(math.sin(ph) * math.exp(-5 * t) * env(i, n, .006, .1))
|
||||
return out
|
||||
|
||||
|
||||
def thump(n_sec=.22, f0=110, f1=48, noise=.25, seed=1):
|
||||
rng, n, ph, out = random.Random(seed), int(n_sec * RATE), 0.0, []
|
||||
hiss = onepole([rng.uniform(-1, 1) for _ in range(n)], .08)
|
||||
for i in range(n):
|
||||
t = i / RATE
|
||||
ph += 2 * math.pi * (f1 + (f0 - f1) * math.exp(-18 * t)) / RATE
|
||||
out.append((math.sin(ph) + noise * hiss[i] * math.exp(-40 * t) * 4) * math.exp(-9 * t) * env(i, n, .004, .05))
|
||||
return out
|
||||
|
||||
|
||||
def whoosh(n_sec=.36, seed=2):
|
||||
rng, n = random.Random(seed), int(n_sec * RATE)
|
||||
raw, out, y = [rng.uniform(-1, 1) for _ in range(n)], [], 0.0
|
||||
for i in range(n):
|
||||
u = i / n
|
||||
y += (.02 + .2 * math.sin(math.pi * u)) * (raw[i] - y)
|
||||
out.append(y * math.sin(math.pi * u) ** 2 * 2.2)
|
||||
return out
|
||||
|
||||
|
||||
def hum(n_sec, seed=3):
|
||||
"""Neon hum: 100 Hz mains buzz with a few harmonics, a slow swell in and out."""
|
||||
rng, n, out = random.Random(seed), int(n_sec * RATE), []
|
||||
for i in range(n):
|
||||
t = i / RATE
|
||||
s = .6 * math.sin(2 * math.pi * 100 * t) + .25 * math.sin(2 * math.pi * 200 * t) + .1 * math.sin(2 * math.pi * 300 * t) + .04 * rng.uniform(-1, 1)
|
||||
out.append(s * env(i, n, .35, .5))
|
||||
return out
|
||||
|
||||
|
||||
def tick(seed=4):
|
||||
rng, n = random.Random(seed), int(.03 * RATE)
|
||||
return [rng.uniform(-1, 1) * math.exp(-180 * i / RATE) for i in range(n)]
|
||||
|
||||
|
||||
def mix(length, events):
|
||||
track = [0.0] * int(length * RATE)
|
||||
for at, samples, gain in events:
|
||||
start = int(at * RATE)
|
||||
for i, s in enumerate(samples):
|
||||
if start + i < len(track):
|
||||
track[start + i] += s * gain
|
||||
return track
|
||||
|
||||
|
||||
SOUNDS = {
|
||||
'laughs': lambda: mix(LENGTH, [(.93, boing(), .5), (2.04, thump(.2, 140, 70, .4), .45)]),
|
||||
'stunt': lambda: mix(LENGTH, [(.24, whoosh(), .35), (.44, whoosh(seed=5), .3), (1.08, thump(.38, 90, 38, .6, 6), .6)]),
|
||||
'after': lambda: mix(LENGTH, [(1.98, tick(), .25), (2.12, tick(5), .2), (2.0, hum(1.95), .22)]),
|
||||
}
|
||||
|
||||
|
||||
def write_wav(path, samples, peak_db=-14.0):
|
||||
"""Normalise to a low peak, so no sting is ever loud; stereo 16-bit."""
|
||||
peak = max(abs(s) for s in samples) or 1
|
||||
gain = 10 ** (peak_db / 20) / peak
|
||||
with wave.open(str(path), 'wb') as w:
|
||||
w.setnchannels(2)
|
||||
w.setsampwidth(2)
|
||||
w.setframerate(RATE)
|
||||
w.writeframes(b''.join(struct.pack('<hh', v, v) for v in (round(max(-1, min(1, s * gain)) * 32767) for s in samples)))
|
||||
|
||||
|
||||
def loudness(path):
|
||||
out = subprocess.run(['ffmpeg', '-hide_banner', '-nostats', '-i', str(path), '-af', 'ebur128=peak=true', '-f', 'null', '-'], capture_output=True, text=True).stderr
|
||||
summary = out[out.rfind('Summary:'):]
|
||||
i = re.search(r'I:\s+(-?[\d.]+|-inf) LUFS', summary)
|
||||
tp = re.search(r'Peak:\s+(-?[\d.]+|-inf) dBFS', summary)
|
||||
return dict(integratedLufs=float(i.group(1)) if i else None, truePeakDbfs=float(tp.group(1)) if tp else None)
|
||||
|
||||
|
||||
# ---- Checks ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def flash_check(video):
|
||||
"""A rough screen for flashing, not a formal photosensitivity test: counts large brightness swings."""
|
||||
w, h = 64, 36
|
||||
raw = subprocess.run(['ffmpeg', '-v', 'error', '-i', str(video), '-vf', f'scale={w}:{h},format=gray', '-f', 'rawvideo', '-'], check=True, capture_output=True).stdout
|
||||
frames = [raw[i:i + w * h] for i in range(0, len(raw), w * h)]
|
||||
means = [sum(f) / len(f) / 255 for f in frames]
|
||||
deltas = [b - a for a, b in zip(means, means[1:])]
|
||||
swings = [d for d in deltas if abs(d) >= .1]
|
||||
reversals = sum(1 for a, b in zip(swings, swings[1:]) if a * b < 0)
|
||||
return dict(frames=len(frames), largestFrameChange=round(max(abs(d) for d in deltas), 4), framesChangingOver10Percent=len(swings), brightnessReversals=reversals)
|
||||
|
||||
|
||||
def hold_check(frames, seconds=.5):
|
||||
"""The last half second must be identical: that is the frame the app holds."""
|
||||
tail = frames[-int(seconds * FPS):]
|
||||
return all(ImageChops.difference(tail[0].convert('RGB'), f.convert('RGB')).getbbox() is None for f in tail)
|
||||
|
||||
|
||||
# ---- Build ----------------------------------------------------------------------------------------------------
|
||||
|
||||
TITLES = {'laughs': 'Riot Laughs', 'stunt': 'Saturday Stunt Night', 'after': 'Riot After Hours'}
|
||||
FOLDERS = {'laughs': 'riot-laughs', 'stunt': 'riot-stunt-night', 'after': 'riot-after-hours'}
|
||||
|
||||
|
||||
def label(img, words, size=26):
|
||||
font = ImageFont.truetype(str(FONTS / 'barlow-semibold.ttf'), size)
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rectangle((0, img.height - size - 16, img.width, img.height), fill=(7, 22, 50))
|
||||
d.text((12, img.height - size - 10), words, font=font, fill=(183, 203, 232))
|
||||
|
||||
|
||||
def build(key):
|
||||
d = OUT / FOLDERS[key]
|
||||
for sub in ('sources', 'exports', 'review'):
|
||||
(d / sub).mkdir(parents=True, exist_ok=True)
|
||||
s, e, r = d / 'sources', d / 'exports', d / 'review'
|
||||
for name, body in elements(key).items():
|
||||
(s / f'{name}.svg').write_text(body)
|
||||
vc(s / f'{name}.svg', e / f'{name}.png')
|
||||
|
||||
# The guide card: the refined Riot guide logo with the block's title beside it.
|
||||
guide = svg(512, 160, GUIDE_LABELS[key])
|
||||
(s / 'guide_label.svg').write_text(guide)
|
||||
vc(s / 'guide_label.svg', e / 'guide_label.png')
|
||||
card = Image.open(V2 / 'exports/orbit_riot_v2_epg.png').convert('RGBA')
|
||||
card = card.resize((round(card.width * .8), round(card.height * .8)), Image.LANCZOS)
|
||||
full = Image.new('RGBA', (512, 160))
|
||||
full.alpha_composite(card, (0, round((160 - card.height) / 2)))
|
||||
title = Image.open(e / 'guide_label.png').convert('RGBA')
|
||||
if key == 'after':
|
||||
full.alpha_composite(glow(title, 5, 1.6))
|
||||
full.alpha_composite(title)
|
||||
stem = FOLDERS[key].replace('-', '_')
|
||||
full.save(e / f'{stem}_guide.png')
|
||||
full.resize((256, 80), Image.LANCZOS).save(e / f'{stem}_guide_app.webp', quality=90)
|
||||
|
||||
# Frames at the app's size, then the final still at 1080p.
|
||||
block = Block(key, Stage(1280), d)
|
||||
frames = [block.frame(i / FPS) for i in range(int(LENGTH * FPS))]
|
||||
tmp = d / 'review' / '.frames'
|
||||
if tmp.exists():
|
||||
shutil.rmtree(tmp)
|
||||
tmp.mkdir()
|
||||
for i, f in enumerate(frames):
|
||||
f.convert('RGB').save(tmp / f'{i:04d}.png')
|
||||
still = Block(key, Stage(1920), d).frame(LENGTH - 1 / FPS).convert('RGB')
|
||||
still.save(r / f'{stem}_final_still.png')
|
||||
|
||||
silent = e / f'{stem}_ident.mp4'
|
||||
encode = ['-an', '-vf', 'fps=30', '-c:v', 'libx264', '-profile:v', 'main', '-pix_fmt', 'yuv420p', '-crf', '30', '-maxrate', '1400k', '-bufsize', '2800k', '-movflags', '+faststart']
|
||||
subprocess.run(['ffmpeg', '-y', '-v', 'error', '-framerate', str(FPS), '-i', str(tmp / '%04d.png'), *encode, str(silent)], check=True)
|
||||
sting = e / f'{stem}_sting.wav'
|
||||
samples = SOUNDS[key]()
|
||||
write_wav(sting, samples)
|
||||
measured = loudness(sting)
|
||||
gain = min(STING_LUFS - measured['integratedLufs'], STING_PEAK - measured['truePeakDbfs'])
|
||||
write_wav(sting, samples, -14.0 + gain)
|
||||
with_sound = r / f'{stem}_preview_with_sound.mp4'
|
||||
subprocess.run(['ffmpeg', '-y', '-v', 'error', '-i', str(silent), '-i', str(sting), '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '96k', '-shortest', '-movflags', '+faststart', str(with_sound)], check=True)
|
||||
shutil.copy(silent, r / f'{stem}_preview_silent.mp4')
|
||||
|
||||
# Contact sheet: nine moments, with times.
|
||||
picks = [0, .4, .7, 1.0, 1.3, 1.7, 2.1, 2.6, LENGTH - 1 / FPS]
|
||||
sheet = Image.new('RGB', (3 * 640, 3 * 360), (7, 22, 50))
|
||||
for n, t in enumerate(picks):
|
||||
tile = frames[min(len(frames) - 1, round(t * FPS))].convert('RGB').resize((640, 360), Image.LANCZOS)
|
||||
label(tile, f'{t:.1f} s' if n < 8 else 'final hold', 22)
|
||||
sheet.paste(tile, ((n % 3) * 640, (n // 3) * 360))
|
||||
head = Image.new('RGB', (sheet.width, 70), (7, 22, 50))
|
||||
ImageDraw.Draw(head).text((20, 16), f'{TITLES[key]} / block ident preview, {LENGTH:.0f} s, built from Riot v2', font=ImageFont.truetype(str(FONTS / 'barlow-semibold.ttf'), 32), fill='white')
|
||||
board = Image.new('RGB', (sheet.width, sheet.height + 70))
|
||||
board.paste(head, (0, 0))
|
||||
board.paste(sheet, (0, 70))
|
||||
board.save(r / f'{stem}_contact_sheet.png')
|
||||
|
||||
report = dict(status='preview-not-integrated', block=TITLES[key], seconds=LENGTH, fps=FPS, size=[1280, 720],
|
||||
identBytes=silent.stat().st_size, previewWithSoundBytes=with_sound.stat().st_size,
|
||||
guideAppBytes=(e / f'{stem}_guide_app.webp').stat().st_size,
|
||||
finalHoldStillForHalfSecond=hold_check(frames), flash=flash_check(silent), sting=loudness(sting))
|
||||
(r / 'checks.json').write_text(json.dumps(report, indent=1))
|
||||
shutil.rmtree(tmp)
|
||||
print(json.dumps(report))
|
||||
|
||||
|
||||
def review_board():
|
||||
"""One page for Paul: each block's final hold frame and its guide card at app size and at 2x."""
|
||||
small, big = ImageFont.truetype(str(FONTS / 'barlow-semibold.ttf'), 22), ImageFont.truetype(str(FONTS / 'barlow-semibold.ttf'), 32)
|
||||
board = Image.new('RGB', (1920, 100 + 3 * 470), (7, 22, 50))
|
||||
d = ImageDraw.Draw(board)
|
||||
d.text((40, 30), 'Riot block idents, Batch 1 previews: final hold frames and guide cards', font=ImageFont.truetype(str(FONTS / 'barlow-semibold.ttf'), 36), fill='white')
|
||||
for i, key in enumerate(['laughs', 'stunt', 'after']):
|
||||
folder, stem, y = OUT / FOLDERS[key], FOLDERS[key].replace('-', '_'), 100 + i * 470
|
||||
if not (folder / 'review' / f'{stem}_final_still.png').exists():
|
||||
continue
|
||||
board.paste(Image.open(folder / 'review' / f'{stem}_final_still.png').resize((768, 432), Image.LANCZOS), (40, y))
|
||||
d.text((850, y), TITLES[key], font=big, fill='white')
|
||||
d.text((850, y + 50), 'Guide card at app size (256x80) on the guide panel, then at 2x', font=small, fill=(183, 203, 232))
|
||||
card = Image.open(folder / 'exports' / f'{stem}_guide_app.webp').convert('RGBA')
|
||||
for (x, w, h, size, at) in [(850, 300, 110, (256, 80), (22, 15)), (1190, 560, 200, (512, 160), (24, 20))]:
|
||||
panel = Image.new('RGBA', (w, h), (18, 53, 107, 255))
|
||||
panel.alpha_composite(card.resize(size, Image.LANCZOS), at)
|
||||
board.paste(panel.convert('RGB'), (x, y + 100))
|
||||
board.save(OUT / 'riot_blocks_review.png')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for key in sys.argv[1:] or ['laughs', 'stunt', 'after']:
|
||||
build(key)
|
||||
review_board()
|
||||
@@ -0,0 +1,24 @@
|
||||
# Riot block idents, Batch 1 previews, 3 November 2026
|
||||
|
||||
Previews for Paul's visual review of the approved concepts: Riot Laughs, Saturday Stunt Night and Riot After Hours. Graveyard keeps the standard Riot ident. Nothing here is in the app, and `../orbit-riot-v2` (the refined Riot artwork these are built from) is read only, never changed.
|
||||
|
||||
Built by `../build-riot-blocks.py`. Every new element (HA! burst, LAUGHS stamp, caution tapes, CRASH! star, skid marks, moon, neon title, guide labels) is an editable SVG in each block's `sources/`.
|
||||
|
||||
**Review files**
|
||||
- `riot_blocks_review.png`: all three final hold frames with their guide cards at app size and at 2x.
|
||||
- `<block>/review/<block>_contact_sheet.png`: nine moments from each ident.
|
||||
- `<block>/review/<block>_final_still.png`: the clean final hold frame at 1080p.
|
||||
- `<block>/review/<block>_preview_silent.mp4` and `_preview_with_sound.mp4`: the ident, silent and with its optional sting.
|
||||
- `<block>/review/checks.json`: size, hold frame, flashing screen and sting loudness.
|
||||
|
||||
**Shared**
|
||||
- 4 seconds at 1280x720, 30 fps, with the app's encoding (silent): 212 KB (Laughs), 281 KB (Stunt Night) and 258 KB (After Hours), against 200 KB to 1.2 MB for today's 7-second channel idents.
|
||||
- The last half second is identical frame to frame (checked), so the frame held while the next programme loads is fully lit and still.
|
||||
- The rough flashing screen finds no brightness reversals. The largest single frame change is 14% (Stunt Night's crash zoom arriving, once).
|
||||
- Stings are original, synthesised in the build script (no samples): levelled to -30 LUFS, about 7 LU under a typical programme, with true peaks of -15.7 dBTP or lower. Each block has a silent ident; the sting is a separate file, so silent playback stays the default unless approved.
|
||||
|
||||
**Riot Laughs:** the TV static pops like a bubble, RIOT drops in with a squash and stretch, a pink HA! burst pops and wobbles to a stop, the halftone jiggles, and a solid pink LAUGHS stamp (ink knocked out, worn edges, hard print shadow) slaps down under RIOT. Sting: one spring boing as RIOT lands, a soft stamp thump.
|
||||
|
||||
**Saturday Stunt Night:** hazard tape whips across behind the TV, the STUNT NIGHT band (torn ends, stencil cut through the lettering) whips in under it, the whole logo crash-zooms in with a CRASH! star and a 0.2-second shake (small, decaying), and pale skid marks draw on where the pink sweep was. The right-hand bolt makes way for the star. Sting: two quiet whooshes and a low impact.
|
||||
|
||||
**Riot After Hours:** the palette sinks to night, the burst and bolts go out, the city's windows switch off one by one (a few stay on), Orbit's crescent rises as a moon behind the TV, the static rolls slowly and stops, the logo arrives with the lime dimmed and the pink edge glowing, then AFTER HOURS hums on in neon: on, one dip, then steady from 2.1 seconds to the end. Sting: a quiet 100 Hz neon hum with two soft ticks.
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"status": "preview-not-integrated",
|
||||
"block": "Riot After Hours",
|
||||
"seconds": 4.0,
|
||||
"fps": 30,
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"identBytes": 258327,
|
||||
"previewWithSoundBytes": 286631,
|
||||
"guideAppBytes": 8964,
|
||||
"finalHoldStillForHalfSecond": true,
|
||||
"flash": {
|
||||
"frames": 120,
|
||||
"largestFrameChange": 0.0887,
|
||||
"framesChangingOver10Percent": 0,
|
||||
"brightnessReversals": 0
|
||||
},
|
||||
"sting": {
|
||||
"integratedLufs": -30.0,
|
||||
"truePeakDbfs": -21.2
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 407 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><path d="M0 1080V900H0V932H64V1080H74V993H126V1080H133V1010H211V1080H221V951H288V1080H302V968H342V1080H355V921H429V1080H434V962H515V1080H522V953H602V1080H618V990H680V1080H687V1004H737V1080H743V946H786V1080H804V1002H876V1080H893V961H972V1080H989V997H1071V1080H1080V924H1128V1080H1136V957H1217V1080H1223V976H1307V1080H1315V975H1375V1080H1385V985H1422V1080H1438V958H1473V1080H1478V948H1537V1080H1544V980H1580V1080H1591V996H1673V1080H1687V995H1722V1080H1739V985H1811V1080H1829V968H1876V1080H1881V993H1921V1080H1920Z" fill="#0B0E2A"/></svg>
|
||||
|
After Width: | Height: | Size: 626 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><text x="428" y="84" font-family="Comfortaa" font-size="36" font-weight="700" fill="none" letter-spacing="3" text-anchor="middle" stroke="#FF2EA6" stroke-width="4">AFTER</text><text x="428" y="128" font-family="Comfortaa" font-size="36" font-weight="700" fill="none" letter-spacing="3" text-anchor="middle" stroke="#FF2EA6" stroke-width="4">HOURS</text></svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><defs><linearGradient id="m" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FFFFFF"/><stop offset="1" stop-color="#C9D2F2"/></linearGradient></defs><g transform="translate(1300,-70) scale(1.15)"><path d="M335 132C141 114 66 384 257 475C143 363 185 207 335 132Z" fill="url(#m)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><text x="1000" y="888" font-family="Comfortaa" font-size="104" font-weight="700" fill="none" letter-spacing="10" text-anchor="middle" stroke="#FFE3F3" stroke-width="3" stroke-linejoin="round">AFTER HOURS</text></svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><text x="1000" y="888" font-family="Comfortaa" font-size="104" font-weight="700" fill="none" letter-spacing="10" text-anchor="middle" stroke="#FF2EA6" stroke-width="9" stroke-linejoin="round">AFTER HOURS</text></svg>
|
||||
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"status": "preview-not-integrated",
|
||||
"block": "Riot Laughs",
|
||||
"seconds": 4.0,
|
||||
"fps": 30,
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"identBytes": 211805,
|
||||
"previewWithSoundBytes": 226831,
|
||||
"guideAppBytes": 7334,
|
||||
"finalHoldStillForHalfSecond": true,
|
||||
"flash": {
|
||||
"frames": 120,
|
||||
"largestFrameChange": 0.0887,
|
||||
"framesChangingOver10Percent": 0,
|
||||
"brightnessReversals": 0
|
||||
},
|
||||
"sting": {
|
||||
"integratedLufs": -30.0,
|
||||
"truePeakDbfs": -18.8
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 587 KiB |
|
After Width: | Height: | Size: 300 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><g transform="rotate(-6 426 92)"><rect x="346" y="58" width="160" height="66" rx="6" fill="#0B0E16" transform="translate(4,4)"/><rect x="346" y="58" width="160" height="66" rx="6" fill="#FF2EA6"/><rect x="353" y="65" width="146" height="52" rx="3" fill="none" stroke="#0B0E16" stroke-width="2.5"/><text x="426" y="108" font-family="Anton" font-size="40" font-weight="400" fill="#0B0E16" letter-spacing="5" text-anchor="middle">LAUGHS</text></g></svg>
|
||||
|
After Width: | Height: | Size: 537 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><polygon points="1548,84 1578,160 1648,106 1630,191 1703,191 1662,246 1755,292 1663,315 1701,394 1608,355 1602,446 1548,374 1499,430 1490,352 1414,378 1430,316 1345,291 1423,244 1363,177 1465,190 1450,109 1514,145" fill="#0B0E16" transform="translate(12,12)"/><polygon points="1548,84 1578,160 1648,106 1630,191 1703,191 1662,246 1755,292 1663,315 1701,394 1608,355 1602,446 1548,374 1499,430 1490,352 1414,378 1430,316 1345,291 1423,244 1363,177 1465,190 1450,109 1514,145" fill="#FF2EA6" stroke="#0B0E16" stroke-width="7" stroke-linejoin="round"/><text x="1440" y="312" font-family="Anton" font-size="132" font-weight="400" fill="#0B0E16" letter-spacing="4" transform="rotate(-8 1548 262)">HA!</text><text x="1434" y="305" font-family="Anton" font-size="132" font-weight="400" fill="#F7F7F7" letter-spacing="4" transform="rotate(-8 1548 262)">HA!</text></svg>
|
||||
|
After Width: | Height: | Size: 952 B |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"status": "preview-not-integrated",
|
||||
"block": "Saturday Stunt Night",
|
||||
"seconds": 4.0,
|
||||
"fps": 30,
|
||||
"size": [
|
||||
1280,
|
||||
720
|
||||
],
|
||||
"identBytes": 281159,
|
||||
"previewWithSoundBytes": 298369,
|
||||
"guideAppBytes": 7822,
|
||||
"finalHoldStillForHalfSecond": true,
|
||||
"flash": {
|
||||
"frames": 120,
|
||||
"largestFrameChange": 0.1404,
|
||||
"framesChangingOver10Percent": 1,
|
||||
"brightnessReversals": 0
|
||||
},
|
||||
"sting": {
|
||||
"integratedLufs": -30.0,
|
||||
"truePeakDbfs": -15.7
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 688 KiB |
|
After Width: | Height: | Size: 331 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><polygon points="1546,67 1587,109 1640,99 1650,158 1707,169 1669,229 1794,262 1667,294 1740,374 1642,358 1651,445 1582,395 1546,455 1507,409 1453,423 1457,351 1389,353 1423,295 1338,262 1396,222 1368,159 1460,176 1447,91 1505,108" fill="#0B0E16" transform="translate(12,12)"/><polygon points="1546,67 1587,109 1640,99 1650,158 1707,169 1669,229 1794,262 1667,294 1740,374 1642,358 1651,445 1582,395 1546,455 1507,409 1453,423 1457,351 1389,353 1423,295 1338,262 1396,222 1368,159 1460,176 1447,91 1505,108" fill="#FFC400" stroke="#0B0E16" stroke-width="7" stroke-linejoin="round"/><text x="1546" y="290" font-family="Anton" font-size="72" font-weight="400" fill="#0B0E16" letter-spacing="2" text-anchor="middle" transform="rotate(-7 1546 262)">CRASH!</text></svg>
|
||||
|
After Width: | Height: | Size: 854 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="160" viewBox="0 0 512 160"><polygon points="337,50 353,53 366,49 380,52 394,51 411,47 424,49 437,51 450,45 469,47 478,46 493,49 506,44 509,60 511,76 508,86 511,105 507,117 512,131 494,132 483,129 469,130 451,131 439,132 427,135 415,136 401,136 383,133 371,135 357,133 340,139 342,123 339,107 343,95 344,78 340,68" fill="#FFC400"/><text x="426" y="86" font-family="Anton" font-size="36" font-weight="400" fill="#0B0E16" letter-spacing="5" text-anchor="middle">STUNT</text><text x="426" y="126" font-family="Anton" font-size="36" font-weight="400" fill="#0B0E16" letter-spacing="5" text-anchor="middle">NIGHT</text></svg>
|
||||
|
After Width: | Height: | Size: 679 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="640" viewBox="0 0 1600 640"><path d="M470 612C760 634 1000 620 1280 584" fill="none" stroke="#F7F7F7" stroke-width="9" stroke-dasharray="10 7" opacity=".7"/><path d="M470 630C760 652 1000 638 1280 602" fill="none" stroke="#F7F7F7" stroke-width="9" stroke-dasharray="10 7" opacity=".7"/></svg>
|
||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080"><defs><clipPath id="a"><polygon points="-43,132 1,128 38,129 82,125 120,121 156,121 201,117 242,117 278,114 324,118 363,115 396,111 440,108 484,108 517,110 557,109 600,105 639,103 677,97 723,97 762,96 804,100 842,97 881,95 917,91 963,88 1003,92 1039,86 1080,84 1124,83 1156,79 1202,84 1240,79 1280,79 1318,76 1360,75 1397,70 1439,74 1479,70 1518,64 1557,69 1602,63 1641,64 1683,65 1719,64 1764,59 1803,56 1839,53 1883,50 1917,54 1962,50 1963,124 1918,128 1879,129 1837,132 1803,138 1762,136 1718,141 1680,141 1636,138 1603,145 1563,145 1519,146 1483,146 1440,147 1402,146 1361,156 1316,154 1281,158 1241,155 1199,162 1163,157 1117,158 1076,163 1037,168 1001,163 960,167 920,167 877,169 839,174 800,171 762,179 722,180 676,179 637,183 597,184 558,186 519,182 479,186 443,187 400,191 364,189 321,194 282,199 238,198 200,199 157,203 124,200 79,207 37,201 -3,209 -38,208"/></clipPath></defs><polygon points="-43,132 1,128 38,129 82,125 120,121 156,121 201,117 242,117 278,114 324,118 363,115 396,111 440,108 484,108 517,110 557,109 600,105 639,103 677,97 723,97 762,96 804,100 842,97 881,95 917,91 963,88 1003,92 1039,86 1080,84 1124,83 1156,79 1202,84 1240,79 1280,79 1318,76 1360,75 1397,70 1439,74 1479,70 1518,64 1557,69 1602,63 1641,64 1683,65 1719,64 1764,59 1803,56 1839,53 1883,50 1917,54 1962,50 1963,124 1918,128 1879,129 1837,132 1803,138 1762,136 1718,141 1680,141 1636,138 1603,145 1563,145 1519,146 1483,146 1440,147 1402,146 1361,156 1316,154 1281,158 1241,155 1199,162 1163,157 1117,158 1076,163 1037,168 1001,163 960,167 920,167 877,169 839,174 800,171 762,179 722,180 676,179 637,183 597,184 558,186 519,182 479,186 443,187 400,191 364,189 321,194 282,199 238,198 200,199 157,203 124,200 79,207 37,201 -3,209 -38,208" fill="#FFC400"/><g clip-path="url(#a)" transform="rotate(-3 960 135)"><rect x="-360" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="-264" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="-168" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="-72" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="24" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="120" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="216" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="312" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="408" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="504" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="600" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="696" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="792" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="888" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="984" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1080" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1176" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1272" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1368" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1464" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1560" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1656" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1752" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1848" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="1944" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="2040" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="2136" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="2232" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="2328" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/><rect x="2424" y="50" width="48" height="180" fill="#0B0E16" transform="skewX(-35)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 838 KiB |
@@ -153,6 +153,17 @@ Editorial directions, not yet built. Check catalogue availability and scheduling
|
||||
- **Recurring film events (3 November 2026):** Slasher Saturday on Horror (Saturday 20:00 to 02:00), B-Movie Throwbacks on After Dark (Friday 21:00 to 01:00) and Cult Classics on After Dark (Sunday 21:00 to 01:00) built in 0.91.0; The Last Drive-In with Joe Bob Briggs as After Dark's Saturday 21:00 staple approved in direction, needing its own engine change. All four are eligible for the future What's On homepage: once their broadcasts are committed, What's On should recognise and promote them by name, channel, artwork and time, through the shared highlights work, not a separate promotion engine.
|
||||
- **Themed nights inside an event (Paul, 3 November 2026):** an event can change its theme from week to week, so each household's Orbit develops its own nights. For example, a Cult Classics Sunday could become a Rob Zombie night: House of 1000 Corpses (2003), The Devil's Rejects (2005), Halloween (2007) and Halloween II (2009), all in the library. Themes would come from hand-made sets (a director, a franchise, a star) chosen in rotation, and would be promoted by name in What's On once booked. Not built; it fits the Living Network work.
|
||||
|
||||
## Special programming presentation (Paul, 3 November 2026)
|
||||
|
||||
Four presentation levels: channel ident, block ident (3 to 5 seconds), event promo (confirmed bookings only) and takeover identity (optional, reversible). Each special identity is a variation of its parent channel, ends on a clean fully lit hold frame, has a small guide card, keeps audio quiet with a silent option, and falls back to the parent ident. Audit and concepts: the "Special programming presentation" doc.
|
||||
|
||||
- **Batch 1, Riot (previews approved to build):** Riot Laughs, Saturday Stunt Night, Riot After Hours. Graveyard keeps the standard Riot ident.
|
||||
- **Later batches:** After Dark and Horror; Sunday Gold, Scottish Comedy Night, Vortex Movie Night, Retro Action, Spooky Teatime; PPV Sunday, Ring X weekly variation, Terra Sunday; Drive-In takeovers, Halloween and Christmas, director nights, premieres.
|
||||
- **Future event identities, not started:** Vortex Prime (Orbit's own name for Vortex's action and anime hours), Heroes Saturday Morning, Orbit X Saturday Morning, Evidence Sunday Mystery, Dark Files Case Files, Odyssey Journeys.
|
||||
- **Lower priority:** Orbit 1 Sunday Drama, Wild Saturday Late.
|
||||
- **No bespoke ident for now:** Gold Saturday Teatime. Tots keeps its existing time-of-day idents.
|
||||
- **Presentation keys (direction liked, needs its own approval and verification, separate from the artwork):** a stable key per block or event, written into each booked programme. A block ident plays only when entering the block, following the committed programme (including cross-midnight events and overlapping block edges), with normal channel idents between programmes inside it. Preloading, seamless changeovers, clean hold frames and fallback to the channel ident stay as they are.
|
||||
|
||||
## Scheduler investigation: film rest periods (Paul, 3 November 2026)
|
||||
|
||||
Read-only investigation, separate from any release. Found in the 0.90.0 replay: After Dark, VHS, Detour, BritX, Wonder and PixelZ can't keep their 7-day rest, because their lists run out before the week does, so the film that has waited longest comes back early (about 290 to 370 showings inside the rest over four weeks). The same happens today.
|
||||
|
||||