Zum Inhalt springen
Alle Artikel
6 Min. Lesezeit

Bad Apple!! als ASCII-Animation im Terminal

Wie ich das berühmte Schattenspiel-Video mit 6.573 Frames in 1,4 MB Text gepackt habe – und warum am Ende der Ton den Takt angibt.

#terminal#animation#performance

Auf dieser Seite gibt es ein Terminal. Drück ~ (auf deutschen Tastaturen ^), und von oben fährt eine kleine Konsole herein. Seit Kurzem kennt sie einen Befehl, der in keinem ernstzunehmenden Terminal fehlen darf: badapple.

Bad Apple!! ist ein Schattenspiel-Musikvideo aus dem Touhou-Universum – komplett schwarz-weiß, 3:39 Minuten lang. Weil es nur aus Silhouetten besteht, ist es seit Jahren das Testbild für alles, was irgendwie Bilder anzeigen kann: Oszilloskope, Taschenrechner, Minecraft-Redstone, Excel. Ein ASCII-Terminal im Browser war also Pflicht.

Die Regeln, die ich mir gesetzt habe:

  • Es muss flüssig laufen – volle 30 Bilder pro Sekunde.
  • Die Seite darf nicht schwerer werden. Wer den Befehl nie eintippt, lädt kein einziges Byte davon.
  • Es muss im hellen und im dunklen Theme richtig aussehen.

Vom Video zu Graustufen

Den Anfang macht ffmpeg. Das Video wird auf die Zielgröße heruntergerechnet und als rohe Graustufen ausgegeben – ein Byte pro Pixel, Frame für Frame hintereinander:

bash
ffmpeg -i bad-apple.mp4 \
  -vf "scale=96:43:flags=area,format=gray" \
  -f rawvideo -pix_fmt gray frames.gray

Warum ausgerechnet 96 × 43? Ein Zeichen in einer Monospace-Schrift ist ungefähr doppelt so hoch wie breit. Geist Mono hat eine Breite von 0,6 em, bei einer Zeilenhöhe von 1 ergibt das:

96 Zeichen × 0,6 / 43 Zeilen ≈ 1,34  →  passt zu 4:3

Jedes Pixel bekommt eine von acht Helligkeitsstufen, und jede Stufe ein Zeichen – von leer bis voll:

" .:-=+*#"

Acht Stufen statt nur Schwarz und Weiß machen den Unterschied: Die Kanten der Silhouetten werden weich, statt zu flimmern.

27 MB sind zu viel

Rechnen wir kurz nach: 96 × 43 = 4.128 Zeichen pro Frame, mal 6.573 Frames – das sind 27 MB. Für ein Easter Egg ein bisschen viel.

Die gute Nachricht: Bad Apple ist ein extrem dankbares Video. Der Hintergrund steht fast immer still, nur die Silhouetten bewegen sich. Also speichere ich pro Frame nur, was sich gegenüber dem vorherigen geändert hat:

[n unverändert] [m geändert] [m neue Stufen] [n unverändert] … bis 4.128, dann Zeilenumbruch

Die Zahlen werden in 5-Bit-Gruppen als druckbare Zeichen geschrieben ('0' + Wert, Bit 5 bedeutet „es folgt noch eine Gruppe“), die Stufen als 'A' + Stufe. Ein Frame, in dem sich nichts tut, ist damit genau drei Zeichen lang.

Ich habe ein paar Auflösungen durchgemessen, bevor ich mich festgelegt habe:

AuflösungStufenTextgzipbrotli
80 × 3641.903 KB776 KB703 KB
96 × 4383.181 KB1.440 KB1.315 KB
120 × 5484.541 KB1.971 KB1.795 KB

96 × 43 mit acht Stufen war der beste Kompromiss aus Detail und Größe.

Warum Text und nicht binär?

Weil der Server Text automatisch komprimiert: Next.js schickt die Datei gzip-gepackt, aus 3,2 MB werden 1,4 MB. Der Browser entpackt das selbst – ich brauche keine DecompressionStream-API, die ältere Safari-Versionen noch gar nicht kennen.

Ein Prüfskript dekodiert danach alle 6.573 Frames und vergleicht sie mit dem Original: null Abweichungen. Die Kodierung ist verlustfrei.

Der Decoder

Im Browser hält der Decoder genau ein Bild im Speicher und spielt die Änderungen der Reihe nach ab:

ts
private readNumber() {
  let value = 0
  let factor = 1
  let code: number
  do {
    code = this.text.charCodeAt(this.pos++) - 48
    value += (code & 31) * factor
    factor *= 32
  } while (code & 32)
  return value
}

private next() {
  let i = 0
  while (i < this.pixels.length) {
    i += this.readNumber()                 // unveränderte Pixel überspringen
    if (i >= this.pixels.length) break
    const count = this.readNumber()        // so viele Pixel ändern sich
    for (let k = 0; k < count; k++) this.pixels[i++] = this.text.charCodeAt(this.pos++) - 65
  }
  this.pos++                               // Zeilenumbruch am Frame-Ende
  this.frame++
}

Spulen nach vorn heißt einfach: weiter dekodieren. Spulen nach hinten heißt: von vorn anfangen. Klingt teuer, ist es aber nicht – alle 6.573 Frames zu dekodieren dauert nur ein paar Millisekunden.

Beim Laden zähle ich übrigens die Zeilenumbrüche im Datenstrom mit. So zeigt der Ladebalken echte Prozente an – auch wenn die Datei komprimiert ankommt und der Browser gar keine Gesamtgröße kennt.

Rendern ohne React

30-mal pro Sekunde 4.128 Zeichen in einen React-State zu schreiben, wäre unnötige Arbeit. Stattdessen setzt die Animation textContent eines <pre> direkt. Der Text wird über ein Uint16Array mit Zeichencodes gebaut und in einem Rutsch zu einem String:

ts
return String.fromCharCode.apply(null, this.codes)

React kümmert sich nur noch um die Zeitanzeige darunter – und die ändert sich nur einmal pro Sekunde.

Das Theme spielt auch mit: Im dunklen Modus stehen dichte Zeichen für helle Bildstellen, im hellen Modus umgekehrt. So sieht das Video in beiden Themes aus wie das Original, statt im hellen Modus zum Negativ zu werden.

Timing: die Uhr gewinnt

Die naheliegende Idee – „pro Animationsframe ein Videoframe weiter“ – geht schief: Auf einem 144-Hz-Monitor liefe das Video fast fünfmal zu schnell, auf einem ruckelnden Laptop zu langsam.

Stattdessen fragt jeder Durchlauf: Welcher Frame ist jetzt dran?

ts
const target = Math.floor((now - start) / (1000 / 30))
if (target > decoder.frame) {
  decoder.seek(target)
  draw()
}

Ruckelt der Rechner, werden Frames übersprungen statt das Video zu verlangsamen. Wechselt man den Tab, pausiert die Wiedergabe und läuft beim Zurückkommen weiter.

Und dann kam der Ton

Den Song selbst liefere ich nicht mit – die Rechte liegen bei Alstroemeria Records. Aber es gibt einen Platz auf dem Server, an dem eine Tonspur liegen kann. Ist sie da, ändert sich die Uhr: Dann gibt der Ton den Takt vor, und das Bild folgt ihm.

Das ist wichtiger, als es klingt. Audio puffert, stockt, läuft je nach Gerät minimal anders. Liefen Bild und Ton jeweils nach eigener Uhr, wären sie nach drei Minuten auseinander. Also gleiche ich bei jedem Frame ab und ziehe das Bild nach, sobald es mehr als 60 ms danebenliegt:

ts
const audioMs = audio.currentTime * 1000
if (Math.abs(now - start - audioMs) > 60) start = now - audioMs

Zwei Stolpersteine gab es dabei:

  • Range-Requests. Safari spielt Audio nur ab, wenn der Server Teilbereiche einer Datei ausliefern kann, und Chrome kann ohne sie nicht spulen. Die Tonspur kommt deshalb über eine eigene API-Route, die Range: bytes=… versteht und mit 206 Partial Content antwortet.
  • Autoplay. Browser lassen Ton nur nach einer Nutzeraktion zu. Den Befehl tippt man zwar selbst ein, aber bis die Frames geladen sind, ist diese Aktion für manche Browser schon „zu lange her“. Dann läuft das Bild stumm los, und ein Knopf Ton aktivieren erscheint – ein Klick, und der Ton setzt an der richtigen Stelle ein.

Fazit

  • 1,4 MB, die nur geladen werden, wenn jemand badapple eintippt
  • 6.573 Frames verlustfrei, 30 fps, in beiden Themes
  • unter 20 ms JavaScript pro Sekunde während der Wiedergabe

Probier es aus: ~ drücken, badapple eintippen. Wer es lieber selbst entdeckt: ls zeigt eine verdächtige Datei namens bad_apple.mp4.

Bad Apple!! feat. nomico – Alstroemeria Records, Original: ZUN (Touhou Project). Schattenspiel-PV: Anira.

Weitere Artikel