diff --git a/design/channels/build-riot-blocks.py b/design/channels/build-riot-blocks.py new file mode 100644 index 00000000..3c7fe46c --- /dev/null +++ b/design/channels/build-riot-blocks.py @@ -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// 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'{body}' + + +def text(x, y, value, size, fill, family='Anton', weight='400', extra=''): + return f'{html.escape(value)}' + + +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'' 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'' + f'' + + 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'' for _ in range(70)) + e['stamp'] = svg(1920, 1080, + '' + '' + f'' + f'' + f'{wear}' + f'' + + text(1110, 880, 'LAUGHS', 78, INK, extra='letter-spacing="18" text-anchor="middle"') + '') + 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'' + f'{caution_stripes(-200, 60, 2400, 160, 48)}') + # 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'' + f'' + f'' + f'{caution_stripes(400, 900, 1200, 22, 30)}' + + text(990, 885, 'STUNT NIGHT', 92, INK, extra='letter-spacing="14" text-anchor="middle" transform="rotate(-1.2 990 848)"') + + f'') + # The comic impact star. + shape = burst_points(1546, 262, 138, 214, 12, seed=8, wobble=.16) + e['crash'] = svg(1920, 1080, + f'' + f'' + + 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'' + 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, + '' + '') + # 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'' + f'' + + text(426, 108, 'LAUGHS', 40, INK, extra='letter-spacing="5" text-anchor="middle"') + ''), + 'stunt': (f'' + + 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']*/>', 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' .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('= .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() diff --git a/design/channels/orbit-riot-blocks/README.md b/design/channels/orbit-riot-blocks/README.md new file mode 100644 index 00000000..bf596039 --- /dev/null +++ b/design/channels/orbit-riot-blocks/README.md @@ -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. +- `/review/_contact_sheet.png`: nine moments from each ident. +- `/review/_final_still.png`: the clean final hold frame at 1080p. +- `/review/_preview_silent.mp4` and `_preview_with_sound.mp4`: the ident, silent and with its optional sting. +- `/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. diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/city_silhouette.png b/design/channels/orbit-riot-blocks/riot-after-hours/exports/city_silhouette.png new file mode 100644 index 00000000..790f2468 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/city_silhouette.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/guide_label.png b/design/channels/orbit-riot-blocks/riot-after-hours/exports/guide_label.png new file mode 100644 index 00000000..38e5acb1 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/guide_label.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/moon.png b/design/channels/orbit-riot-blocks/riot-after-hours/exports/moon.png new file mode 100644 index 00000000..69f0bf14 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/moon.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/neon_core.png b/design/channels/orbit-riot-blocks/riot-after-hours/exports/neon_core.png new file mode 100644 index 00000000..6c93c8f7 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/neon_core.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/neon_tube.png b/design/channels/orbit-riot-blocks/riot-after-hours/exports/neon_tube.png new file mode 100644 index 00000000..5988a6cb Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/neon_tube.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_guide.png b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_guide.png new file mode 100644 index 00000000..abb13cf9 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_guide.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_guide_app.webp b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_guide_app.webp new file mode 100644 index 00000000..a49e7628 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_guide_app.webp differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_ident.mp4 b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_ident.mp4 new file mode 100644 index 00000000..d192e4f2 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_ident.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_sting.wav b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_sting.wav new file mode 100644 index 00000000..da2df61f Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/exports/riot_after_hours_sting.wav differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/review/checks.json b/design/channels/orbit-riot-blocks/riot-after-hours/review/checks.json new file mode 100644 index 00000000..27bca372 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-after-hours/review/checks.json @@ -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 + } +} \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_contact_sheet.png b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_contact_sheet.png new file mode 100644 index 00000000..bf9fc3c9 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_contact_sheet.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_final_still.png b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_final_still.png new file mode 100644 index 00000000..c58406d4 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_final_still.png differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_preview_silent.mp4 b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_preview_silent.mp4 new file mode 100644 index 00000000..d192e4f2 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_preview_silent.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_preview_with_sound.mp4 b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_preview_with_sound.mp4 new file mode 100644 index 00000000..d0fd61a4 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-after-hours/review/riot_after_hours_preview_with_sound.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/sources/city_silhouette.svg b/design/channels/orbit-riot-blocks/riot-after-hours/sources/city_silhouette.svg new file mode 100644 index 00000000..813b5246 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-after-hours/sources/city_silhouette.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/sources/guide_label.svg b/design/channels/orbit-riot-blocks/riot-after-hours/sources/guide_label.svg new file mode 100644 index 00000000..8a356e37 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-after-hours/sources/guide_label.svg @@ -0,0 +1 @@ +AFTERHOURS \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/sources/moon.svg b/design/channels/orbit-riot-blocks/riot-after-hours/sources/moon.svg new file mode 100644 index 00000000..8c38eaea --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-after-hours/sources/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/sources/neon_core.svg b/design/channels/orbit-riot-blocks/riot-after-hours/sources/neon_core.svg new file mode 100644 index 00000000..3574eaa9 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-after-hours/sources/neon_core.svg @@ -0,0 +1 @@ +AFTER HOURS \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-after-hours/sources/neon_tube.svg b/design/channels/orbit-riot-blocks/riot-after-hours/sources/neon_tube.svg new file mode 100644 index 00000000..d4e20f97 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-after-hours/sources/neon_tube.svg @@ -0,0 +1 @@ +AFTER HOURS \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-laughs/exports/guide_label.png b/design/channels/orbit-riot-blocks/riot-laughs/exports/guide_label.png new file mode 100644 index 00000000..bc0d6ff7 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/exports/guide_label.png differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/exports/ha.png b/design/channels/orbit-riot-blocks/riot-laughs/exports/ha.png new file mode 100644 index 00000000..1f9ba12f Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/exports/ha.png differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_guide.png b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_guide.png new file mode 100644 index 00000000..b26781ba Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_guide.png differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_guide_app.webp b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_guide_app.webp new file mode 100644 index 00000000..d7877d6f Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_guide_app.webp differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_ident.mp4 b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_ident.mp4 new file mode 100644 index 00000000..c8471c17 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_ident.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_sting.wav b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_sting.wav new file mode 100644 index 00000000..06c356ed Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/exports/riot_laughs_sting.wav differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/exports/stamp.png b/design/channels/orbit-riot-blocks/riot-laughs/exports/stamp.png new file mode 100644 index 00000000..7b987fff Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/exports/stamp.png differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/review/checks.json b/design/channels/orbit-riot-blocks/riot-laughs/review/checks.json new file mode 100644 index 00000000..2bd1912e --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-laughs/review/checks.json @@ -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 + } +} \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_contact_sheet.png b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_contact_sheet.png new file mode 100644 index 00000000..c96f8703 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_contact_sheet.png differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_final_still.png b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_final_still.png new file mode 100644 index 00000000..5e4fa715 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_final_still.png differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_preview_silent.mp4 b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_preview_silent.mp4 new file mode 100644 index 00000000..c8471c17 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_preview_silent.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_preview_with_sound.mp4 b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_preview_with_sound.mp4 new file mode 100644 index 00000000..900c6b90 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-laughs/review/riot_laughs_preview_with_sound.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-laughs/sources/guide_label.svg b/design/channels/orbit-riot-blocks/riot-laughs/sources/guide_label.svg new file mode 100644 index 00000000..4eb2d8ef --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-laughs/sources/guide_label.svg @@ -0,0 +1 @@ +LAUGHS \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-laughs/sources/ha.svg b/design/channels/orbit-riot-blocks/riot-laughs/sources/ha.svg new file mode 100644 index 00000000..77e3a6c7 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-laughs/sources/ha.svg @@ -0,0 +1 @@ +HA!HA! \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-laughs/sources/stamp.svg b/design/channels/orbit-riot-blocks/riot-laughs/sources/stamp.svg new file mode 100644 index 00000000..0e4b71eb --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-laughs/sources/stamp.svg @@ -0,0 +1 @@ +LAUGHS \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/crash.png b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/crash.png new file mode 100644 index 00000000..857b0bbc Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/crash.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/guide_label.png b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/guide_label.png new file mode 100644 index 00000000..53410ab6 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/guide_label.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_guide.png b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_guide.png new file mode 100644 index 00000000..a7dd2240 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_guide.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_guide_app.webp b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_guide_app.webp new file mode 100644 index 00000000..6d93f44c Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_guide_app.webp differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_ident.mp4 b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_ident.mp4 new file mode 100644 index 00000000..8cddd28c Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_ident.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_sting.wav b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_sting.wav new file mode 100644 index 00000000..6b5a0446 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/riot_stunt_night_sting.wav differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/skid.png b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/skid.png new file mode 100644 index 00000000..284d2923 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/skid.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/tape_a.png b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/tape_a.png new file mode 100644 index 00000000..d9493645 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/tape_a.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/exports/tape_b.png b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/tape_b.png new file mode 100644 index 00000000..458b2a1b Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/exports/tape_b.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/review/checks.json b/design/channels/orbit-riot-blocks/riot-stunt-night/review/checks.json new file mode 100644 index 00000000..9314082d --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-stunt-night/review/checks.json @@ -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 + } +} \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_contact_sheet.png b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_contact_sheet.png new file mode 100644 index 00000000..6426ffb6 Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_contact_sheet.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_final_still.png b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_final_still.png new file mode 100644 index 00000000..269fb5db Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_final_still.png differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_preview_silent.mp4 b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_preview_silent.mp4 new file mode 100644 index 00000000..8cddd28c Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_preview_silent.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_preview_with_sound.mp4 b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_preview_with_sound.mp4 new file mode 100644 index 00000000..b6e418db Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot-stunt-night/review/riot_stunt_night_preview_with_sound.mp4 differ diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/sources/crash.svg b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/crash.svg new file mode 100644 index 00000000..55da4d82 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/crash.svg @@ -0,0 +1 @@ +CRASH! \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/sources/guide_label.svg b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/guide_label.svg new file mode 100644 index 00000000..832a405c --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/guide_label.svg @@ -0,0 +1 @@ +STUNTNIGHT \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/sources/skid.svg b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/skid.svg new file mode 100644 index 00000000..901a762a --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/skid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/sources/tape_a.svg b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/tape_a.svg new file mode 100644 index 00000000..b1942608 --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/tape_a.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot-stunt-night/sources/tape_b.svg b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/tape_b.svg new file mode 100644 index 00000000..7d9db6af --- /dev/null +++ b/design/channels/orbit-riot-blocks/riot-stunt-night/sources/tape_b.svg @@ -0,0 +1 @@ +STUNT NIGHT \ No newline at end of file diff --git a/design/channels/orbit-riot-blocks/riot_blocks_review.png b/design/channels/orbit-riot-blocks/riot_blocks_review.png new file mode 100644 index 00000000..4b7f3a5f Binary files /dev/null and b/design/channels/orbit-riot-blocks/riot_blocks_review.png differ diff --git a/docs/roadmap.md b/docs/roadmap.md index 2acce75a..feff72a9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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.