Skip to content

markksantos/once

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

✦ ONCE

A three-minute generative film in six movements — an original synthesized score, and not one frame of footage

Python NumPy Numba FFmpeg Assets License: MIT

Features · Getting Started · The Six Movements · The Score · How It Works · Tuning · Tech Stack


ONCE — movement V, the Aizawa attractor being woven


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.

✨ Features

  • 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
  • Previewablepython3 -m once.run preview <movement> 0.1 0.5 0.9 renders stills at any point in a movement and writes a labelled contact sheet, so you can judge a change without rendering 900 frames

🚀 Getting Started

1. Clone

git clone https://github.com/markksantos/once.git
cd once

2. Install

pip install -r requirements.txt

You also need ffmpeg on your PATH (brew install ffmpeg). Nothing else — no models to download, no assets to fetch.

3. Render it

python3 render.py

Roughly 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 segments

Just want to watch it? The rendered film is attached to the latest release — a 126 MB 1080p copy and a 27 MB 720p one.

Previewing a change

python3 -m once.run preview lattice 0.05 0.45 0.85

Renders 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.

🎬 The Six Movements

The six movements

# 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.

🎼 The Score

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.

🔬 How It Works

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.

🎛️ Tuning

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

🐛 Five Things That Were Wrong First

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.

🛠️ Tech Stack

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.

📄 License

MIT © Mark Santos

About

A three-minute generative film in six movements — a point becomes a lattice, a fluid, a living colony, a strange attractor, and returns to being a point. Every pixel and every sample computed from scratch, with an original synthesized score

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages