# Scroll-Video-Experiment „Kirchenbegehung" — Dokumentation

Stand: 28.07.2026 · Projekt: Kirchenportal (Inhalte lagen bis Juli 2026 im Unterordner `ExperimentVideo/`)

## 1. Ziel

Eine scrollgesteuerte, filmische Begehung einer Kirche: Die Kamera fliegt beim Scrollen
vom Himmel über die Fassade durch das Portal bis vor den Altar, hält unterwegs für
Text-Einblendungen an und endet an einer **Weggabelung** — der Besucher entscheidet per
Klick oder Cursor-Position, ob es links herum (Kerzenstand), rechts herum (Psalter-Lesepult)
oder geradeaus zum Altar weitergeht.

Kernfragen des Experiments:

1. Wie bekommt man Video-Scrubbing per Scroll **flüssig** (Vorbild: terminal-industries.com)?
2. Wie baut man eine **Verzweigung** in eine lineare Scroll-Animation?
3. Welche **Qualität/Dateigröße** ist erreichbar bzw. produktionstauglich?

## 2. Ausgangsmaterial

KI-generierte Clips (Google Veo), alle 2048 × 1152 px, 30 fps, H.264:

| Datei | Inhalt | Länge |
|---|---|---|
| `assets/video/Start.mp4` | Himmel → Fassade → Portal → Innenraum bis vor den Altar | 20,77 s |
| `assets/video/L.mp4` | Abzweig links → Kerzenstand | 5,8 s |
| `assets/video/R.mp4` | Abzweig rechts → Psalter an der Wand | 5,5 s |
| `assets/video/End.mp4` | Geradeaus → Altar mit aufgeschlagener Bibel | 4,8 s |

Wichtig: Der letzte Frame von `Start.mp4` ist identisch mit dem ersten Frame aller drei
Abzweige — dadurch ist die Verzweigung nahtlos.

## 3. Versionshistorie

| Version | Technik | Erkenntnis |
|---|---|---|
| **V1** `archive/v1-scrub.html` | `<video>`, GSAP setzt `currentTime` direkt aus Scrollfortschritt | Funktioniert, aber ruckelig — Decoder-Seeks sind teuer |
| **V2** `archive/v2-canvas.html` | 150 JPG-Frames auf Canvas | Am flüssigsten — der Ansatz, den Terminal Industries perfektioniert hat |
| **V3** `archive/v3-smooth.html` | Video + Lerp-Glättung des `currentTime` | Glättung hilft, löst das Seek-Problem aber nicht |
| **V4** `archive/v4-stops.html` | Ein Video, drei Text-Stopps (GSAP-Timeline mit Scrub-Legs) | Grundstruktur für Timeline + Text-Unterbrechungen |
| **V5** `v5-video.html` | Neues Veo-Material, Verzweigung, Seeking-Guard, Lenis | Bestes Video-basiertes Ergebnis |
| **V6** `v6-canvas.html` | Canvas-Frame-Sequenz nach Terminal-Methode, 60 fps interpoliert, Lenis | Bestes Gesamtergebnis, aber schwerstes Asset-Paket |

V5 und V6 existieren jeweils in zwei Qualitätsstufen (`?q=web`), Vergleich unter `compare.html`.

## 4. Referenz-Analysen

### 4.1 terminal-industries.com (Hauptreferenz)

Die Scroll-Animation ist **kein Video**, sondern eine **WebP-Einzelbild-Sequenz auf einem
2D-Canvas**. Aus den JS-Bundles und dem Netzwerkverkehr gemessen:

| Parameter | Wert |
|---|---|
| Frames Desktop | 410 Stück, `hero_anim_desktop_60_{index}.webp`, **1920 × 1080** |
| Frames Mobile | 409 Stück, eigene Sequenz **810 × 1440** (Hochformat!) |
| Framerate | 60 fps Prerender (≈ 6,8 s Animation) |
| Frame-Größen | 9–132 KB je nach Motiv, Ø ~50–60 KB, gesamt ~20–25 MB |
| Scroll-Dichte | ~7–10 px Scroll pro Frame (Sektion 4257 px bei 1419 px Viewport) |
| Loader | Web Worker fetcht Blobs in 8er-Batches alle 50 ms; Frame 0 sofort; Main-Thread macht `Image`-Objekte daraus |
| Zeichnen | Canvas 2D, `drawImage` mit object-fit-cover-Mathematik, DPR-skaliert; Redraw nur bei Frame-Wechsel |
| Scroll-Glättung | **Lenis mit Standard-Settings** (`lerp: 0.1`, smoothWheel) — nichts überschrieben |
| ScrollTrigger | `scrub: true` (hart verlinkt — die Glättung passiert im Scroll, nicht im Bild) |
| Sonstiges | Nuxt, Storyblok-CDN für die Loop-MP4s an anderen Stellen der Seite |

**Warum das so flüssig ist:** Es gibt keine Decoder-Seeks. Jeder Frame ist ein fertiges
Bild; `drawImage` kostet praktisch nichts, funktioniert rückwärts wie vorwärts und kann
bei schnellem Scrollen beliebig Frames überspringen.

### 4.2 Inspirations-Posts (X/Twitter)

Die beiden Ausgangs-Links des Experiments (ursprünglich als `scroll1.webloc` /
`scroll2.webloc` gespeichert; die Dateien sind gelöscht, die URLs hier festgehalten):

- **scroll1** — [@mikenevermiss](https://x.com/mikenevermiss/status/2076603705323892815):
  Video-Post „Claude Fable 5 is a beast for web design" mit Guide zum Bauen interaktiver,
  animierter 3D-Websites mit Claude Code (Prompts in den Kommentaren).
- **scroll2** — [@zeuuss_01](https://x.com/zeuuss_01/status/2067204840342630789):
  X-Article **„The $35K Motion-Website Playbook with Higgsfield + Claude Code"** —
  Playbook für Motion-Websites aus KI-generiertem Video; mutmaßlich die Quelle des
  Seeking-Guard-Screenshots aus 4.3. Der Workflow dieses Playbooks (KI-Video generieren
  → scrollgesteuert abspielen) entspricht dem Ansatz dieses Experiments.

### 4.3 Video-Scrubbing-Technik (Screenshot-Referenz)

Ein separat gefundenes Muster für Video-basiertes Scrubbing (rAF-Loop):

```js
if (!video.seeking && Math.abs(video.currentTime - currentTime) > 0.01) {
  video.currentTime = currentTime;
}
// plus: currentTime += (targetTime - currentTime) * 0.08  (Lerp)
```

Bewertung: korrekt und sinnvoll. Der **Seeking-Guard** verhindert, dass sich Seek-Requests
im Decoder stauen (schwarze Frames, Ruckeln, v. a. Safari). Die Lerp-Glättung entspricht
GSAPs `scrub`-Smoothing bzw. Lenis — nur framerate-abhängig statt zeitbasiert.
In V5 übernommen: GSAP tweent nur noch eine Zielzeit, ein Ticker überträgt sie mit Guard
auf das Video.

## 5. Techniken im Detail

### 5.1 V5 — Video-Scrubbing

- **All-Intra-Encoding ist Pflicht.** Die Veo-Originale haben ~1 Keyframe/Sekunde — damit
  ist Scrubbing unbrauchbar (jeder Seek muss vom letzten Keyframe dekodieren). Re-Encode
  mit Keyframe auf jedem Frame:
  ```
  ffmpeg -i in.mp4 -c:v libx264 -preset medium -crf 19 -g 1 -pix_fmt yuv420p -an -movflags +faststart out.mp4
  ```
  Kostet Dateigröße (Start: 100 MB bei 2048 px/crf 19), macht Seeks aber ~instant.
- **Seeking-Guard** (siehe 4.2): `currentTime` nur setzen, wenn `!video.seeking`.
- **Vier `<video>`-Elemente** (Start + 3 Abzweige) übereinander, alle vorgeladen;
  Umschalten per Opacity — kein `src`-Wechsel, kein Nachladen an der Gabelung.
- **Server-Anforderung:** Chrome lädt `<video>` nur von Servern mit HTTP-Range-Support
  (206). Pythons `http.server` kann das nicht → Video bleibt schwarz. Geeignet:
  `npx http-server`, nginx (Herd), jeder echte Webserver.

### 5.2 V6 — Canvas-Frame-Sequenz (Terminal-Methode)

- **Frame-Pipeline (beste Qualität):** Video → verlustfrei PNG → WebP. *Niemals* über ein
  verlustbehaftetes Zwischenformat (unser erster Versuch Video→JPEG→WebP erzeugte
  sichtbare Doppelkompressions-Artefakte). Immer in nativer Quellauflösung bleiben —
  Runterskalieren + Retina-Hochskalieren macht alles weich.
- **60 fps durch Motion-Interpolation.** Die Quelle hat 30 fps; `ffmpeg minterpolate`
  (`mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1`) berechnet echte Zwischenbilder.
  Bei langsamen, gleichmäßigen Kamerafahrten artefaktfrei. Ergebnis: halbe Sprunggröße
  zwischen Frames → deutlich weicheres Ausrollen am Scroll-Ende.
- **Frame-Dichte ist die Kennzahl fürs Scroll-Gefühl:** Ziel ~7–10 px Scroll pro Frame
  (Terminal-Niveau). Wichtig: *Scroll-Empfindlichkeit erhöhen ersetzt keine Frames* —
  die Sprunggröße zwischen zwei Frames ist vom Material vorgegeben; nur echte
  Zwischenbilder verkleinern sie.
- **Worker-Loader, verbessert:** Terminal pumpt Batches per `setTimeout(50)` — das wird
  in Hintergrund-Tabs auf 1/s gedrosselt. Unsere Version schickt das nächste 8er-Häppchen,
  sobald das vorige zurück ist (worker-getrieben, natürliche Backpressure): 2.200 Frames
  in < 10 s, auch im Hintergrund-Tab.
- **`Image` statt `ImageBitmap`:** Blobs → Object-URL → `Image`. Der Browser dekodiert
  lazy und verwaltet den Decode-Cache selbst. 2.200 ImageBitmaps würden ~9 GB dekodierten
  Speicher pinnen.
- **Robustheit:** Fehlt ein Frame noch, wird der nächstliegende geladene gezeichnet;
  Redraw nur bei Frame-Index-Wechsel; Cover-Fit + `devicePixelRatio`-Skalierung;
  Frame-URLs versioniert (`?v=…`) gegen Browser-Cache-Reste.
- **Achtung Blob-Worker:** relative fetch-URLs lösen gegen die Blob-URL auf → immer
  absolute URLs an den Worker geben.

### 5.3 Lenis (beide Varianten)

```js
const lenis = new Lenis({ lerp: 0.1, autoRaf: false });
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
```

Glättet die ruckartigen Mausrad-Deltas zu einer kontinuierlichen Kurve — die Glättung
gehört in den Scroll, nicht ins Bildmaterial. ScrollTrigger dann hart (`scrub: true`,
V5: `0.2`). Terminal fährt exakt die Lenis-Defaults. Programmatisches Springen immer über
`lenis.scrollTo(…)`, nicht `window.scrollTo` (läuft sonst auseinander).

### 5.4 Verzweigung

- Eine einzige Sticky-Stage, eine GSAP-Timeline: Scrub-Leg(s) → Text-Stopp → Leg →
  Weggabelungs-Overlay (einblenden, halten, ausblenden) → Branch-Segment → Nachlauf.
- Das Branch-Segment tweent nur einen Proxy (`{p: 0→1}`); welches Material gezeigt wird,
  entscheidet die Wahl zur Laufzeit — so bleibt die statische Timeline mit dynamischem
  Inhalt kompatibel.
- **Wahl:** Klick auf eine der drei Zonen (fährt den Pfad automatisch per
  `lenis.scrollTo` ab) *oder* einfach weiterscrollen — dann entscheidet die
  Cursor-Position (linkes/mittleres/rechtes Bildschirmdrittel), Touch-Fallback: geradeaus.
- Zurückscrollen zur Gabelung erlaubt Umentscheiden; die Zonen sind nur klickbar, solange
  das Overlay sichtbar ist.
- Pro Pfad ein eigener Abschlusstext, der auf den letzten ~18 % des Abzweigs einblendet.

## 6. Gelernte Lektionen (Fallstricke)

1. **Range-Requests:** `python3 -m http.server` scheitert an `<video>` (kein 206) —
   Symptom: Video lädt nie, `readyState 0`, keine Fehlermeldung.
2. **Doppelkompression:** Nie über verlustbehaftete Zwischenformate gehen. Erste
   V6-Fassung (JPEG q12 → WebP) sah deutlich schlechter aus als nötig.
3. **Auflösung:** Immer native Quellauflösung durchreichen. 1600 px auf einem
   Retina-Viewport (3000+ effektive Pixel) wird sichtbar weich.
4. **Framerate ≠ Bildqualität:** Mehr Frames machen Bewegung feiner (v. a. das Ausrollen
   am Scroll-Ende), nicht das Einzelbild schärfer. Beides sind getrennte Stellschrauben.
5. **Browser-Cache:** Nach Asset-Austausch bei gleichem Dateinamen liefert Chrome bis zu
   1 h alte Dateien. Lösung: Versions-Query an Asset-URLs und/oder Server ohne Caching
   (`http-server -c-1`).
6. **Hintergrund-Tabs drosseln alles:** `requestAnimationFrame` pausiert, `setTimeout`
   wird auf ≥ 1 s geklemmt, große Medien-Loads werden aufgeschoben. Betrifft Ladelogik
   (→ worker-getriebener Loader) und automatisierte Tests.
7. **Veo-Materialplanung:** Dass alle Abzweige exakt auf dem Endframe des Hauptclips
   starten, ist die Voraussetzung für die nahtlose Gabelung — beim Generieren neuer
   Clips unbedingt beibehalten.

## 7. Qualitätsstufen & Gewichte

| Konfiguration | Auflösung | Kompression | fps | Gewicht |
|---|---|---|---|---|
| V5 · HQ | 2048 px | H.264 all-intra crf 19 | 30 | ~166 MB |
| V5 · Web (`?q=web`) | 1600 px | crf 22 | 30 | ~86 MB |
| V6 · HQ | 2048 px | WebP q80 | 60 (interpoliert) | ~479 MB |
| V6 · Web (`?q=web`) | 1600 px | WebP q60 | 60 (interpoliert) | ~230 MB |

(2.200 Frames gesamt: Start 1243, L 345, R 329, End 283 — `assets/frames-hq/` bzw. `assets/frames-web/`.)

## 8. V5 vs. V6 — Vor- und Nachteile

| | **V5 Video-Scrubbing** | **V6 Canvas-Sequenz** |
|---|---|---|
| Scroll-Gefühl | gut (mit Guard + Lenis) | am besten; keine Seek-Latenz, rückwärts identisch |
| Framerate | an Quelle gebunden (30 fps) | frei (Interpolation auf 60 fps) |
| Gewicht | **deutlich kleiner** (H.264 nutzt zeitliche Redundanz nicht — aber Intra-Kompression ist immer noch effizienter als Einzelbilder) | 1,5–3× schwerer bei gleicher Qualität |
| Start-Verhalten | Browser streamt; abspielbar bevor alles geladen ist | Loader nötig; Fallback auf nächstliegenden Frame überbrückt |
| Speicher (RAM) | gering (Decoder-Puffer) | Blobs komplett im Speicher; Decode-Cache beim Browser |
| Implementierung | einfacher (ein `<video>`-Element pro Clip) | mehr Eigenbau (Loader, Worker, Draw-Loop) |
| Safari/Mobile | Seeks träger; braucht den Guard | unkritisch (nur Bilder), aber Datenvolumen! |
| Skalierung auf lange Sequenzen | gut | Gewicht wächst linear mit Sekunden × fps |

**Faustregel:** V6 für kurze, prominente Hero-Sequenzen (Terminal animiert nur 6,8 s!),
V5 wenn die Sequenz lang ist oder Datenvolumen zählt.

## 9. Alternativen (nicht oder noch nicht umgesetzt)

- **WebCodecs (`VideoDecoder`):** MP4 herunterladen, selbst dekodieren, Frames als
  Bitmaps cachen und aufs Canvas zeichnen — kombiniert Video-Dateigröße mit
  Frame-Sequenz-Flüssigkeit. Aufwendig (Demuxing via mp4box.js), aber der modernste Weg;
  Kandidat für ein V7.
- **AVIF statt WebP:** ~30–50 % kleiner bei gleicher Qualität; Encoding deutlich
  langsamer. Für Produktion klar empfohlen.
- **CSS Scroll-Driven Animations** (`animation-timeline: scroll()`): nativ, ohne JS —
  kann aber kein `video.currentTime` und keine Canvas-Frames treiben; nur für die
  Text-Overlays denkbar.
- **4K-Upscale der Quelle** (Topaz Video AI, Real-ESRGAN) vor der Frame-Extraktion:
  einziger Weg über die 2048-px-Grenze des Veo-Materials hinaus.
- **KI-Interpolation (RIFE/Topaz)** statt ffmpeg `minterpolate`: bessere Zwischenbilder
  bei komplexer Bewegung; bei unseren ruhigen Fahrten war ffmpeg ausreichend.
- **Mobile-Sequenz im Hochformat** (Terminal: 810 × 1440): eigener Bildausschnitt statt
  nur kleinerer Desktop-Frames — für Produktion sinnvoll.
- **`requestVideoFrameCallback`:** präzisere Frame-Anzeige beim Video-Ansatz; ersetzt
  aber keine Seeks.
- **Frame-Sprites/Mosaike:** weniger HTTP-Requests; mit HTTP/2 + Worker-Batching
  praktisch ohne Vorteil, dafür Speichernachteile.

## 10. Empfehlung für die Produktion (Kirchenportal)

1. **Technik: V6-Ansatz** (Canvas-Sequenz) für die Begehung — das Scroll-Gefühl ist der
   Kern des Erlebnisses.
2. **Gewicht drücken:** Sequenz straffen (kürzere Kamerafahrt), AVIF, q-Stufe je Motiv,
   Mobile-Sequenz, Lazy-Load der Abzweige erst an der Gabelung (Start-Sequenz zuerst).
   Realistisches Ziel: 25–50 MB Desktop.
3. **Ausliefern über CDN** mit langen Cache-Zeiten + Versionierung im Pfad.
4. **Beibehalten:** Lenis-Defaults, worker-getriebener Loader, Seeking-Guard-Pattern
   (falls doch Video), Frame-Dichte ~8–10 px/Frame, nahtlose Gabelung über identische
   Übergangs-Frames.

## 11. Dateien & Starten

```
index.html              Übersicht aller Varianten
compare.html            Vorgelagerte Vergleichsseite (2 Panes, 4 Konfigurationen)
v5-video.html           Video-Scrubbing + Verzweigung   (?q=web für Web-Stufe)
v6-canvas.html          Canvas-Sequenz + Verzweigung    (?q=web für Web-Stufe)
archive/                Historische Prototypen V1–V4
assets/video/           Veo-Quellclips + *-scrub.mp4 / *-scrub-web.mp4 (all-intra)
assets/video-legacy/    Scrub-Videos der Prototypen (V1, V3, V4)
assets/frames-legacy/   150 JPG-Frames (V2)
assets/frames-hq/       2.200 WebP-Frames HQ  (2048 px, q80, 60 fps)
assets/frames-web/      2.200 WebP-Frames Web (1600 px, q60, 60 fps)
assets/images/          Konzeptbilder der Anfangsphase (im Git)
ASSETS.md               Download-Hinweis: Medien liegen auf dem File-Server
```

Frühere Namen (bis 28.07.2026): `v5-branch.html`/`v6-frames.html`, `Video/`, `Video2/`,
`frames/`, `frames6/`, `frames6web/`, `Images/`.

Lokal starten: Projekt über **Herd** ausliefern (nginx, Range-Support vorhanden) oder
`npx http-server -p 8123 -c-1`. Wichtig: kein `python3 -m http.server` (siehe 6.1).

Asset-Rebuild-Skripte (Interpolation, WebP-Konvertierung, Web-Stufe) liegen als
ffmpeg/cwebp-Aufrufe in dieser Doku (Abschnitt 5) — die konkreten Parameter sind dort
vollständig angegeben.
