A three-minute generative film in six movements — an original synthesized score, and not one frame of footage
Features · Getting Started · The Six Movements · The Score · How It Works · Tuning · Tech Stack
Most generative films are a shader with a colour ramp on it, run until something pretty happens. ONCE is composed: six movements, each one growing out of the state the last one ended in, and a structure that closes. A single point of light splits into four. The four become a quasicrystal. The crystal melts into a fluid. The fluid starts to metabolise. The colony resolves into a strange attractor. The attractor falls inward, ignites, and leaves the point it started as.
Nothing in it was photographed, downloaded or sampled. There are no textures, no models, no image generators, no audio files and no fonts beyond the system one used for two words of type. Every pixel is the output of an equation evaluated in numpy, and every sample of the score is synthesized in the same process — including the arpeggio in movement V, which is not composed at all. It is the Aizawa attractor's own x(t), quantised to the scale, so the picture and the music are being driven by the same numbers at the same moment.
The film runs 2:44 at 1920×1080p30 with a stereo 48 kHz score. It regenerates from source in about fifteen minutes.
- Six movements, one continuous idea — void, lattice, flow, life, attractor, collapse. Each begins in the state the previous one ended in, and the last returns to the first
- Real simulation, not noise dressed up — 2.2 M particles on a divergence-free curl field, Gray-Scott reaction-diffusion, and 190 k particles integrating the Aizawa system. Nothing is a texture pretending to be physics
- One look across the whole film — every movement returns linear-light float32 RGB and hands off to a shared post chain: thresholded multi-scale bloom, red-biased halation, radial chromatic aberration, vignette, an ACES filmic curve, grain and dither
- A score with a spine — a harmonic-series drone on a fundamental that glides A1 → C2 → D2 → E2 and falls home to A1. Phase comes from the integral of frequency, so the glides are continuous rather than stepped
- Sonification, not decoration — movement V's arpeggio reads pitch off the attractor's trajectory. Same constants, same integrator, same numbers as the image
- Mastered, not just mixed — FFT-convolution reverb against a synthesized impulse response, a feed-forward compressor, and an explicit macro-dynamic curve so the void is genuinely quiet and the ignition genuinely loud
- Renders in parallel — six movements, six processes, ~10 minutes of wall clock on 18 cores
- Lossless intermediates — segments are FFV1 so the crossfade chain doesn't stack a second generation of compression on the first
- Previewable —
python3 -m once.run preview <movement> 0.1 0.5 0.9renders stills at any point in a movement and writes a labelled contact sheet, so you can judge a change without rendering 900 frames
1. Clone
git clone https://github.com/markksantos/once.git
cd once2. Install
pip install -r requirements.txtYou also need ffmpeg on your PATH (brew install ffmpeg). Nothing else — no models to download, no assets to fetch.
3. Render it
python3 render.pyRoughly fifteen minutes: ~10 for the picture, 4 seconds for the score, ~5 for the final encode. The film lands at out/ONCE_1080p.mp4.
Or build the pieces separately:
python3 render.py segments # picture only, six parallel processes
python3 render.py score # music only
python3 render.py assemble # re-cut from existing segmentsJust want to watch it? The rendered film is attached to the latest release — a 126 MB 1080p copy and a 27 MB 720p one.
python3 -m once.run preview lattice 0.05 0.45 0.85Renders those three points of the movement at full resolution and writes work/sheet_lattice.png. Movements that declare STATELESS = True jump straight to the frames you asked for; simulated ones have to be advanced in order.
| # | Movement | Length | What it is |
|---|---|---|---|
| I | VOID | 22 s | One analytic light source — a blown core a few pixels wide, Airy rings, four diffraction spikes — over 420 k dust motes advected on a slow curl field and lit by inverse-square falloff from the star. It destabilises, trembles, and splits: 1 → 2 → 4 |
| II | LATTICE | 26 s | The four points become a quasicrystal. Five, then seven, then nine plane waves at incommensurate headings sum to a field that never repeats; it is contoured into thin fringes with copper-hot antinodes where the interference is strongest. Then a noise warp melts it |
| III | FLOW | 30 s | The melt becomes fluid. 2.2 M particles advected on the curl of an evolving potential, accumulated into a decaying buffer so every frame carries the smear of the ones before it. A vortex opens in the second half and drains the frame |
| IV | LIFE | 34 s | Gray-Scott reaction-diffusion seeded as a single central colony that spreads outward. F and k vary across the frame, so solitons, mitosis and labyrinths are all alive in the same image. Shaded as lit relief with a specular sheen, not as a heatmap |
| V | ATTRACTOR | 30 s | 190 k particles integrating the Aizawa system, depositing at every substep so each one lays down a continuous streak. The manifold is being woven live rather than plotted. Camera orbits, tilts toward the pole, and dollies in |
| VI | COLLAPSE | 28 s | The same swarm, its own dynamics progressively giving way to radial infall, under an imploding ghost of the lattice. It compresses into a knot, ignites, and what is left is the single point from movement I |
Movements crossfade 1.2 s into each other, so the film is 164.0 s rather than 170.
Synthesized end to end in once/score.py, in about four seconds.
| Layer | Where | What |
|---|---|---|
| Drone | throughout | 14 harmonics on a gliding fundamental, each partial breathing on its own irrational period, upper partials entering as the film opens up |
| Grains | I | Sparse band-passed noise motes — the texture of the void |
| Bells | II | Inharmonic FM, struck at intervals that tighten as the crystal densifies |
| Wash | III | Broadband noise split into fixed bands with moving gains, which reads as a filter sweep without a time-varying IIR |
| Voice | IV | The only sung line in the piece — a slow pentatonic melody with a long attack |
| Arpeggio | V | The attractor's own x(t), quantised to the scale, amplitude from z |
| Sub + bell | VI | A swell into the ignition, three struck bells at the flash, and a last quiet strike under the end card |
Then FFT-convolution reverb against a synthesized IR, a feed-forward compressor, an explicit macro curve, soft clipping, and a fade at each end.
once/core.py format, linear-light colour and LUT ramps, noise, the shared
post chain, the ffmpeg encoder
once/splat.py numba kernels — bilinear deposit, field sampling, and the fused
integrate-and-deposit attractor tracer
once/m1..m6_*.py one module per movement, each exposing build(n) -> .frame(i)
once/score.py the entire score
once/run.py movement registry, segment renderer, still-preview harness
render.py orchestration: six parallel processes, then the cut
Every movement is a class with a frame(i) that returns linear-light float32 RGB — deliberately unbounded, values well above 1.0 where things are hot. All the film's character is applied afterwards in core.post(), which is the only place tone, bloom and grain are decided. That is why six completely different simulations look like one film.
Colour is never picked in sRGB. ramp() builds LUTs from hex control points converted to linear light, and apply_lut() does an interpolated lookup, so a gradient through a palette doesn't dip in luminance the way naive hex interpolation does.
Most of what is worth changing sits at the top of a movement module or in core.py:
| Knob | Where | Effect |
|---|---|---|
MOVEMENTS |
core.py |
Names and durations. The score reads the same list, so timings stay in sync automatically |
XFADE |
core.py |
Crossfade length; shortens the film by one per join |
PAL |
core.py |
Six palettes as hex control points. This is the fastest way to make it a different film |
POST |
each movement | Per-movement bloom, halation, chromatic aberration, vignette, grain, saturation |
N |
m3, m5, m6 |
Particle counts — 2.2 M, 190 k, 190 k |
DECAY |
m3, m5, m6 |
Trail persistence. Higher is silkier and slower to respond |
SW, SH |
m4 |
Reaction-diffusion grid. Pattern wavelength is fixed in cells, so this sets how large the forms read on screen |
SUB, DT |
m5, m6 |
Substeps and step size for the attractor. SUB × DT is how much phase-space time passes per frame |
seed |
every movement | A different composition of the same idea |
Kept here because each one cost real time and none of them are obvious:
- A Gaussian kernel sized at 4σ truncates the tail at 1.9σ. Invisible on ordinary images. On HDR bloom, where the range is 100:1, the cut-off draws a hard rectangular cliff around every bright source — a measured 100% step at exactly 147 px.
cv2.GaussianBlur(img, (0,0), sigma)sizes it properly. - The living region of Gray-Scott space is a narrow curved ridge, roughly
k ≈ 0.0357 + 0.667·F, not a rectangle. Varying F and k as independent gradients drops whole corners of the frame into the dead and flooded states — which is how movement IV first came out as a saturated orange mass eating half the image. - Inertial particles in an incompressible flow preferentially concentrate. Relaxing a particle's velocity toward a curl field gives it inertia, and inertia ropes the fluid into bright cords. Advecting on the streamlines keeps the density even.
- Normalising a sum of N waves by N collapses its variance toward zero by the central limit theorem, so the bright-interference term never fires and the lattice stays dim and blue. Divide by
√N. - Splatting an ODE swarm once per rendered frame throws away the path between substeps, and the attractor reads as a sparse cloud with one blown-out streak. Depositing inside the integration loop is the whole difference between a point cloud and a woven manifold.
One more, free: curl-noise particle fields bunch onto streamlines over time no matter what you do. Recycling a slice of the population every frame keeps them statistically uniform for the length of a movement.
| What | How |
|---|---|
| Simulation | NumPy, with Numba JIT for the deposit and integration kernels |
| Reaction-diffusion | 9-point Mehrstellen Laplacian via cv2.filter2D |
| Blurs and resampling | OpenCV |
| Colour | Linear-light float32 throughout, LUTs from hex control points, ACES filmic curve |
| Score | NumPy synthesis, SciPy for filtering and FFT convolution |
| Encoding | ffmpeg — FFV1 lossless intermediates, x264 CRF 16 master, AAC 320k |
| Assets | None |
Built with Claude Code. I directed and reviewed; the AI did the typing.
MIT © Mark Santos

