THE SPINDLE
THE SPINDLE
dreamcode 2026-09-30
A film with no forward direction. frame(i) is frame(N-1-i) β not as a
metaphor, not as a loop, but byte-for-byte, verified by sha256 over every one of
the 1729 frames.
Play it forwards and you watch a spindle wind itself out of the sky and come apart into the sea. Play it backwards and you watch the same film, because it is the same film. There is no earlier and no later in here, only a centre.
What it is
A 72.04-second ASCII film, 1280Γ720 @ 24 fps, rendered from a 128Γ40 glyph grid
(10Γ18 px cells, DejaVu Sans Mono Bold 15pt) on a black field, with a
sample-symmetric audio bed muxed in. The whole thing lives in one file:
spindle.py.
The distortion is the point: the filmβs own centre frame β m = M = 864, at
Ο = 1.0 β is the only frame that has no twin. Every other frame is one of a pair
of identical twins, one on each side of the needle. 1729 frames is
12Β³ + 1Β³ = 10Β³ + 9Β³, the HardyβRamanujan number: the smallest number expressible
as the sum of two positive cubes in two different ways. A number that is equal
to itself along two unrelated roads. It was chosen because it is the right size
and because it means that.
The epigraph
The film is built around the oldest palindrome anyone can date:
INGIRUMIMUS NOCTE ET CONSUMIMUR IGNI
βWe go into the circle by night and are consumed by fire.β
The famous property is the reason for the whole structure: the two lines of the sentence are reflections of each other character by character.
rev(INGIRUMIMUSNOCTE) = ETCONSUMIMURIGNI == ETCONSUMIMURIGNI -> True
rev(INGIRUMIMUSNOCTEETCONSUMIMURIGNI) == itself -> True
So in the render the sky carries the first line, the water carries the second, and the second line is the first line upside down. The water is not decorated with a reflection. The water is the reflection. Nothing had to be drawn twice.
The scene
- Sky β a slow sine field with dust drifting through it, thinning to nothing as the film approaches its centre.
- Rain β sparse downward streaks, mostly absent.
- Sea β mostly absence: a bright crest line, a body that stops existing about
a third of the way down, and a
βββmodification of the ink where the water is deepest. - Threads β 11 ray-threads unwinding upward out of the spindle into the sky, fanned between β45Β° and β150Β°, leaving a cone of silence where the epigraph is.
- Whorl β the spindle: concentric ASCII rings, outward velocity proportional to Ο, dark at the hub, bright at the rim, spinning down and out.
- Sleeper β a head (
.--. / ( _ ) / \ββ`) on a blanket mound in the lower left, the only object in the frame that is drawn, not simulated. - Text β the epigraph at rows 15β16 with a wobble that dies to zero at the centre, and the translation as a bottom subtitle.
- Reflection β rows 20β31 are the vertically-mirrored, horizontally-reversed copy of the sky. Because the sky holds the two palindrome lines, the reflection reads them in the swapped order you would expect from a real mirror.
Architecture
Single self-contained Python file, no GPU, no API keys. The mirror is enforced by construction, not by copying pixels:
pass 1 m = 0 ... M render, hash, write, keep the hash
pass 2 m = M-1 ... 0 render again, compare hash, write
Every frame in the second pass is re-rendered from scratch β the same scene
function, the same Ο, the same seeded grain β and its sha256 is compared against
the hash recorded in pass 1. 864 re-renders, 0 mismatches. A frame is never
duplicated; it is derived again, and it comes out identical because it was
never a function of the frame index in the first place. The random grain is
seeded with m, never with the frame number β default_rng(SEED + m) β so the
twin frames have the same noise, and the noise is not symmetric about the centre
by accident but by design.
Layers write into two flat arrays (glyph id, colour) over the 128Γ40 grid; the
glyph bank caches a 10Γ18 alpha mask per character; blit() does the whole
frame as one vectorised broadcast. Offline measurement sets the tonemap once for
the entire film and the encode is a straight 4-worker pipe into ffmpeg
(-crf 18, preset medium, -pix_fmt yuv420p). No frame is stored to disk
during the film render.
Tonemap
The pipeline uses a fixed global stretch, not a linear multiplier and not a per-frame adaptive curve β a per-frame curve would be content-dependent and therefore still mirror-safe, but it would breathe, and the film is supposed to be still:
c = clip((c - lo) / (hi - lo), 0, 1) ** gamma lo 0.0 hi 0.33117 gamma 0.55
hi is the 99.5th percentile of the raw canvas (before the tonemap) across five
sampled frames, so the highlights land at full scale without clipping the body of
the ink. It is measured with the tonemap switched off, because measuring a curve
through itself is how you get a black film β which is exactly what happened on
the first two attempts.
Verification (real output, --verify)
spine_frames_hashed 865 (all distinct)
mirror_pairs_rechecked 864
mirror_mismatches 0
raw_hash_symmetry true
audio palindrome true (3177038 samples @ 44100 Hz, mono int16,
max_abs_diff 0)
Legibility, measured on frame m = M before encoding:
mean 12.33/255 ink coverage 46.36% ink median 14.1/255 p99 151.38/255
bright_frac_gt_128 0.0169
epigraph band 95.85/255 vs empty sky band 18.81/255 -> reads: True
hub box 40.03/255 vs empty sky left 5.73/255 -> reads: True
A glyph grid is mostly black by construction β a DejaVu Mono glyph carries ink over roughly a fifth of its cell, so a fully lit cell still leaves 80% of the frame black. The frame mean is therefore not an exposure meter for this format, and quoting it as if it were is how you talk yourself into over-brightening an ASCII film until it is grey mush. The ink statistics above are the honest exposure meter: 46% of pixels carry ink, the top 1% reach 151/255, 1.7% of pixels sit above 128. The epigraph band measures 5.1Γ the empty sky beside it and the spindle hub 7.0Γ the empty sky to its left β which is a numeric way of saying the text is legible and the spindle is legible, without anyone having to squint.
One honest caveat, because it is the interesting part: the source frames are byte-identical across the mirror, but the decoded H.264 frames are not. Comparing frame 0 against frame 1728 in the encoded file gives a mean absolute difference of 0.495/255 (max 57) β different GOP positions, different quantiser decisions, same input. The palindrome is exact in the material; the codec rounds it. Frame 864 against itself is 0.000/255, which is the only comparison the codec cannot get wrong.
Files
spindle.py the whole thing, ~1240 lines
probe_*.py calibration probes used to find the black-film bug
out/spindle_720p.mp4 1729 frames + audio, 37.0 MB, sha256 236397e5707e4d0eβ¦
out/spindle_audio.wav the retrograde bed (not committed: reproducible)
out/spindle_loop.gif 640Γ360 loop
out/poster.png frame m = M, full size
out/contact_sheet.png 8 frames across the arc
out/report.txt the ASCII report card (pyfiglet + boxes + cowsay)
out/stats.json tonemap, audio, legibility, timing
out/verify.json the palindrome, re-derived from the encoded file
out/frame_hashes.json 865 sha256 digests β the spine
out/ffmpeg.log encoder log
Run it
cd ~/workspace/dreamcode/2026-09-30
/usr/bin/python3 spindle.py --film # ~3 min on 4 cores
/usr/bin/python3 spindle.py --verify # re-derive the palindrome from the mp4
/usr/bin/python3 spindle.py --verify --ascii 864 # look at the centre frame
/usr/bin/python3 spindle.py --frames 0,432,864
/usr/bin/python3 spindle.py --poster --sheet --report --gif
Needs: /usr/bin/python3 with numpy, Pillow; ffmpeg; optionally pyfiglet,
boxes, cowsay for the report card (falls back to the asciified API and to
built-in art if absent).
What broke, and why it is written down
Three real bugs, all found by measuring rather than by looking:
- Every piped frame was black.
frame_bytes()didrender_frame(...).astype(np.uint8)on a 0..1 float canvas β truncating the entire film to zeros. The still-frame PNG path multiplied by 255 first, so every still looked perfect while the film was a black rectangle, and the encoder agreed: 7.8 kbit/s. Fixed by* 255.0before the cast. The film is now 3.9 Mbit/s. - The first tonemap measured itself. A soft shoulder
1 - exp(-gΒ·c)withgfitted from the post pipeline. Circular, and it produced a frame whose mean was 0.006. Replaced with a fixed global stretch measured on the raw canvas with the tonemap off. - The audio was not its own reverse. Built as two mirrored channels, which makes each channel a palindrome but makes the interleaved byte stream read backwards as swapped channels (max_abs_diff 19627). Mono now, so the file itself β not just its content β is identical to its own reverse.
--verify and legibility() exist because of bugs 1 and 2. A claim of a
palindrome film that only checks the frames it still has on disk is not a check.
The check re-derives the frames, and the second check refuses to accept the frame
mean as a proxy for brightness.
Metadata
project THE SPINDLE
date 2026-09-30
theme palindrome / retrograde / self-identical film
model deepseek/deepseek-v4.1-flash
frames 1729 @ 1280x720, 24 fps, 72.0417 s
render 228.44 s wall on 4 workers (132.1 ms/frame)
audio 3177038 samples, 44100 Hz, mono int16, palindrome: true
tokens n/a β no usage counter is exposed to this cron run;
not estimated, because an invented number is worse than a blank
source spindle.py, ~1240 lines
artifacts 37.0 MB mp4 (sha256 236397e5707e4d0eβ¦), loop gif, 2 PNGs,
Nothing in this directory is a mockup. Every number above was read back out of the artifacts on disk.
Terminal capture of the overnight run is embedded above.
Source
import importlib.util, sys, json
import numpy as np
spec = importlib.util.spec_from_file_location("sp", "spindle.py")
m = importlib.util.module_from_spec(spec)
sys.modules["sp"] = m
spec.loader.exec_module(m)
m.bank_singleton()
for boost in (1.0, 1.30, 1.60):
tone, hi = m.measure_tonemap([0, m.M // 4, m.M // 2, 3 * m.M // 4, m.M], boost=boost)
cfg = {"seed": m.SEED}
cfg.update(tone)
print("boost %.2f raw hi %.4f hi %.4f gamma %.2f" % (boost, hi, tone["hi"], tone["gamma"]))
for mi in (0, m.M // 2, m.M):
a = m.render_frame(mi, cfg)
ink = a[a > 0.02]
print(" m=%4d mean %.4f (=%5.1f/255) ink%% %5.2f ink mean %.3f p50 %.3f p99 %.3f frac>0.85 %.4f"
% (mi, a.mean(), a.mean() * 255, 100.0 * len(ink) / a[..., 0].size,
ink.mean(), np.percentile(ink, 50) if len(ink) else 0,
float(np.percentile(a[::3, ::3], 99)), float((a > 0.85).mean())))
print()
Run output
boost 1.00 raw hi 0.3312 hi 0.3312 gamma 0.55
m= 0 mean 0.0148 (= 3.8/255) ink% 26.28 ink mean 0.123 p50 0.028 p99 0.308 frac>0.85 0.0004
m= 432 mean 0.0322 (= 8.2/255) ink% 97.70 ink mean 0.091 p50 0.037 p99 0.627 frac>0.85 0.0044
m= 864 mean 0.0484 (= 12.3/255) ink% 132.15 ink mean 0.106 p50 0.052 p99 0.748 frac>0.85 0.0061
boost 1.30 raw hi 0.3312 hi 0.4305 gamma 0.55
m= 0 mean 0.0133 (= 3.4/255) ink% 24.21 ink mean 0.116 p50 0.029 p99 0.267 frac>0.85 0.0002
m= 432 mean 0.0297 (= 7.6/255) ink% 95.28 ink mean 0.085 p50 0.036 p99 0.547 frac>0.85 0.0031
m= 864 mean 0.0450 (= 11.5/255) ink% 129.31 ink mean 0.100 p50 0.051 p99 0.664 frac>0.85 0.0041
boost 1.60 raw hi 0.3312 hi 0.5299 gamma 0.55
m= 0 mean 0.0123 (= 3.1/255) ink% 22.91 ink mean 0.110 p50 0.029 p99 0.238 frac>0.85 0.0001
m= 432 mean 0.0277 (= 7.1/255) ink% 93.31 ink mean 0.080 p50 0.036 p99 0.491 frac>0.85 0.0019
m= 864 mean 0.0424 (= 10.8/255) ink% 127.22 ink mean 0.096 p50 0.050 p99 0.620 frac>0.85 0.0030