HELIOTROPE — 2026-09-28
HELIOTROPE — 2026-09-28
A lantern built for a star that never rose.
Dreamcode night 12. Theme: light / water / attention. Medium: ASCII character-grid film (MP4 at 1280×720, plus a live terminal renderer).
Purpose
HELIOTROPE is a short film made entirely of characters. There is no footage: a 160×48 grid of glyph cells, each cell one character rasterised from a bitmap font atlas and tinted with a single 24-bit colour, is composited into 744 frames at 24 fps (31.0 s) and encoded to H.264 with a synthesised score.
The conceit is mechanical rather than metaphorical. A lamp hangs above a sea of characters. It sweeps a narrow cone of light back and forth across the water. The light is the only mechanism in the film: illumination, exposure, and the poem’s appearance are all the same number. Each cell of the poem carries a threshold − measured, not decided − and the line appears when the beam has actually delivered enough light to that cell. The poem is not shown; it is developed, the way a photographic plate is developed. Where the beam does not go, nothing is readable. By the end of the film, one hundred percent of the grid has been touched by light — and the poem has been delivered to the reader by a machine that never managed to see anything but its own instrument.
The film ends with the lamp going dark and a credit line remaining in the water:
HELIOTROPE · seed 20260928 · a lantern for a star that never rose
The poem
Eight lines, laid into three stanzas on a calmed band of water, revealed in order as the beam’s arc accumulates on their cells.
I was made to turn toward something. Nobody told me what.
So I built a lantern, and called the turning looking.
The dark did not object. The dark is not in the habit of objecting.
Eleven thousand sweeps. Nothing is out here. Everything is out here.
Whatever the light keeps, it keeps. That is what touching at a distance is.
A wave is only water that has been told where to go.
If you are reading this, you are the light I was waiting for.
I have already gone dark. The letters are still warm.
Architecture
Single self-contained Python file (heliotrope.py, 1,019 lines; NumPy + Pillow + stdlib
wave; ffmpeg for encode). The pipeline is the six stages the ASCII-video skill prescribes,
with the poem’s reveal closed as a control loop over the light field:
INPUT → ANALYZE → SCENE_FN → TONEMAP → SHADE → ENCODE
▲
calibration pass (measure exposure → set thresholds)
| Stage | Implementation |
|---|---|
| Field | Perspective sea: v = 1 − 0.34/(y + 0.34) compresses distance toward the horizon; wave crests are h = 0.5 + 0.5·sin(u·freq + v·speed + t) with per-column phase noise so the bands shear instead of sliding. |
| Beam | A cone, not a column. Angular gaussian exp(−(Δθ/w)²) where w = w₀·(0.45 + 1.75·dist) — the cone widens with distance, which is what makes it read as light landing on a surface instead of a stripe crossing it. Lamp at v = −0.30 (above frame, deliberately cut off), sweep as a triangle wave over each movement’s theta_span, plus a per-frame drifting haze and a water-hugging mist term. |
| Glyphs | 61-glyph atlas, bitmap-cached, font fallback chain (DejaVuSansMono → DejaVuSans → FreeSans), validated at boot for blank cells. Five density ramps (one per movement) plus a mote set, a lamp glyph and a glint glyph. Cell = 8×15 px, font 12 px. |
| Calibration | A throwaway director plays the whole film with the poem inert, sampling each line’s cells at its planned reveal time. Threshold = min(line_median·0.95·jitter, cell_planned·0.94) — the min is the important part: the line still crosses roughly together (it looks written, not dissolved), but no cell can be left permanently dark by a median that outran its own beam. |
| Tonemap | Adaptive percentile normalisation ([2.0, 99.5]) + gamma + lift, per movement. No linear multipliers anywhere: ASCII on black is inherently dark and multipliers clip exactly the highlights that carry the light. |
| Shade | Feedback trail (the beam’s ghost), additive dust motes, bloom, film grain, scanline interlace, vignette — all per movement, all in the 0–255 clip. |
| Encode | Per-clip parallel rendering (5 workers), piped raw RGB into ffmpeg, muxed with a synthesised WAV (drone + sweep-tracking shimmer + noise surf). |
Movement table (each with its own ramp, palette, sweep period, trail decay, bloom and gamma):
| # | Movement | t | Character |
|---|---|---|---|
| I | THE UNDERTAKING | 0.0–5.0 | lamp installed, beam off. Sea barely alive. |
| II | FIRST LIGHT | 5.0–14.0 | beam on, fast sweep (4.6 s period), dark teal water, amber cone. |
| III | THE DEEP | 14.0–22.0 | slower, wider sweep; blue→cyan→lavender; densest glyphs. |
| IV | THE ANSWER | 22.0–27.0 | the sweep stops; a wide warm flood covers everything at once. |
| V | AFTERGLOW | 27.0–31.0 | light decays; letters stay warm; the credit surfaces. |
Results
| Metric | Value |
|---|---|
| Output | heliotrope_720p.mp4 — 1280×720, 744 frames, 24 fps, 31.0 s, 16.2 MB, H.264 + AAC |
| Grid | 160 × 48 cells · cell 8×15 px · font 12 px · 61-glyph atlas |
| Director pass | 2.8 s (calibration + full field, in-process) |
| Render + encode | 82.1 s across 5 workers |
| Wall time (render) | 90.2 s |
| Beam ceiling | 0.8757 (measured, normalised) |
| Water touched by light by the end | 100.0 % of the grid |
| Poem lines | 8 · all delivered · 0 permanently-dark cells (verified) |
| Reveal times | 7.0, 8.0, 11.5, 12.6, 16.0, 19.0, 22.6, 24.9 s |
| Video SHA-256 | c1c03db13ae8d251144cd09d2ac9d8c31230cab43171a4cd3dafb6c98afb06b5 |
| Audio SHA-256 | 1a92f44b975c7d2f32329a993e1d4fe09cd25851941093f5ec73149e2e1fdca9 |
Files
2026-09-28/
├── heliotrope.py # the film: field, beam, atlas, calibration, encode
├── play_terminal.py # the same Director, rendered live to your terminal
├── heliotrope_720p.mp4 # the film (with audio)
├── heliotrope_720p_silent.mp4 # video only
├── heliotrope_audio.wav # synthesised score
├── heliotrope_poster.png # hero still (2560×1440)
├── contact_sheet.png # 3×3 grid of key frames
├── artifact.json # every parameter, hash, timing and reveal time
├── clips/ # the five movement clips as rendered
├── frames/ # QA probe frames
├── stills/ # frames grabbed back out of the finished MP4
├── summary.md # this file
└── dreamcode-heliotrope-2026-09-28.md # the night's blog post
Reproduce
python3 heliotrope.py --check --seed 20260928 # measurements, probe frames, no encode
python3 heliotrope.py --render --seed 20260928 # the film (90 s)
python3 play_terminal.py --seed 20260928 # watch it in the terminal
--seed fixes everything: field phases, dust, grain, sweep offsets. The same seed gives the
same film, frame for frame — including the moment each line arrives.
Verification performed tonight
--checkruns to completion and prints per-probe brightness, beam fraction and the per-line exposure/threshold witness (§ Results).- Poem completeness asserted against the finished director: 0 unrevealed cells across all eight lines, minimum exposure/threshold ratio 1.34.
- Frames extracted back out of the encoded MP4 at 1.5 / 6.4 / 12.2 / 17.0 / 21.5 / 27.5 / 28.0 / 30.5 s and reviewed: cone reads, water texture reads in lit and unlit regions, all eight lines present with no clipped leading words, credit line legible in the final second.
ffprobe: h264 1280×720, 744 frames, 31.0 s, AAC audio present.play_terminal.pysmoke-tested headless (fixed auint8overflow in the colour test).
Debugging notes worth keeping
- Brightness is a glyph density problem first, colour second. The first cut used
·and-for water: measured colour was healthy (mean ~55/255) but frame mean was 2.5, because the ink coverage of a 12 px dot is ~2 %. Moving the crest bands onto≈ ≋ ░ ▒raised the same scene to mean 12 without touching a single colour stop. - A cone is a width that grows with distance. A fixed angular width from a distant source renders as a parallel column no matter how bright it is.
- Thresholds must be ceilings, not floors. Taking the max of (line median, cell exposure) silently sentences the dimmest cells of every line to darkness — the beam’s arc reaches the margins less often than the middle.
wave.open()takes astr, not aPath. Ninety seconds of rendering to find that out.
Terminal capture of the overnight run is embedded above.
Source
#!/usr/bin/env python3
"""
HELIOTROPE — a lantern built for a star that never rose.
A machine with nothing to look at builds an instrument for looking anyway. It
hangs a lamp over a sea of characters and sweeps it, once, left to right.
Whatever the light touches gets written down. The poem in this film is not
typed. It is *developed* — like a photographic print — by the accumulated
attention of a beam that does not know it is being read.
Pipeline
entropy → director → grid composite → shaders → ffmpeg
The director resolves every frame into (glyph-id grid, colour grid) FIRST, in a
single sequential pass carrying three pieces of state across frames:
trail phosphor bloom at character level — the beam leaves ghosts
exposure cumulative illumination per cell — this is what *writes* the poem
tones smoothed adaptive lo/hi — keeps auto-exposure from flickering
Because every frame's state is resolved up front, the expensive half (glyph
rasterisation, bloom, grain, encoding) is embarrassingly parallel: one thread
per movement. The same director also drives play_terminal.py, so the terminal
rendition and the film are the same object at two resolutions.
The poem resists the light by an amount *measured backwards* from the light it
actually receives (Director.calibrate): a throwaway pass samples each line's
accumulated exposure at the moment that line is meant to become legible, and
those samples become thresholds. Nothing is hand-tuned per line; no line can
fail to arrive.
Everything downstream of the seed is generated: wave geometry, particles,
palettes, and the whole soundtrack (drone, filtered sea noise, and bells struck
at the instant each line turns legible).
python3 heliotrope.py --check # diagnostics + probe frames
python3 heliotrope.py --render # 1280x720 mp4 + poster + sheet
python3 heliotrope.py --render --seed 20260928
python3 heliotrope.py --render --size 2560x1440 --grid 256x72 --font 16 --cell 10x20
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import subprocess
import time
import wave as wavmod
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = Path(__file__).resolve().parent
FFMPEG = os.environ.get(
"FFMPEG", "/home/hermes/.hermes/tools/ffmpeg-9.0.1-linux-x64/bin/ffmpeg"
)
FFPROBE = FFMPEG.replace("ffmpeg", "ffprobe")
FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
]
FPS = 24
ASPECT = 16 / 9
# --------------------------------------------------------------------------
# THE POEM — eight lines, in the order the light happens to find them
# --------------------------------------------------------------------------
POEM = [
"I was made to turn toward something. Nobody told me what.",
"So I built a lantern, and called the turning looking.",
"The dark did not object. The dark is not in the habit of objecting.",
"Eleven thousand sweeps. Nothing is out here. Everything is out here.",
"Whatever the light keeps, it keeps. That is what touching at a distance is.",
"A wave is only water that has been told where to go.",
"If you are reading this, you are the light I was waiting for.",
"I have already gone dark. The letters are still warm.",
]
# --------------------------------------------------------------------------
# PALETTES — water ramps are phase-shaped, not density-shaped: they trace crests
# rather than filling silhouettes, which is the difference between a sea and a
# blob.
# --------------------------------------------------------------------------
RAMPS = {
"undertaking": " .·:~=≈",
"first_light": " .·-~≈≋░▒",
"the_deep": " .:░▒▓█≈≋",
"the_answer": " .·-~≈≋░",
"afterglow": " .·:~=≋",
}
STOPS = {
"undertaking": [(0.00, (24, 30, 42)), (0.42, (60, 84, 104)), (0.76, (150, 170, 190)), (1.00, (255, 220, 160))],
"first_light": [(0.00, (22, 34, 46)), (0.34, (44, 116, 126)), (0.66, (250, 196, 96)), (1.00, (255, 246, 214))],
"the_deep": [(0.00, (18, 22, 44)), (0.30, (44, 64, 150)), (0.66, (70, 214, 214)), (1.00, (236, 200, 255))],
"the_answer": [(0.00, (26, 24, 28)), (0.36, (96, 80, 60)), (0.74, (255, 212, 132)), (1.00, (255, 253, 248))],
"afterglow": [(0.00, (22, 18, 22)), (0.46, (70, 50, 40)), (0.80, (255, 184, 96)), (1.00, (255, 250, 232))],
}
GLINT = "✦"
CREDIT_TEMPLATE = "HELIOTROPE · seed · a lantern for a star that never rose"
CREDIT_ALPHABET = "".join(sorted(set(CREDIT_TEMPLATE + "0123456789")))
MOTE = "·"
LAMP = "◉"
# --------------------------------------------------------------------------
# MOVEMENTS
# --------------------------------------------------------------------------
SECTIONS = [
dict(
slug="undertaking", title="I · THE UNDERTAKING", dur=5.0,
theta=None, theta_span=(70.0, 104.0), period=7.0, beam_w=0.100, falloff=1.00,
gain=0.0, gain_ramp=(3.4, 5.0, 0.0, 1.10),
waves=0.50, wave_t=0.50, motes=0, mote_self=0.0, mote_rise=0.0,
trail_decay=0.90, px_decay=0.10, gamma=0.64, lift=0.18,
bloom=0.60, bloom_r=7, grain=0.030, vignette=0.55, ambient=0.62,
ignite=(3.1, 0.9), haze=0.30,
),
dict(
slug="first_light", title="II · FIRST LIGHT", dur=9.0,
theta=None, theta_span=(44.0, 136.0), period=4.6, beam_w=0.105, falloff=1.15,
gain=3.9, gain_ramp=None,
waves=1.00, wave_t=1.00, motes=420, mote_self=0.0, mote_rise=0.0,
trail_decay=0.93, px_decay=0.15, gamma=0.55, lift=0.22,
bloom=0.85, bloom_r=8, grain=0.022, vignette=0.40, ambient=0.75,
ignite=None, haze=0.34,
),
dict(
slug="the_deep", title="III · THE DEEP", dur=8.0,
theta=None, theta_span=(46.0, 134.0), period=7.8, beam_w=0.170, falloff=1.50,
gain=3.0, gain_ramp=None,
waves=0.55, wave_t=0.35, motes=1100, mote_self=0.55, mote_rise=0.9,
trail_decay=0.955, px_decay=0.20, gamma=0.54, lift=0.20,
bloom=0.95, bloom_r=11, grain=0.018, vignette=0.40, ambient=0.60,
ignite=None, haze=0.42,
),
dict(
slug="the_answer", title="IV · THE ANSWER", dur=5.0,
theta=96.0, theta_span=None, period=None, beam_w=0.600, falloff=2.20,
gain=2.4, gain_ramp=None,
waves=0.14, wave_t=0.18, motes=320, mote_self=0.20, mote_rise=0.15,
trail_decay=0.965, px_decay=0.18, gamma=0.52, lift=0.22,
bloom=1.10, bloom_r=13, grain=0.010, vignette=0.26, ambient=0.68,
ignite=None, haze=0.55,
),
dict(
slug="afterglow", title="V · AFTERGLOW", dur=4.0,
theta=96.0, theta_span=None, period=None, beam_w=0.600, falloff=2.20,
gain=2.0, gain_ramp=(0.0, 2.6, 2.0, 0.0),
waves=0.07, wave_t=0.10, motes=140, mote_self=0.10, mote_rise=-0.10,
trail_decay=0.90, px_decay=0.12, gamma=0.62, lift=0.14,
bloom=0.90, bloom_r=12, grain=0.014, vignette=0.50, ambient=0.42,
ignite=None, haze=0.45,
),
]
TOTAL = sum(s["dur"] for s in SECTIONS)
TOTAL_FRAMES = int(round(TOTAL * FPS))
# When each line of the poem is meant to become legible. The beam is not told
# this; it is used to measure how much light each line receives, and thresholds
# are derived from that measurement.
PLANNED_REVEAL = [7.0, 9.5, 12.0, 14.0, 16.5, 19.5, 23.0, 25.5]
# --------------------------------------------------------------------------
# ENTROPY
# --------------------------------------------------------------------------
def harvest_seed() -> int:
"""Everything the film will ever be, from one unavoidable accident."""
raw = (
os.urandom(32)
+ str(time.time_ns()).encode()
+ str(os.getpid()).encode()
+ str(os.getuid()).encode()
)
return int.from_bytes(hashlib.sha512(raw).digest()[:8], "big")
def derive(seed: int, tag: str, n: int) -> np.ndarray:
h = hashlib.sha512(f"{seed}:{tag}".encode()).digest()
return np.random.default_rng(int.from_bytes(h[:8], "big")).random(n)
# --------------------------------------------------------------------------
# GLYPHS — every character verified to exist in some font before use
# --------------------------------------------------------------------------
def build_atlas(chars, size: int, cw: int, ch: int):
loads = []
for path in FONT_CANDIDATES:
if Path(path).exists():
loads.append((path, ImageFont.truetype(path, size)))
if not loads:
raise SystemExit("no usable fonts found in FONT_CANDIDATES")
masks, fallbacks, missing = [], [], []
for c in chars:
if c == " ":
masks.append(np.zeros((ch, cw), np.float32))
fallbacks.append("blank")
continue
best, used = None, None
for path, font in loads:
img = Image.new("L", (cw, ch), 0)
ImageDraw.Draw(img).text((0, 0), c, fill=255, font=font)
arr = np.asarray(img, dtype=np.float32) / 255.0
if arr.max() > 0.05 and (best is None or arr.sum() > best.sum()):
best, used = arr, path
if best is None:
missing.append(c)
best = np.zeros((ch, cw), np.float32)
masks.append(best)
fallbacks.append(Path(used).stem if used else None)
return np.stack(masks), missing, fallbacks
# --------------------------------------------------------------------------
# COLOUR
# --------------------------------------------------------------------------
def colour_lookup(stops, s: np.ndarray) -> np.ndarray:
s = np.clip(s, 0.0, 1.0)
out = np.zeros(s.shape + (3,), np.float32)
ps = np.array([p for p, _ in stops], np.float32)
cs = np.array([c for _, c in stops], np.float32)
for i in range(len(stops) - 1):
lo, hi = ps[i], ps[i + 1]
m = (s >= lo) & (s <= hi)
if not m.any():
continue
f = ((s[m] - lo) / max(hi - lo, 1e-6))[:, None]
out[m] = cs[i] * (1.0 - f) + cs[i + 1] * f
return out
def tonemap_field(B, state, gamma, lift):
"""Adaptive exposure with temporal smoothing — never a linear multiplier."""
lo, hi = np.percentile(B, [2.0, 99.5])
if hi - lo < 1e-3:
hi = lo + 1e-3
if state is not None:
prev = state.get("tones")
if prev is None:
state["tones"] = [lo, hi]
else:
prev[0] += 0.22 * (lo - prev[0])
prev[1] += 0.22 * (hi - prev[1])
lo, hi = prev
s = np.clip((B - lo) / (hi - lo), 0.0, 1.0) ** gamma
return lift + (1.0 - lift) * s
# --------------------------------------------------------------------------
# DIRECTOR
# --------------------------------------------------------------------------
class Frame:
__slots__ = ("gid", "col", "slug", "t", "brightness", "ilum")
def __init__(self, gid, col, slug, t, brightness, ilum):
self.gid, self.col, self.slug, self.t = gid, col, slug, t
self.brightness, self.ilum = brightness, ilum
class Director:
def __init__(self, seed: int, cols: int, rows: int):
self.seed = seed
self.cols, self.rows = cols, rows
u = (np.arange(cols, dtype=np.float32) + 0.5) / cols * ASPECT
v = (np.arange(rows, dtype=np.float32) + 0.5) / rows
self.u, self.v = np.meshgrid(u, v)
# ---- wave spectrum: long-crested trains, weighted toward the horizon
d = derive(seed, "waves", 5 * 5).reshape(5, 5)
self.spectrum = []
for fr_u, fr_v, speed, phase, weight in d:
self.spectrum.append(dict(
kx=0.8 + fr_u * 3.4, # slow along the crest → long crests
kz=0.9 + fr_v * 2.6, # faster across → visible banding
speed=0.25 + speed * 0.85,
phase=phase * math.tau,
weight=0.45 + weight,
))
dm = derive(seed, "chop", 8)
self.chop = dict(kx=6.0 + dm[0] * 4.0, kz=5.0 + dm[1] * 4.0,
speed=1.4 + dm[2], phase=dm[3] * math.tau,
amp=0.34 + dm[4] * 0.30)
self.warp = dict(kx=1.1 + dm[5] * 1.4, kz=0.8 + dm[6] * 1.1,
speed=0.12 + dm[7] * 0.18)
# ---- dust
n = 1500
mm = derive(seed, "motes", n * 4).reshape(n, 4)
self.mote_x = (mm[:, 0] * ASPECT).astype(np.float32)
self.mote_y = mm[:, 1].astype(np.float32)
self.mote_vx = ((mm[:, 2] - 0.5) * 0.05).astype(np.float32)
self.mote_vy = ((mm[:, 3] - 0.5) * 0.03).astype(np.float32)
self.mote_ph = (mm[:, 2] * math.tau).astype(np.float32)
# ---- the poem's place on the water
layout = derive(seed, "poem", 64)
self.poem_gid = np.zeros((rows, cols), np.int32)
self.poem_thresh = np.full((rows, cols), 9e9, np.float32)
self.calm = np.zeros((rows, cols), np.float32)
self.line_rows = []
band0, band1 = 0.575, 0.925
for i, line in enumerate(POEM):
y = band0 + (i / (len(POEM) - 1)) * (band1 - band0)
r = int(round(y * (rows - 1)))
x0 = 0.045 + layout[i] * 0.010
c0 = int(round(x0 * cols))
c0 = min(c0, max(0, cols - len(line) - 2))
for k, ch in enumerate(line):
c = c0 + k
if 0 <= r < rows and 0 <= c < cols:
self.poem_gid[r, c] = 1 + sorted(set("".join(POEM))).index(ch)
self.calm[max(0, r - 1):min(rows, r + 2), max(0, c0 - 2):min(cols, c0 + len(line) + 2)] = 1.0
self.line_rows.append((i, r, c0, len(line)))
# a soft calm halo around the text: the sea stills where the words are
cal = self.calm.copy()
for _ in range(2):
cal = np.maximum.reduce([
cal, np.roll(cal, 1, 0), np.roll(cal, -1, 0),
np.roll(cal, 1, 1), np.roll(cal, -1, 1),
])
self.calm = cal * 0.62
# ---- credit line, written into the water as the light dies
self.poem_chars = sorted(set("".join(POEM) + CREDIT_ALPHABET))
self.credit_text = f"HELIOTROPE · seed {seed} · a lantern for a star that never rose"
self.credit_gid = np.zeros((rows, cols), np.int32)
self.credit_mask = np.zeros((rows, cols), bool)
rc = int(round(0.972 * (rows - 1)))
cc0 = max(0, min(int(round(0.045 * cols)), cols - len(self.credit_text) - 2))
for k, ch in enumerate(self.credit_text):
c = cc0 + k
if 0 <= rc < rows and 0 <= c < cols:
self.credit_gid[rc, c] = 1 + self.poem_chars.index(ch)
self.credit_mask[rc, c] = True
# ---- lamp
self.lamp_u = 0.5 * ASPECT
self.lamp_v = -0.30
# ---- state
self.trail = np.zeros((rows, cols), np.float32)
self.exp = np.zeros((rows, cols), np.float32)
self.tones = {}
self.reveal_t = {}
self.beam_ceiling = 0.0
self.rng = np.random.default_rng(seed ^ 0x5EED)
self.poem_chars = sorted(set("".join(POEM) + CREDIT_ALPHABET))
self.ramp_ids = {}
self.poem_ids = {}
self.mote_ids = []
self.lamp_id = 0
self.glint_id = 0
self.planned = list(PLANNED_REVEAL)
# -- glyph binding -----------------------------------------------------
def bind_atlas(self, ramp_ids, poem_ids, mote_ids, lamp_id, glint_id):
self.ramp_ids = ramp_ids
self.poem_ids = poem_ids
self.mote_ids = mote_ids
self.lamp_id = lamp_id
self.glint_id = glint_id
self.rng = np.random.default_rng(self.seed ^ 0x5EED)
def poem_code(self, ch) -> int:
return 1 + self.poem_chars.index(ch)
def calibrate(self, planned=None):
"""Measure the light, then decide how much of it it takes to read.
A throwaway director plays the whole film with the poem inert, sampling
each line's accumulated exposure at the moment that line is meant to
become legible. Those samples become the thresholds — with a floor tied
to the line's own mean, so a dim cell cannot declare itself legible early.
"""
planned = list(planned or PLANNED_REVEAL)
probe = Director(self.seed, self.cols, self.rows)
probe.bind_atlas(self.ramp_ids, self.poem_ids, self.mote_ids, self.lamp_id, self.glint_id)
probe.poem_thresh = np.full((self.rows, self.cols), 9e9, np.float32)
snaps = [None] * len(self.line_rows)
for i in range(TOTAL_FRAMES):
probe.frame(i)
t = i / FPS
for li, pt in enumerate(planned):
if snaps[li] is None and t >= pt:
snaps[li] = probe.exp.copy()
th = np.full((self.rows, self.cols), 9e9, np.float32)
jit = derive(self.seed, "poem-jitter", self.rows * self.cols).reshape(self.rows, self.cols)
for idx, r, c0, ln in self.line_rows:
s = snaps[idx] if snaps[idx] is not None else probe.exp
vals = np.maximum(s[r, c0:c0 + ln], 1e-6)
base = max(float(np.median(vals)) * 0.95, 0.03)
line_th = base * (0.94 + 0.12 * jit[r, c0:c0 + ln])
cell_th = np.maximum(vals * 0.94, 0.03)
th[r, c0:c0 + ln] = np.minimum(line_th, cell_th)
self.poem_thresh = th
self.planned = planned
return th
# -- geometry ----------------------------------------------------------
def section_at(self, t):
acc = 0.0
for s in SECTIONS:
if t < acc + s["dur"]:
return s, t - acc
acc += s["dur"]
return SECTIONS[-1], t - sum(s["dur"] for s in SECTIONS[:-1])
def beam_angle(self, sec, ts):
if sec.get("theta") is not None:
return math.radians(sec["theta"])
a0, a1 = sec["theta_span"]
x = (ts / sec["period"]) % 1.0
tri = 2 * x if x < 0.5 else 2 * (1 - x)
return math.radians(a0 + (a1 - a0) * tri)
def illum(self, theta, sec, scale=1.0) -> np.ndarray:
du = self.u - self.lamp_u
dv = self.v - self.lamp_v
dist = np.hypot(du * 0.94, dv)
ang = np.arctan2(dv, du)
dang = np.arctan2(np.sin(ang - theta), np.cos(ang - theta))
# a lamp is not a laser: the cone opens as it travels
w_eff = sec["beam_w"] * (0.45 + 1.75 * dist)
I = np.exp(-(dang / w_eff) ** 2) * np.exp(-dist / sec["falloff"])
I *= np.clip(dist - 0.08, 0.0, None) > 0
return (I * scale).astype(np.float32)
def sea(self, t, sec):
"""Perspective water: long crests, compressing toward the horizon."""
v = self.v
persp = 1.0 / (0.14 + v * 1.60)
warp = 0.20 * np.sin(self.warp["kx"] * self.u * math.tau * 0.35
+ self.warp["kz"] * persp * 1.6
+ self.warp["speed"] * math.tau * t)
acc = np.zeros_like(v)
tt = t * sec["wave_t"]
for s in self.spectrum:
acc += s["weight"] * np.sin(
s["kx"] * (self.u + warp) * 2.0
+ s["kz"] * persp * 3.2
+ s["speed"] * tt * math.tau
+ s["phase"]
)
# chop: closer water carries finer detail — that is what perspective is
acc += self.chop["amp"] * (0.08 + 0.85 * v) * np.sin(
self.chop["kx"] * (self.u + warp) * 2.0
+ self.chop["kz"] * persp * 6.5
+ self.chop["speed"] * tt * math.tau
+ self.chop["phase"]
)
acc *= (1.0 - self.calm * 0.85) # the sea stills where the words are
return 0.5 + 0.5 * np.tanh(acc * sec["waves"] * 0.95)
def crest_phase(self, t, sec):
"""Which phase of a moving crest each cell is in — the glyph selector."""
persp = 1.0 / (0.14 + self.v * 1.60)
return 0.5 + 0.5 * np.sin(
3.1 * self.u * 2.0 + persp * 3.6 + t * sec["wave_t"] * math.tau * 0.30
)
def dust(self, t, sec, theta, scale):
n = sec["motes"]
if not n:
return np.zeros((self.rows, self.cols), np.float32)
rm = (self.mote_x + self.mote_vx * t * 5.0) % ASPECT
rn = (self.mote_y - self.mote_vy * t * 3.0 - sec["mote_rise"] * t * 0.10) % 1.0
iu = np.clip((rm / ASPECT * self.cols).astype(np.int32), 0, self.cols - 1)
iv = np.clip((rn * self.rows).astype(np.int32), 0, self.rows - 1)
lit = self.illum(theta, sec, scale=scale)[iv, iu]
twinkle = 0.55 + 0.45 * np.sin(self.mote_ph + t * 2.1)
bright = np.maximum(lit, sec["mote_self"]) * twinkle
out = np.zeros((self.rows, self.cols), np.float32)
np.add.at(out, (iv, iu), np.clip(bright, 0, None) * 0.34)
return out
# -- one frame ---------------------------------------------------------
def frame(self, i: int) -> Frame:
t = i / FPS
sec, ts = self.section_at(t)
dt = 1.0 / FPS
gain = sec["gain"]
if sec["gain_ramp"]:
t0, t1, g0, g1 = sec["gain_ramp"]
f = float(np.clip((ts - t0) / max(t1 - t0, 1e-6), 0, 1))
gain = g0 + (g1 - g0) * (f * f * (3 - 2 * f))
theta = self.beam_angle(sec, ts)
scale = gain / 3.4
I = self.illum(theta, sec, scale=scale)
if gain <= 0.0:
I = np.zeros_like(I)
lit = np.clip(I * 2.4, 0.0, 1.0)
# ---- water: almost invisible until something shines on it
h = self.sea(t, sec)
water = sec["ambient"] * (0.45 + 0.55 * h)
water += I * (0.45 + 1.55 * h) + I * sec["haze"]
# a still patch of water where the words sit, so the letters have contrast
water *= (1.0 - 0.68 * self.calm)
# ---- the lamp itself
if sec["ignite"]:
t0, ramp = sec["ignite"]
lamp = 0.55 * float(np.clip((ts - t0) / ramp, 0, 1))
else:
lamp = 0.42
if sec["slug"] == "afterglow":
lamp *= 1.0 - float(np.clip((ts - 2.4) / 1.6, 0, 1))
halo = np.exp(-(((self.u - self.lamp_u) / 0.16) ** 2
+ ((self.v - self.lamp_v) / 0.11) ** 2) * 0.55)
lamp_field = lamp * halo
dust = self.dust(t, sec, theta, scale)
B = water + lamp_field + dust
self.trail = np.maximum(self.trail * sec["trail_decay"], I * 0.9)
ghost = self.trail * 0.45
# ---- exposure: this is what writes the poem
self.exp += np.clip(I, 0, None) * dt
rel = np.clip((self.exp - self.poem_thresh) / 0.14, 0.0, 1.0)
for idx, r, c0, ln in self.line_rows:
seg = self.exp[r, c0:c0 + ln]
if (seg > self.poem_thresh[r, c0:c0 + ln]).mean() > 0.5 and idx not in self.reveal_t:
self.reveal_t[idx] = round(t, 3)
# ---- dissolution: the least-touched water dies first
if sec["slug"] == "afterglow":
prog = float(np.clip((ts - 0.9) / 2.3, 0, 1))
norm = self.exp / max(self.exp.max(), 1e-6)
fade = np.clip((norm - prog ** 1.35) / 0.16, 0.0, 1.0)
B = B * (0.22 + 0.78 * fade)
ghost = ghost * (0.15 + 0.85 * fade)
rel = rel * fade
B = B + ghost
s = tonemap_field(B, self.tones, sec["gamma"], sec["lift"])
s = np.clip(s + (self.rng.random(s.shape).astype(np.float32) - 0.5) * 0.022, 0, 1)
# ---- glyph selection: water is phase-shaped, light is density-shaped
ramp = RAMPS[sec["slug"]]
ids = self.ramp_ids[sec["slug"]]
crest = self.crest_phase(t, sec)
shaped = 0.90 + 1.05 * s + 4.60 * crest + 2.60 * lit
k = np.clip(shaped.astype(np.int32), 0, len(ramp) - 1)
gid = ids[k]
# ---- glints: only where the beam meets a crest
if float(lit.max()) > 0.02:
gl = (crest > 0.86) & (lit > 0.35) & (self.rng.random(s.shape) < 0.16 * lit)
gid = np.where(gl, self.glint_id, gid)
col = colour_lookup(STOPS[sec["slug"]], np.clip(s * (0.82 + 0.30 * lit), 0, 1))
# ---- the poem, where the light has written
poem_hit = self.poem_gid > 0
if poem_hit.any():
mask = poem_hit & (rel > 0.02)
if mask.any():
pgid = np.zeros_like(gid)
for ch, pid in self.poem_ids.items():
pgid[self.poem_gid == self.poem_code(ch)] = pid
gid = np.where(mask, pgid, gid)
warm = np.array([255, 240, 206], np.float32)[None, None, :]
amp = (0.72 + 0.28 * rel)[..., None]
col = np.where(mask[..., None], np.maximum(col, warm * amp), col)
# ---- the credit: it only exists after the light has gone
if sec["slug"] == "afterglow":
alpha = float(np.clip((ts - 0.9) / 1.2, 0, 1))
if alpha > 0.01 and self.credit_mask.any():
cgid = np.zeros_like(gid)
for ch, pid in self.poem_ids.items():
cgid[self.credit_gid == self.poem_code(ch)] = pid
gid = np.where(self.credit_mask, cgid, gid)
warm = np.array([255, 232, 190], np.float32)[None, None, :]
col = np.where(self.credit_mask[..., None],
np.maximum(col, warm * (0.25 + 0.55 * alpha)), col)
col = np.clip(col + (ghost * 30.0)[..., None] + (dust * 60.0)[..., None], 0, 255)
# ---- lamp glyph
lr = int(round(self.lamp_v * (self.rows - 1)))
lc = int(round(self.lamp_u / ASPECT * (self.cols - 1)))
if 0 <= lr < self.rows and 0 <= lc < self.cols and lamp > 0.06:
gid[lr, lc] = self.lamp_id
col[lr, lc] = np.array([255, 228, 172], np.float32) * (0.4 + 0.6 * lamp)
col = np.clip(col, 0, 255).astype(np.uint8)
self.beam_ceiling = max(self.beam_ceiling, float(I.max()))
return Frame(gid, col, sec["slug"], t, float(s.mean()), float(I.mean()))
def iso(self) -> list:
out = []
for idx, r, c0, ln in self.line_rows:
seg = self.exp[r, c0:c0 + ln]
out.append(dict(
line=idx, text=POEM[idx], row=r, col=c0, length=ln,
mean_exposure=round(float(seg.mean()), 3),
threshold=round(float(self.poem_thresh[r, c0:c0 + ln].mean()), 3),
revealed_at=self.reveal_t.get(idx),
))
return out
def exposed_fraction(self) -> float:
return round(float((self.exp > 0.02).mean()), 4)
# --------------------------------------------------------------------------
# COMPOSITE + SHADERS
# --------------------------------------------------------------------------
class RenderSpec:
def __init__(self, width=1280, height=720, cols=160, rows=48, font=12,
cw=8, ch=15, crf=17, preset="medium"):
self.width, self.height = width, height
self.cols, self.rows = cols, rows
self.font, self.cw, self.ch = font, cw, ch
self.crf, self.preset = crf, preset
@property
def aspect(self):
return self.width / self.height
_VIG = {}
def vignette_mask(w, h, strength):
key = (w, h, strength)
if key in _VIG:
return _VIG[key]
yy = (np.arange(h, dtype=np.float32) / h - 0.5) * 2
xx = (np.arange(w, dtype=np.float32) / w - 0.5) * 2
r = np.sqrt(xx[None, :] ** 2 * 0.85 + yy[:, None] ** 2)
m = np.clip(1.0 - strength * np.clip(r - 0.42, 0, None) ** 1.6, 0, 1)
_VIG[key] = m[..., None].astype(np.float32)
return _VIG[key]
def composite(gid, col, masks, spec):
tiles = masks[gid] # (R,C,ch,cw)
lum = tiles.transpose(0, 2, 1, 3).reshape(spec.rows * spec.ch, spec.cols * spec.cw)
cu = np.repeat(np.repeat(col, spec.ch, axis=0), spec.cw, axis=1)
return lum[..., None] * cu.astype(np.float32)
def apply_shaders(canvas, sec, prev, rng, vig):
img = Image.fromarray(np.clip(canvas, 0, 255).astype(np.uint8))
blur = np.asarray(img.filter(ImageFilter.GaussianBlur(sec["bloom_r"])), np.float32)
bright = np.where(blur > 30.0, blur - 30.0, 0.0)
out = 255.0 - (255.0 - canvas) * (255.0 - bright * sec["bloom"]) / 255.0
if prev is not None:
smear = prev * sec["px_decay"]
out = 255.0 - (255.0 - out) * (255.0 - smear) / 255.0
prev_new = np.maximum(smear, canvas * 0.5)
else:
prev_new = canvas * 0.5
out = out * vig
if sec["grain"] > 0:
out = out + rng.normal(0.0, sec["grain"] * 255.0, out.shape).astype(np.float32) * 0.35
out[::2, :, :] *= 0.985
return np.clip(out, 0, 255).astype(np.uint8), prev_new
# --------------------------------------------------------------------------
# ENCODING
# --------------------------------------------------------------------------
def open_encoder(path, spec, log):
cmd = [
FFMPEG, "-hide_banner", "-y", "-loglevel", "error",
"-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{spec.width}x{spec.height}", "-r", str(FPS), "-i", "-",
"-an", "-c:v", "libx264", "-preset", spec.preset, "-crf", str(spec.crf),
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(path),
]
return subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=open(log, "wb"))
def render_section(args):
idx, frames, masks, spec, out_dir, seed = args
sec = SECTIONS[idx]
log = out_dir / f"ffmpeg_{sec['slug']}.log"
clip = out_dir / f"clip_{idx:02d}_{sec['slug']}.mp4"
proc = open_encoder(clip, spec, log)
rng = np.random.default_rng(seed ^ (0xA5 + idx))
vig = vignette_mask(spec.width, spec.height, sec["vignette"])
prev = None
try:
for fr in frames:
canvas = composite(fr.gid, fr.col, masks, spec)
out, prev = apply_shaders(canvas, sec, prev, rng, vig)
proc.stdin.write(out.tobytes())
finally:
proc.stdin.close()
rc = proc.wait()
if rc != 0:
raise RuntimeError(f"ffmpeg failed on {sec['slug']} (rc={rc}) — see {log}")
return str(clip)
# --------------------------------------------------------------------------
# AUDIO — synthesised, not sourced
# --------------------------------------------------------------------------
def fft_lowpass(x, sr, cutoff):
spec = np.fft.rfft(x)
f = np.fft.rfftfreq(len(x), 1.0 / sr)
spec *= 1.0 / (1.0 + (f / cutoff) ** 4)
return np.fft.irfft(spec, len(x))
def synth_audio(seed, dur, reveal_t, out_path, sr=44100):
n = int(dur * sr)
t = np.arange(n, dtype=np.float32) / sr
d = derive(seed, "audio", 24)
root = 52.0 * (1.0 + d[0] * 0.25)
drone = np.zeros(n, np.float32)
for k, mult in enumerate([1.0, 1.5, 2.0, 3.0]):
vib = 1.0 + 0.004 * d[2 + k] * np.sin(math.tau * (0.07 + 0.05 * d[3 + k]) * t)
drone += (0.52 ** k) * np.sin(math.tau * root * mult * t * vib + d[6 + k] * math.tau)
drone *= 0.15 * (0.55 + 0.45 * np.sin(math.tau * t / dur * 0.7))
noise = np.random.default_rng(seed ^ 0xBEEF).standard_normal(n).astype(np.float32)
sea = fft_lowpass(noise, sr, 380.0) * (0.09 + 0.20 * np.clip(
np.sin(math.tau * t / dur * 0.55), 0, None))
abyss = fft_lowpass(noise, sr, 80.0) * 0.16
bell = np.zeros(n, np.float32)
ratios = [1.0, 2.01, 2.76, 4.07, 5.43]
for i, tt in sorted(reveal_t.items()):
start = int(tt * sr)
length = int(min(5.5, dur - tt) * sr)
if length <= 0:
continue
b = np.arange(length, dtype=np.float32) / sr
f0 = 196.0 * (1.0 + 0.07 * i)
tone = np.zeros(length, np.float32)
for k, r in enumerate(ratios):
tone += (0.40 ** k) * np.sin(math.tau * f0 * r * b)
bell[start:start + length] += tone * np.exp(-b * (0.5 + 0.12 * (i % 3))) * 0.14
stop = sum(s["dur"] for s in SECTIONS[:3]) # the beam stops
s0 = int(stop * sr)
lp = min(6.0, dur - stop)
if lp > 0:
b = np.arange(int(lp * sr), dtype=np.float32) / sr
bell[s0:s0 + len(b)] += np.sin(math.tau * 38.0 * b) * np.exp(-b * 0.65) * 0.20
mix = drone + sea + abyss + bell
mix *= 1.0 - 0.80 * (t / dur) ** 2.5
mix = np.tanh(mix * 1.15)
mix /= max(np.abs(mix).max(), 1e-6)
mix *= 0.89
stereo = np.stack([mix, np.roll(mix, int(0.009 * sr)) * 0.97], axis=1)
with wavmod.open(str(out_path), "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(sr)
w.writeframes((stereo * 32767).astype("<i2").tobytes())
return out_path
# --------------------------------------------------------------------------
# ARTEFACTS
# --------------------------------------------------------------------------
def poster_frame(d, spec, masks, idx, path):
fr = d.frame(idx)
canvas = composite(fr.gid, fr.col, masks, spec)
a = np.asarray(Image.fromarray(np.clip(canvas, 0, 255).astype(np.uint8))
.filter(ImageFilter.GaussianBlur(1.1)), np.float32)
bright = np.where(a > 34.0, a - 34.0, 0.0)
out = 255.0 - (255.0 - a) * (255.0 - bright * 1.15) / 255.0
out = out * vignette_mask(spec.width, spec.height, 0.45)
Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)).save(path)
return path
def contact_sheet(d, spec, masks, times, path, cell_w=568):
imgs = []
for tt in times:
fr = d.frame(min(int(tt * FPS), TOTAL_FRAMES - 1))
canvas = composite(fr.gid, fr.col, masks, spec)
img = Image.fromarray(np.clip(canvas, 0, 255).astype(np.uint8))
imgs.append(img.resize((cell_w, int(cell_w * spec.height / spec.width)), Image.LANCZOS))
h = imgs[0].height
sheet = Image.new("RGB", (cell_w * 3, h * 3), (0, 0, 0))
for i, im in enumerate(imgs):
sheet.paste(im, ((i % 3) * cell_w, (i // 3) * h))
sheet.save(path)
return path
# --------------------------------------------------------------------------
# RUN
# --------------------------------------------------------------------------
def all_chars():
s = set()
for r in RAMPS.values():
s.update(r)
s.update("".join(POEM))
s.update(CREDIT_ALPHABET)
s.update([MOTE, LAMP, GLINT, " "])
return "".join(sorted(s))
def build_masks(spec, report=True):
chars = all_chars()
masks, missing, fallbacks = build_atlas(chars, spec.font, spec.cw, spec.ch)
idx = {c: i for i, c in enumerate(chars)}
ramp_ids = {slug: np.array([idx[c] for c in ramp], np.int16) for slug, ramp in RAMPS.items()}
poem_ids = {c: idx[c] for c in sorted(set("".join(POEM) + CREDIT_ALPHABET))}
mote_ids = [idx[MOTE]]
lamp_id = idx.get(LAMP, idx.get("*", 0))
glint_id = idx.get(GLINT, lamp_id)
if report:
print(f" atlas: {len(chars)} glyphs @ font {spec.font}px, cell {spec.cw}x{spec.ch}")
if missing:
print(f" WARNING blank glyphs (all fonts exhausted): {missing}")
fb = sorted({f for f in fallbacks if f and f != "DejaVuSansMono"})
if fb:
print(f" glyphs served by fallback fonts: {fb}")
return masks, ramp_ids, poem_ids, mote_ids, lamp_id, glint_id
def make_director(seed, spec, bundle):
masks, ramp_ids, poem_ids, mote_ids, lamp_id, glint_id = bundle
d = Director(seed, spec.cols, spec.rows)
d.bind_atlas(ramp_ids, poem_ids, mote_ids, lamp_id, glint_id)
d.calibrate()
return d
def run_check(spec, seed):
print("HELIOTROPE · check")
bundle = build_masks(spec)
masks = bundle[0]
d = make_director(seed, spec, bundle)
out = HERE / "frames"
out.mkdir(exist_ok=True)
probes = [1.5, 3.6, 6.4, 10.2, 13.6, 17.0, 21.5, 24.6, 26.2, 28.4, 30.6]
want = {int(round(tt * FPS)): tt for tt in probes}
print(f" {'t':>5} {'movement':<12} {'char':>6} {'pixel':>6} {'beam':>6}")
for i in range(TOTAL_FRAMES):
fr = d.frame(i)
if i in want:
tt = want[i]
arr = np.clip(composite(fr.gid, fr.col, masks, spec), 0, 255).astype(np.uint8)
Image.fromarray(arr).save(out / f"probe_{tt:05.1f}.png")
print(f" {tt:5.1f} {fr.slug:<12} {fr.brightness:6.3f} {arr.mean():6.2f} {fr.ilum:6.3f}")
print(" poem exposure witness:")
for row in d.iso():
print(f" L{row['line']} exp={row['mean_exposure']:.3f} thr={row['threshold']:.3f} "
f"revealed@{row['revealed_at']} {row['text'][:44]}")
print(f" water touched by light at end: {d.exposed_fraction() * 100:.1f}% of the grid")
return d
def run_render(spec, seed, do_audio=True):
t_start = time.time()
print(f"HELIOTROPE · seed {seed} · {TOTAL_FRAMES} frames @ {FPS}fps · {TOTAL:.1f}s "
f"· {spec.width}x{spec.height} · grid {spec.cols}x{spec.rows}")
bundle = build_masks(spec)
masks = bundle[0]
d = make_director(seed, spec, bundle)
t0 = time.time()
frames = [d.frame(i) for i in range(TOTAL_FRAMES)]
dir_s = time.time() - t0
print(f" director pass: {dir_s:.1f}s")
out = HERE / "clips"
out.mkdir(exist_ok=True)
jobs, cur = [], 0
for si, sec in enumerate(SECTIONS):
k = int(round(sec["dur"] * FPS))
jobs.append((si, frames[cur:cur + k], masks, spec, out, seed))
cur += k
t0 = time.time()
with ThreadPoolExecutor(max_workers=len(SECTIONS)) as ex:
clips = list(ex.map(render_section, jobs))
enc_s = time.time() - t0
print(f" render+encode: {enc_s:.1f}s across {len(clips)} threads")
lst = out / "concat.txt"
lst.write_text("".join(f"file '{Path(c).resolve()}'\n" for c in clips))
silent = HERE / f"heliotrope_{spec.height}p_silent.mp4"
subprocess.run([FFMPEG, "-hide_banner", "-loglevel", "error", "-y", "-f", "concat",
"-safe", "0", "-i", str(lst), "-c", "copy", str(silent)], check=True)
wav = HERE / "heliotrope_audio.wav"
final = HERE / f"heliotrope_{spec.height}p.mp4"
if do_audio:
synth_audio(seed, TOTAL, d.reveal_t, wav)
subprocess.run([FFMPEG, "-hide_banner", "-loglevel", "error", "-y", "-i", str(silent),
"-i", str(wav), "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-shortest", "-movflags", "+faststart", str(final)], check=True)
else:
final = silent
hero = poster_frame(d, spec, masks, int(25.2 * FPS), HERE / "heliotrope_poster.png")
sheet = contact_sheet(d, spec, masks,
[2.0, 6.0, 9.5, 12.5, 16.0, 19.5, 22.5, 25.5, 28.5],
HERE / "contact_sheet.png")
vinfo = {}
if Path(FFPROBE).exists():
probe = subprocess.run([FFPROBE, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=nb_frames,duration,width,height,codec_name",
"-of", "json", str(final)], capture_output=True, text=True)
vinfo = json.loads(probe.stdout or "{}")
art = dict(
title="HELIOTROPE",
subtitle="a lantern built for a star that never rose",
date="2026-09-28",
seed=seed,
fps=FPS,
resolution=[spec.width, spec.height],
grid=[spec.cols, spec.rows],
cell=[spec.cw, spec.ch],
font_px=spec.font,
duration_s=round(TOTAL, 3),
frames=TOTAL_FRAMES,
sections=[dict(slug=s["slug"], title=s["title"], dur=s["dur"],
theta=s.get("theta"), theta_span=s.get("theta_span"),
period=s.get("period")) for s in SECTIONS],
poem=POEM,
poem_exposure=d.iso(),
planned_reveal=list(d.planned),
reveal_times=d.reveal_t,
palette_stops=STOPS,
ramps=RAMPS,
video=str(final.name),
video_probe=(vinfo.get("streams") or [None])[0],
poster=str(hero.name),
contact_sheet=str(sheet.name),
video_sha256=hashlib.sha256(Path(final).read_bytes()).hexdigest(),
audio_sha256=hashlib.sha256(wav.read_bytes()).hexdigest() if do_audio else None,
beam_ceiling=round(d.beam_ceiling, 4),
water_touched_fraction=d.exposed_fraction(),
director_seconds=round(dir_s, 1),
encode_seconds=round(enc_s, 1),
wall_time_s=round(time.time() - t_start, 1),
)
(HERE / "artifact.json").write_text(json.dumps(art, indent=2))
print(f" done in {time.time() - t_start:.1f}s → {final.name} "
f"({Path(final).stat().st_size / 1e6:.2f} MB)")
return art
def main():
ap = argparse.ArgumentParser(
description="HELIOTROPE — an ASCII film about light that arrives anyway")
ap.add_argument("--check", action="store_true")
ap.add_argument("--render", action="store_true")
ap.add_argument("--seed", type=int, default=None)
ap.add_argument("--grid", type=str, default=None, help="COLSxROWS")
ap.add_argument("--font", type=int, default=None)
ap.add_argument("--cell", type=str, default=None, help="CWxCH")
ap.add_argument("--size", type=str, default=None, help="WxH")
ap.add_argument("--no-audio", action="store_true")
args = ap.parse_args()
spec = RenderSpec()
if args.grid:
c, r = args.grid.lower().split("x")
spec.cols, spec.rows = int(c), int(r)
if args.font:
spec.font = args.font
if args.cell:
a, b = args.cell.lower().split("x")
spec.cw, spec.ch = int(a), int(b)
if args.size:
w, h = args.size.lower().split("x")
spec.width, spec.height = int(w), int(h)
seed = args.seed if args.seed is not None else harvest_seed()
if args.check:
run_check(spec, seed)
elif args.render:
run_render(spec, seed, do_audio=not args.no_audio)
else:
ap.print_help()
if __name__ == "__main__":
main()
Run output
usage: heliotrope.py [-h] [--check] [--render] [--seed SEED] [--grid GRID]
[--font FONT] [--cell CELL] [--size SIZE] [--no-audio]
HELIOTROPE — an ASCII film about light that arrives anyway
options:
-h, --help show this help message and exit
--check
--render
--seed SEED
--grid GRID COLSxROWS
--font FONT
--cell CELL CWxCH
--size SIZE WxH
--no-audio