|
| 1 | +# LFSR-16 Animation Pipeline: From Video to ZX Spectrum Intro |
| 2 | +**Date:** 2026-03-31 |
| 3 | + |
| 4 | +> Turn any MP4 into a playable LFSR-16 animation — and understand why the result is nearly incompressible by construction. |
| 5 | +
|
| 6 | +--- |
| 7 | + |
| 8 | +## What Is This? |
| 9 | + |
| 10 | +A ZX Spectrum demo intro stores a few hundred bytes and generates a recognizable image on screen using a 16-bit LFSR (Linear Feedback Shift Register). The key insight: instead of storing pixels, store the *seed* that causes the LFSR to paint the right pixels. |
| 11 | + |
| 12 | +This project extends that idea to **video**: encode each frame as a sequence of LFSR seeds, play them back in a browser, and get a recognizable animation from a tiny data stream. |
| 13 | + |
| 14 | +--- |
| 15 | + |
| 16 | +## The Pipeline |
| 17 | + |
| 18 | +``` |
| 19 | +MP4 / YouTube URL |
| 20 | + │ |
| 21 | + ▼ ffmpeg (scale 128×96, grayscale, contrast boost) |
| 22 | + │ |
| 23 | + ▼ encode_anim.py |
| 24 | + │ ├─ extract frames (--every N) |
| 25 | + │ ├─ [optional] OpenCV heatmap weights |
| 26 | + │ └─ CUDA brute-force per frame |
| 27 | + │ ├─ KEYFRAME: blk=4→2→1, shrinking area |
| 28 | + │ └─ DELTA: blk=2→1→1→1, full canvas, from prev result |
| 29 | + │ |
| 30 | + ▼ animation_flat JSON |
| 31 | + │ { type, n_frames, frame_starts[], frame_sizes[], frame_types[], seeds[] } |
| 32 | + │ |
| 33 | + ▼ docs/renderer.html (browser) |
| 34 | + LFSR-16 replay, frame-by-frame, seed list, GIF export |
| 35 | +``` |
| 36 | + |
| 37 | +--- |
| 38 | + |
| 39 | +## CUDA Search: How One Frame Is Encoded |
| 40 | + |
| 41 | +Each seed covers the canvas with a pattern of **blocks** (8×8, 4×4, 2×2, or 1×1 pixels). The LFSR-16 (poly 0xB400) with AND-N consecutive bits controls which blocks are active — AND-7 gives ~1% density, AND-3 gives ~22%. |
| 42 | + |
| 43 | +The GPU kernel (`searchKernel`) tries all 65,535 seeds simultaneously. For each seed it computes the **signed delta** against the current canvas: how many pixels would improve vs. worsen if this seed were applied. The best seed is selected and applied, then the next seed is searched on the updated canvas. |
| 44 | + |
| 45 | +**Phase schedule (delta frame, budget=256):** |
| 46 | +``` |
| 47 | +blk=2 AND-3 1 seed (coarse bounce, full canvas) |
| 48 | +blk=1 AND-4 3 seeds |
| 49 | +blk=1 AND-5 20 seeds |
| 50 | +blk=1 AND-6 50 seeds |
| 51 | +blk=1 AND-7 54 seeds ← 43% of budget here, finest detail |
| 52 | +``` |
| 53 | + |
| 54 | +**Keyframe** (first frame or scene cut) uses a shrinking-area schedule: coarse seeds scatter everywhere, fine seeds concentrate toward the image center. |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +## Carrier-Payload (CP) Delta Encoding |
| 59 | + |
| 60 | +Standard delta encoding searches the full canvas for each seed. CP introduces a **two-level hierarchy**: |
| 61 | + |
| 62 | +### Level 0: Carrier (blk=8) |
| 63 | + |
| 64 | +One seed at (0,0) with blk=8 covers the entire 128×96 canvas in 192 non-overlapping 8×8 blocks. The carrier seed is chosen to activate blocks that have the most errors — it's a coarse "error map" in one seed. |
| 65 | + |
| 66 | +``` |
| 67 | +carrier: { type:'cp', cs:3960, cx:0, cy:0, can:6, |
| 68 | + ps:[[171,0,0,4,3],[194,32,8,2,4],[199,48,72,1,5]] } |
| 69 | +``` |
| 70 | + |
| 71 | +### Level 1-3: Payloads (blk=4→2→1) |
| 72 | + |
| 73 | +Payload seeds are scored and applied **only within carrier-active zones** — pixels in 8×8 blocks that the carrier touched. This focuses the remaining budget on areas that actually need fixing. |
| 74 | + |
| 75 | +**Result:** 4 seeds total (1 carrier + 3 payloads) vs. 32 seeds in plain mode — **8× fewer seeds** for comparable quality on sparse content. |
| 76 | + |
| 77 | +### CP JSON format |
| 78 | + |
| 79 | +```json |
| 80 | +{ |
| 81 | + "type": "cp", |
| 82 | + "cs": 3960, "cx": 0, "cy": 0, "can": 6, |
| 83 | + "ps": [ |
| 84 | + [171, 0, 0, 4, 3], |
| 85 | + [194, 32, 8, 2, 4], |
| 86 | + [199, 48, 72, 1, 5] |
| 87 | + ] |
| 88 | +} |
| 89 | +``` |
| 90 | +Each payload: `[seed, ox, oy, blk, and_n]`. |
| 91 | + |
| 92 | +--- |
| 93 | + |
| 94 | +## Carrier Catalog: O(1) Carrier Search |
| 95 | + |
| 96 | +For each possible (seed, andN) pair, the LFSR generates the same carrier pattern every time. We can precompute all 65,535 × 6 patterns and store them as **192-bit bitmaps** (one bit per 8×8 block). |
| 97 | + |
| 98 | +**Build once** (236ms on RTX 4060 Ti): |
| 99 | +```bash |
| 100 | +./cuda/prng_budget_search --cp-build-catalog data/carrier_catalog.bin \ |
| 101 | + --cp-andN-lo 3 --cp-andN-hi 8 |
| 102 | +# Output: 9.4MB file, format "CPCT" + andN range + 65535×6×24 bytes |
| 103 | +``` |
| 104 | + |
| 105 | +**Query per frame** (CPU, ~2ms): |
| 106 | +1. Build 192-bit `hot_bits` mask: which 8×8 blocks have errors? |
| 107 | +2. Build `hot_px[192]`: error count per block (0-64) |
| 108 | +3. Scan all (65535 × 6) catalog entries: `score = Σ(active blocks: 64 - 2×err_count)` |
| 109 | +4. Take top-16 candidates, pixel-rescore with actual delta → best wins |
| 110 | + |
| 111 | +**Use in encoding:** |
| 112 | +```bash |
| 113 | +./cuda/prng_budget_search --cp --target frame.pgm --init-canvas prev.pgm \ |
| 114 | + --cp-catalog data/carrier_catalog.bin \ |
| 115 | + --out result.json --out-pgm result.pgm |
| 116 | +``` |
| 117 | + |
| 118 | +--- |
| 119 | + |
| 120 | +## Content Analysis: What Works |
| 121 | + |
| 122 | +| Works great | Struggles | |
| 123 | +|-------------|-----------| |
| 124 | +| Dark background, bright silhouettes | Full-frame busy content | |
| 125 | +| Slow motion, portraits | Fast cuts, camera shake | |
| 126 | +| High-contrast edges | Uniform gradients | |
| 127 | +| <10% lit pixels/frame | >30% lit pixels/frame | |
| 128 | + |
| 129 | +### Ёжик в тумане (Hedgehog in the Fog) |
| 130 | + |
| 131 | +The 1975 Soviet animated film is near-perfect content: dark silhouettes on misty background, slow movement, high contrast after a simple curves adjustment. |
| 132 | + |
| 133 | +```bash |
| 134 | +yt-dlp -f best -o /tmp/yozhik.mkv "https://www.youtube.com/watch?v=Klt8bVaycQw" |
| 135 | +ffmpeg -i /tmp/yozhik.mkv \ |
| 136 | + -vf "scale=128:96,format=gray,curves=all='0/0 0.3/0 0.6/1 1/1'" \ |
| 137 | + yozhik_contrasted.mp4 |
| 138 | +python3 cuda/encode_anim.py --input yozhik_contrasted.mp4 \ |
| 139 | + --out data/yozhik_b256.json --budget 256 --kf-budget 512 --every 5 |
| 140 | +``` |
| 141 | + |
| 142 | +Result: **104 frames, avg delta budget used = 149/256** — the algorithm stops early because the scene is so sparse. Each delta frame changes only a small fraction of the canvas. |
| 143 | + |
| 144 | +--- |
| 145 | + |
| 146 | +## Seed Stream Compressibility |
| 147 | + |
| 148 | +A critical question: can we compress the output further? |
| 149 | + |
| 150 | +### Seeds are incompressible by construction |
| 151 | + |
| 152 | +The brute-force search selects the *best* seed from 65,535 candidates. By definition, this looks like a uniform random sample — the found seeds have no predictable structure. |
| 153 | + |
| 154 | +``` |
| 155 | +budget-128: seed entropy = 11.76 bits (gzip achieves 98% of raw — useless) |
| 156 | +budget-64: seed entropy = 10.99 bits |
| 157 | +``` |
| 158 | + |
| 159 | +Delta coding makes it worse: seed[i] - seed[i-1] has even higher entropy. |
| 160 | + |
| 161 | +### Fields that do compress |
| 162 | + |
| 163 | +| Field | Entropy | gzip ratio | |
| 164 | +|-------|---------|-----------| |
| 165 | +| `and_n` | 1.6 bits | **1%** — nearly free | |
| 166 | +| `blk` | 0.06 bits | **<1%** — 99.3% are blk=1 | |
| 167 | +| `ox/8` | 3.6 bits | 47% | |
| 168 | +| `oy/8` | 3.3 bits | 42% | |
| 169 | +| `seed` | ~12 bits | **98%** — incompressible | |
| 170 | + |
| 171 | +### Optimal binary format |
| 172 | + |
| 173 | +``` |
| 174 | +4 bytes per seed: |
| 175 | + seed u16 (2 bytes) |
| 176 | + and_n-3 u3 packed } 1 byte |
| 177 | + blk_enc u2 packed } |
| 178 | + ox/8 u4 packed } 1 byte |
| 179 | + oy/8 u4 packed } |
| 180 | +``` |
| 181 | + |
| 182 | +No compression needed — the fields are already at entropy. This is the correct on-wire format for any streaming or tape-loading use case. |
| 183 | + |
| 184 | +### ZX Spectrum tape math |
| 185 | + |
| 186 | +At 1200 bps tape speed: |
| 187 | + |
| 188 | +| Encoding | Seeds/frame | Bytes/frame | Load time/frame | |
| 189 | +|----------|-------------|-------------|-----------------| |
| 190 | +| budget-256 | 250 | 1000B | **6.7 seconds** — impractical | |
| 191 | +| budget-64 | 64 | 256B | **1.7 seconds** — marginal | |
| 192 | +| CP mode | ~4 | ~16B | **0.1 seconds** — viable! | |
| 193 | + |
| 194 | +**CP mode is the only path to real-time ZX Spectrum playback.** A 4-seed CP frame (1 carrier + 3 payloads) encodes in ~16 bytes — fast enough to load and display at ~10 fps from tape. |
| 195 | + |
| 196 | +--- |
| 197 | + |
| 198 | +## Web Player |
| 199 | + |
| 200 | +Open `docs/renderer.html`. Presets include: |
| 201 | + |
| 202 | +- **Ёжик в тумане 🦔** — 104 frames, contrast-boosted, budget 256 |
| 203 | +- **Che Anima 2 🎬** — 63 frames at budget 256 and 64 |
| 204 | +- **CP variants** — carrier-payload comparison at low budget |
| 205 | +- **Lissajous 〰️** — ideal LFSR content (near-zero error) |
| 206 | + |
| 207 | +Controls: play/pause, frame scrubber, per-layer toggle, GIF export. |
| 208 | + |
| 209 | +--- |
| 210 | + |
| 211 | +## Build & Run |
| 212 | + |
| 213 | +```bash |
| 214 | +# Build CUDA search binary |
| 215 | +nvcc -O3 -o cuda/prng_budget_search cuda/prng_budget_search.cu -lm |
| 216 | + |
| 217 | +# Build carrier catalog (once, ~236ms) |
| 218 | +./cuda/prng_budget_search --cp-build-catalog data/carrier_catalog.bin |
| 219 | + |
| 220 | +# Encode any video |
| 221 | +python3 cuda/encode_anim.py \ |
| 222 | + --input your_video.mp4 \ |
| 223 | + --out data/my_anim.json \ |
| 224 | + --budget 256 --kf-budget 512 \ |
| 225 | + --every 3 \ |
| 226 | + --name "My Animation" |
| 227 | + |
| 228 | +# Encode with CP (ultra-low bitrate) |
| 229 | +python3 cuda/encode_anim.py \ |
| 230 | + --input your_video.mp4 \ |
| 231 | + --out data/my_anim_cp.json \ |
| 232 | + --budget 32 --kf-budget 128 \ |
| 233 | + --cp --cp-catalog data/carrier_catalog.bin \ |
| 234 | + --every 3 |
| 235 | +``` |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## Key Numbers |
| 240 | + |
| 241 | +| Metric | Value | |
| 242 | +|--------|-------| |
| 243 | +| Canvas | 128×96 pixels, 1-bit | |
| 244 | +| LFSR | 16-bit, poly 0xB400, 65,535 states | |
| 245 | +| Encoding speed | ~2-4s/frame on RTX 4060 Ti | |
| 246 | +| Catalog build | 236ms (9.4MB, andN 3-8) | |
| 247 | +| Catalog query | ~2ms/frame (CPU popcount) | |
| 248 | +| Best error (Che, 1194B) | **15.0%** — segmented quadtree | |
| 249 | +| Best error (Cat, 128B) | **4.9%** — dual-layer evolutionary | |
| 250 | +| CP mode overhead vs plain | **3× fewer seeds**, ~5% more error | |
| 251 | +| Tape-viable format | CP, ~16 bytes/frame | |
| 252 | + |
| 253 | +--- |
| 254 | + |
| 255 | +*Inspired by [BB](https://www.pouet.net/prod.php?which=63074) (Introspec, ZX 256b, Multimatograf 2014) and [Mona](https://www.pouet.net/prod.php?which=62917) (Ilmenit, Atari 256b).* |
0 commit comments