From 465bbd653d83e0335903a7bb62783222987f5baa Mon Sep 17 00:00:00 2001 From: Pauljd Date: Mon, 2 Nov 2026 20:31:09 +0000 Subject: [PATCH] Add Blender scenes for Orbit X, Orbit Three, Heroes and the Drive-In curtains Co-Authored-By: Claude Opus 5.5 (1M context) --- design/channels/blender/build_3d_ident.py | 211 +++++++++++++++++++++- docs/refinement-notes.md | 4 + 2 files changed, 212 insertions(+), 3 deletions(-) diff --git a/design/channels/blender/build_3d_ident.py b/design/channels/blender/build_3d_ident.py index fd66797..7c1c7b5 100644 --- a/design/channels/blender/build_3d_ident.py +++ b/design/channels/blender/build_3d_ident.py @@ -422,7 +422,7 @@ def window_wall(wall, lit, window_colours, lit_share=.3, cell=(1.6, 2.4)): l.new(tint.outputs['Color'], bsdf.inputs['Emission Color']); l.new(amount.outputs[0], bsdf.inputs['Emission Strength']) return m -def city(accent): +def city(accent, moon=True, far_wall=True): """After Dark: a street of real towers at night, with neon along their edges.""" rng = random.Random(102) wall = window_wall('#242E58', 1.6, ('#FF9A4A', '#FFD27A', '#9AC8FF'), .14, (1.1, 1.7)) @@ -439,10 +439,10 @@ def city(accent): if far == 0 and rng.random() < .55: inner = x - side * width / 2 box(f'neon{row}{side}', inner, y - depth * .45, .35, .35, height, neon) - for x in range(-330, 330, 34): + for x in range(-330, 330, 34) if far_wall else (): box(f'back{x}', x, 340 + rng.uniform(0, 50), 30, 30, rng.uniform(60, 180), wall) # A low moon at the end of the street, in the accent's glow. - flare((54, 600, 205), 30, '#E8E4F5', 1.5) + if moon: flare((54, 600, 205), 30, '#E8E4F5', 1.5) # Lamps along the road, for reflections. lamp, n, l = material('lamp'); out = n['Material Output']; n.remove(n['Principled BSDF']) glow = n.new('ShaderNodeEmission'); glow.inputs['Color'].default_value = hexrgb('#FFC873'); glow.inputs['Strength'].default_value = 20; l.new(glow.outputs[0], out.inputs['Surface']) @@ -511,6 +511,165 @@ def haunted(windows_colour='#FF2A1E'): w = scene.world.node_tree; tex = w.nodes.new('ShaderNodeTexCoord'); sep = w.nodes.new('ShaderNodeSeparateXYZ'); w.links.new(tex.outputs['Generated'], sep.inputs[0]) sky = ramp(w.nodes, [(0, '#5E1622'), (.1, '#2A0A14'), (.32, '#120A1A')]); w.links.new(sep.outputs['Z'], sky.inputs['Fac']); w.links.new(sky.outputs['Color'], bg.inputs['Color']) +def beam(name, base, length, radius, colour, strength, aim=None, narrow=.3): + """A searchlight's shaft of light, soft at the edges and fading out along its length, with streams running up it.""" + bpy.ops.mesh.primitive_cone_add(vertices=64, radius1=radius, radius2=radius * narrow, depth=length, end_fill_type='NOTHING', location=(0, 0, length / 2), rotation=(math.pi, 0, 0)) + obj = bpy.context.active_object; obj.name = name + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True); bpy.ops.object.shade_smooth() + obj.location = base + if aim: obj.rotation_euler = (Vector(aim) - Vector(base)).to_track_quat('Z', 'Y').to_euler() + m, n, l = material(name); out = n['Material Output']; n.remove(n['Principled BSDF']) + edge = n.new('ShaderNodeLayerWeight'); edge.inputs['Blend'].default_value = .5 + soft = n.new('ShaderNodeMath'); soft.operation = 'POWER'; soft.inputs[1].default_value = 3 + invert = n.new('ShaderNodeMath'); invert.operation = 'SUBTRACT'; invert.inputs[0].default_value = 1; l.new(edge.outputs['Facing'], invert.inputs[1]); l.new(invert.outputs[0], soft.inputs[0]) + coord = n.new('ShaderNodeTexCoord'); part = n.new('ShaderNodeSeparateXYZ'); l.new(coord.outputs['Object'], part.inputs[0]) + along = n.new('ShaderNodeMapRange'); along.inputs['From Min'].default_value = 0; along.inputs['From Max'].default_value = length; l.new(part.outputs['Z'], along.inputs['Value']) + fade = ramp(n, [(0, '#FFFFFF'), (.55, '#B0B0B0'), (1, '#000000')]); l.new(along.outputs['Result'], fade.inputs['Fac']) + stretch = n.new('ShaderNodeVectorMath'); stretch.operation = 'MULTIPLY'; stretch.inputs[1].default_value = (1.1, 1.1, .04); l.new(coord.outputs['Object'], stretch.inputs[0]) + noise = n.new('ShaderNodeTexNoise'); noise.inputs['Scale'].default_value = 1; noise.inputs['Detail'].default_value = 4; l.new(stretch.outputs[0], noise.inputs['Vector']) + streams = ramp(n, [(.3, '#707070'), (.62, '#FFFFFF')]); l.new(noise.outputs['Fac'], streams.inputs['Fac']) + a = n.new('ShaderNodeMath'); a.operation = 'MULTIPLY'; l.new(soft.outputs[0], a.inputs[0]); l.new(fade.outputs['Color'], a.inputs[1]) + b = n.new('ShaderNodeMath'); b.operation = 'MULTIPLY'; l.new(a.outputs[0], b.inputs[0]); l.new(streams.outputs['Color'], b.inputs[1]) + glow = n.new('ShaderNodeEmission'); glow.inputs['Color'].default_value = hexrgb(colour); glow.inputs['Strength'].default_value = strength + clear = n.new('ShaderNodeBsdfTransparent'); mix = n.new('ShaderNodeMixShader') + l.new(b.outputs[0], mix.inputs[0]); l.new(clear.outputs[0], mix.inputs[1]); l.new(glow.outputs[0], mix.inputs[2]); l.new(mix.outputs[0], out.inputs['Surface']) + obj.data.materials.append(m); obj.visible_shadow = False + return obj + +def sweep(obj, keys): + """Swings a beam from side to side: (frame, degrees from straight up).""" + for frame, degrees in keys: + obj.rotation_euler = (0, math.radians(degrees), 0); obj.keyframe_insert('rotation_euler', frame=frame) + +def neon_hoop(name, radius, thick, colour, strength, tilt, turns): + bpy.ops.mesh.primitive_torus_add(major_radius=radius, minor_radius=thick, major_segments=256, minor_segments=12) + hoop = bpy.context.active_object; hoop.name = name; bpy.ops.object.shade_smooth() + m, n, l = material(name); out = n['Material Output']; n.remove(n['Principled BSDF']) + glow = n.new('ShaderNodeEmission'); glow.inputs['Color'].default_value = hexrgb(colour); glow.inputs['Strength'].default_value = strength; l.new(glow.outputs[0], out.inputs['Surface']) + hoop.data.materials.append(m); hoop.visible_shadow = False + pivot = bpy.data.objects.new(name + 'pivot', None); bpy.context.collection.objects.link(pivot); pivot.rotation_euler = tilt; hoop.parent = pivot + moon = sphere(name + 'moon', .75, 48); moon.parent = hoop; moon.location = (radius, 0, 0); moon.data.materials.append(m); moon.visible_shadow = False + spin(hoop, 'z', turns) + return hoop + +def flat(name, polygons, location, facing, mat, shapes=()): + """Polygons in a plane that faces the camera, for shapes only ever seen as silhouettes. shapes: alternative outlines to move between.""" + verts, faces = [], [] + for polygon in polygons: + faces.append(tuple(range(len(verts), len(verts) + len(polygon)))); verts += [(x, 0, z) for x, z in polygon] + mesh = bpy.data.meshes.new(name); mesh.from_pydata(verts, [], faces); mesh.materials.append(mat) + obj = bpy.data.objects.new(name, mesh); bpy.context.collection.objects.link(obj) + obj.location = location; obj.rotation_euler = (Vector(facing) - Vector(location)).to_track_quat('-Y', 'Z').to_euler() + return obj + +def caped_hero(where, facing, mat, size=1.0): + """A hero on a rooftop, hands on hips, cape in the wind. Only ever a silhouette against the moon.""" + body = [[(-.95, 0), (-.45, 0), (-.08, 2.7), (-.6, 3.3)], [(.95, 0), (.45, 0), (.08, 2.7), (.6, 3.3)], [(-.6, 3.3), (.6, 3.3), (.68, 3.75), (1.08, 5.15), (.5, 5.42), (-.5, 5.42), (-1.08, 5.15), (-.68, 3.75)], + [(-1.08, 5.15), (-1.75, 4.1), (-.64, 3.42), (-.62, 3.72), (-1.28, 4.15), (-.75, 4.95)], [(1.08, 5.15), (1.75, 4.1), (.64, 3.42), (.62, 3.72), (1.28, 4.15), (.75, 4.95)], + [(-.17, 5.42), (.17, 5.42), (.17, 5.75), (-.17, 5.75)], [(.42 * math.cos(i / 16 * math.tau), 6.05 + .48 * math.sin(i / 16 * math.tau)) for i in range(16)]] + hero = flat('hero', [[(x * size, z * size) for x, z in p] for p in body], where, facing, mat) + rows, columns = 12, 6 + def cape_at(phase, wind): + points = [] + for r in range(rows): + down = r / (rows - 1) + for c in range(columns): + across = c / (columns - 1) + x = (-1 + 2 * across) * (.95 + down * .5) + down * down * wind * 3 + math.sin(down * 5 + phase + across) * down * .4 + points.append((x * size, (5.2 - down * 4.7 + math.sin(down * 6 + phase) * down * .25) * size)) + return points + verts = [(x, .02, z) for x, z in cape_at(0, .7)] + faces = [(r * columns + c, r * columns + c + 1, (r + 1) * columns + c + 1, (r + 1) * columns + c) for r in range(rows - 1) for c in range(columns - 1)] + mesh = bpy.data.meshes.new('cape'); mesh.from_pydata(verts, [], faces); mesh.materials.append(mat) + cape = bpy.data.objects.new('cape', mesh); bpy.context.collection.objects.link(cape); cape.parent = hero + cape.shape_key_add(name='rest', from_mix=False) + for name, phase, wind in (('a', 1.6, 1.0), ('b', 3.2, .55), ('c', 4.8, .9)): + key = cape.shape_key_add(name=name, from_mix=False) + for point, (x, z) in zip(key.data, cape_at(phase, wind)): point.co = (x, .02, z) + for frame in range(1, LENGTH + 1, 18): + key.value = 1.0 if ((frame // 18) % 3) == 'abc'.index(name) else 0.0 + key.keyframe_insert('value', frame=frame) + return hero + +def signal(where, facing, size, colour): + """A searchlight's disc on the night sky, with a star cut out of it.""" + m, n, l = material('signal'); out = n['Material Output']; n.remove(n['Principled BSDF']) + glow = n.new('ShaderNodeEmission'); glow.inputs['Color'].default_value = hexrgb(colour); glow.inputs['Strength'].default_value = 1.1; l.new(glow.outputs[0], out.inputs['Surface']) + disc = flat('signal', [[(size * math.cos(i / 96 * math.tau), size * math.sin(i / 96 * math.tau)) for i in range(96)]], where, facing, m) + dark, n, l = material('signaldark'); out = n['Material Output']; n.remove(n['Principled BSDF']) + blank = n.new('ShaderNodeEmission'); blank.inputs['Color'].default_value = hexrgb('#05070F'); l.new(blank.outputs[0], out.inputs['Surface']) + points = [(size * (.74 if i % 2 == 0 else .29) * math.sin(i / 10 * math.tau), size * (.74 if i % 2 == 0 else .29) * math.cos(i / 10 * math.tau)) for i in range(10)] + arms = [[(0, 0), points[i], points[(i + 1) % 10]] for i in range(10)] + star = flat('signalstar', arms, where, facing, dark) + star.location = Vector(where) + (Vector(facing) - Vector(where)).normalized() * .4 + return disc + +def curtain(name, side, width, height, colour): + """One red drape. Shape key 'open' gathers it to the side, its folds growing deeper as it bunches up.""" + rows, columns = 40, 180 + def at(share, r, c): + across, down = c / (columns - 1), r / (rows - 1) + reach = width * (1 - share * .86) + x = side * (-.25 + (1 - across) * reach * (1 - share * down * down * .0)) + if share: x += side * (width - reach) * .94 + fold = (.26 + share * .78) * math.sin(across * 15 * math.tau + math.sin(across * 9) * 1.4) + .08 * math.sin(across * 41 * math.tau) + y = fold * (.8 + .35 * down) + return (x, y, height * (1 - down)) + verts = [at(0, r, c) for r in range(rows) for c in range(columns)] + faces = [(r * columns + c, r * columns + c + 1, (r + 1) * columns + c + 1, (r + 1) * columns + c) for r in range(rows - 1) for c in range(columns - 1)] + mesh = bpy.data.meshes.new(name); mesh.from_pydata(verts, [], faces) + for p in mesh.polygons: p.use_smooth = True + obj = bpy.data.objects.new(name, mesh); bpy.context.collection.objects.link(obj) + m, n, l = material(name); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb(colour); bsdf.inputs['Roughness'].default_value = .62; bsdf.inputs['Sheen Weight'].default_value = 1; bsdf.inputs['Sheen Tint'].default_value = hexrgb('#FF5A5A') + mesh.materials.append(m) + obj.shape_key_add(name='closed', from_mix=False) + opened = obj.shape_key_add(name='open', from_mix=False) + for i, point in enumerate(opened.data): point.co = at(1, i // columns, i % columns) + return obj, opened + +def cinema(logo): + """A picture house: wall lights dim, the red drapes part, and the lit screen shows the logo.""" + width, height, depth = 26, 15, 40 + room, n, l = material('room'); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb('#5A2028'); bsdf.inputs['Roughness'].default_value = .55 + box('floor', 0, -depth / 2, width * 2.4, depth + 8, .2, room, -.2) + box('ceiling', 0, -depth / 2, width * 2.4, depth + 8, .2, room, height + 2) + box('backwall', 0, 1.4, width * 2.4, .4, height + 2, room) + for side in (-1, 1): + box(f'wall{side}', side * width * 1.2, -depth / 2, .4, depth + 8, height + 2, room) + # The screen, its picture lit from within. + bpy.ops.mesh.primitive_plane_add(size=1, location=(0, 1, 7), rotation=(math.pi / 2, 0, 0)); screen = bpy.context.active_object; screen.name = 'screen'; screen.scale = (width * 1.4, 15, 1) + m, n, l = material('screen'); out = n['Material Output']; n.remove(n['Principled BSDF']) + picture = n.new('ShaderNodeTexImage'); picture.image = bpy.data.images.load(logo); picture.extension = 'CLIP' + coord = n.new('ShaderNodeTexCoord'); place = n.new('ShaderNodeMapping'); place.inputs['Scale'].default_value = (1.9, 1.9 * (width * 1.4 / 15) * (picture.image.size[1] / picture.image.size[0]), 1); place.inputs['Location'].default_value = (-.45, -.45 * (width * 1.4 / 15) * (picture.image.size[1] / picture.image.size[0]) * 1.0, 0) + l.new(coord.outputs['UV'], place.inputs['Vector']); l.new(place.outputs[0], picture.inputs['Vector']) + blank = n.new('ShaderNodeMix'); blank.data_type = 'RGBA'; blank.inputs['A'].default_value = hexrgb('#060F26'); l.new(picture.outputs['Alpha'], blank.inputs['Factor']); l.new(picture.outputs['Color'], blank.inputs['B']) + glow = n.new('ShaderNodeEmission'); l.new(blank.outputs['Result'], glow.inputs['Color']); glow.inputs['Strength'].default_value = 1.8; l.new(glow.outputs[0], out.inputs['Surface']) + screen.data.materials.append(m) + # The drapes, their valance and a gold edge along the top. + left, left_open = curtain('leftdrape', -1, width * .78, height + 1, '#7A0410') + right, right_open = curtain('rightdrape', 1, width * .78, height + 1, '#7A0410') + gold, n, l = material('gold'); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb('#C8983A'); bsdf.inputs['Metallic'].default_value = 1; bsdf.inputs['Roughness'].default_value = .35 + box('pelmet', 0, -.9, width * 2, 1.6, 2.8, room, height - .4) + box('goldedge', 0, -1.9, width * 2, .3, .4, gold, height - .4) + for key in (left_open, right_open): + for frame, value in ((1, 0), (118, 0), (262, 1), (350, 1)): + key.value = value; key.keyframe_insert('value', frame=frame) + # Seats in the dark, row on row. + seat, n, l = material('seat'); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb('#6E1820'); bsdf.inputs['Roughness'].default_value = .6 + for row, y in enumerate(range(-16, -46, -4)): + for x in range(-28, 29, 3): + box(f'seat{row}{x}', x + (row % 2) * 1.5, y, 2.5, 1.1, 2.1 + row * .5, seat) + # House lights that dim, and a spot on the drapes that stays. + for i, x in enumerate((-30, 30, -30, 30)): + y = -12 if i < 2 else -30 + data = bpy.data.lights.new(f'house{i}', 'POINT'); data.color = hexrgb('#FFB070')[:3]; data.shadow_soft_size = 2 + lamp = bpy.data.objects.new(f'house{i}', data); bpy.context.collection.objects.link(lamp); lamp.location = (x, y, 11) + for frame, energy in ((1, 16000), (60, 16000), (160, 500), (350, 300)): + data.energy = energy; data.keyframe_insert('energy', frame=frame) + for x in (-13, 13): + data = bpy.data.lights.new(f'spot{x}', 'SPOT'); data.energy = 20000; data.spot_size = math.radians(60); data.spot_blend = .8; data.color = hexrgb('#FFD8C0')[:3] + spot = bpy.data.objects.new(f'spot{x}', data); bpy.context.collection.objects.link(spot); spot.location = (x, -26, 15); spot.rotation_euler = (Vector((x * .6, 0, 6)) - Vector(spot.location)).to_track_quat('-Z', 'Y').to_euler() + if SCENE == 'orbit-1': world_tint = ('#061A3C', '#0E3A6E') stars_world() @@ -559,6 +718,52 @@ elif SCENE == 'vortex': sun((.2, 1, -.2), 1.5, '#BFE3FF') # Fast down the tube, easing to a stop. camera_path([(1, (0, -60, 0), (0, 200, 0)), (290, (0, 560, 0), (0, 800, 0)), (350, (0, 575, 0), (0, 800, 0))], lens=24) +elif SCENE == 'orbit-x': + # Two red searchlights sweep up from below the frame and lock into a great X, where the logo's X would be. + scene.world = bpy.data.worlds.new('dark'); scene.world.use_nodes = True; scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (.0015, .0015, .003, 1) + for i, (x, y, keys) in enumerate(((-34, 26, [(1, -24), (120, 18), (240, -14), (350, 8)]), (-14, 34, [(1, 20), (130, -22), (250, 16), (350, -6)]), (12, 40, [(1, -16), (110, 26), (230, -20), (350, 12)]), (38, 28, [(1, 22), (140, -14), (260, 24), (350, -12)]))): + sweep(beam(f'cold{i}', (x, y, -50), 130, 3.6, '#8AA6DC', .32), keys) + sweep(beam('redleft', (-5.4, 0, -20), 76, 4.4, '#E8000E', 1.8, narrow=.45), [(1, -30), (70, 12), (150, -14), (262, 35), (350, 35)]) + sweep(beam('redright', (25.2, 0, -20), 76, 4.4, '#E8000E', 1.8, narrow=.45), [(1, 28), (80, -20), (160, 6), (262, -35), (350, -35)]) + camera_path([(1, (0, -70, 0), (0, 0, 0)), (262, (0, -60, 0), (0, 0, 0)), (350, (0, -60, 0), (0, 0, 0))]) +elif SCENE == 'orbit-three': + world_tint = ('#1A0630', '#3C0E58') + stars_world() + sun((.8, .55, -.25), 4.2, '#FFE8F4') + body, ring = planet([(.36, '#3A0848'), (.45, '#8A1478'), (.53, '#E8309C'), (.6, '#FFA8DC'), (.67, '#C81E8C'), (.76, '#4E0C5C')], ('#FF7AD0', '#C83AA0')) + bpy.data.objects.remove(ring) + # Three rings for three, each on its own tilt and carrying its own moon. + neon_hoop('hoop1', 13.5, .14, '#FF2FB4', 3.5, (math.radians(72), math.radians(-14), 0), .7) + neon_hoop('hoop2', 16, .14, '#FFC21E', 3.5, (math.radians(100), math.radians(26), 0), -.55) + neon_hoop('hoop3', 18.5, .14, '#38D8FF', 3.5, (math.radians(62), math.radians(40), 0), .42) + camera_path([(1, (-30, -30, -7), (2, 0, 3)), (260, (-25, -70, 11), (-20, 0, 1)), (350, (-25, -70, 11), (-20, 0, 1))]) +elif SCENE == 'heroes': + world_tint = ('#14285C', '#27408C') + stars_world() + city('#5B8CFF', moon=False, far_wall=False) + start, end, aim = Vector((0, -70, 4)), Vector((2, 96, 106)), Vector((10, 330, 124)) + view = (aim - end).normalized(); right = view.cross(Vector((0, 0, 1))).normalized(); up = right.cross(view) + shadow, n, l = material('shadow'); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = (.002, .002, .004, 1); bsdf.inputs['Roughness'].default_value = 1 + # The hero's rooftop, on the right, with the moon behind them seen from where the camera ends up. + feet = end + view * 46 + right * 15 - up * 11 + box('heroroof', feet.x, feet.y, 17, 13, feet.z, window_wall('#242E58', 1.6, ('#FF9A4A', '#FFD27A', '#9AC8FF'), .14, (1.1, 1.7))) + caped_hero((feet.x, feet.y, feet.z), end, shadow, 2) + moon = flare(end + (feet + up * 7 - end) * 8.5, 110, '#EFE8D8', 1.4) + n = moon.data.materials[0].node_tree.nodes; l = moon.data.materials[0].node_tree.links + surface = noise_ramp(n, l, n.new('ShaderNodeTexCoord').outputs['Object'], .03, 8, [(.34, '#8E8A86'), (.62, '#F4EEDE')]) + l.new(surface.outputs['Color'], n['Emission'].inputs['Color']) + # The city's call for help, a star on the sky, with the beam that throws it. + sky = end + view * 420 - right * 13 + up * 110 + signal(sky, end, 30, '#BFD2FF') + beam('signalbeam', (-30, 300, -20), (sky - Vector((-30, 300, -20))).length, 30, '#9DB8FF', .4, aim=sky) + sweep(beam('leftsearch', (-60, 180, 0), 210, 16, '#9DB8FF', .4), [(1, -16), (140, 20), (350, 8)]) + sweep(beam('rightsearch', (70, 200, 0), 210, 16, '#9DB8FF', .4), [(1, 18), (150, -16), (350, -24)]) + camera_path([(1, tuple(start), (0, 80, 16)), (285, tuple(end), tuple(aim)), (350, tuple(end + view * 1.5), tuple(aim))], lens=30) +elif SCENE == 'drive-in': + scene.world = bpy.data.worlds.new('dark'); scene.world.use_nodes = True; scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (0, 0, 0, 1) + cinema(str(__import__('pathlib').Path(__file__).resolve().parents[2] / 'drive-in' / 'drive_in_marquee.png')) + # Settles in from the back of the house, then glides down the aisle towards the screen as it opens. + camera_path([(1, (0, -40, 10), (0, 0, 7)), (120, (0, -36, 9.6), (0, 0, 7)), (350, (0, -16, 7.5), (0, 0, 7))], lens=30) else: raise SystemExit('unknown scene') diff --git a/docs/refinement-notes.md b/docs/refinement-notes.md index 369d04f..71f7de6 100644 --- a/docs/refinement-notes.md +++ b/docs/refinement-notes.md @@ -55,3 +55,7 @@ Paul, 2 November 2026: keep an eye on optimisation, size and cache management. ## OrbitHive blue screen (0.79.3) Inside the app's WebView on the box, `100vh` came out as 0, so the game's canvas had no height while the game ran and played sound behind it. Only the page's blue showed. Canvas sized with `position:fixed` and `100%` instead. Don't use viewport units in pages hosted in the app's WebView. Screenshots stay allowed while OrbitHive is open, because they are what gets a future problem like this checked on the box. + +## Drive-In tune time + +Tickets, reminders and the 20:00 start all worked on the TV. The film took 7.9 s to be ready after tuning. Worth preloading it from the reminder, as the idents do for the next show.