Da wir ja kürzlich im Zuge des in JavaScript geschriebenen MP3-Decoders die These „das muss doch auch mit Video gehen“ aufgestellt hatten, dachte ich, dass eine kleine Canvas-Video-Demo nebst Erklärungen angebracht wäre. Das was wir in diesem Artikel durchexerzieren ist nur das simpelste aller Beispiele, sollte aber für einen grundsätzlichen Überblick reichen; wir basteln uns ein kleines Script, das einen Videostream von einem Video-Element auf ein Canvas-Element kopiert und mit einen Hipster-Effekt versieht:

Der fertige Videoeffekt in Aktion

Grundlagen

Für unsere Effektmaschine brauchen wir HTML-seitig nichts weiter als ein Video-Element, zwei Canvas-Elemente und einen Script-Block:

<!doctype html>
<video id="film" width="320" height="180" controls>
    <source src="video.mp4" type="video/mp4">
    <source src="video.ogv" type="video/ogg">
</video>
<canvas id="zwischenablage" width="320" height="180" style="display:none"></canvas>
<canvas id="ziel" width="320" height="180"></canvas>
<script></script>

Warum zwei Canvas-Elemente? Unsere Video-Bearbeitung muss in zwei Schritten erfolgen, denn wir können nicht auf die einzelnen Pixel auf einem Video-Element zugreifen – das geht nur bei Canvas-Elementen. Also müssen wir unsere Video-Frames vom Canvas-Element auf Canvas 1 kopieren und von dort auf Canvas 2. Und weil Canvas 1 zu wirklich nichts weiter gebraucht wird, können wir sie auch mit display:none unsichtbar machen. Jetzt fehlt nur noch DOM-Zugriff auf die drei Elemente …

// Elemente in der Seite
var film           = document.getElementById('film'),
    ziel           = document.getElementById('ziel').getContext('2d');
    zwischenablage = document.getElementById('zwischenablage').getContext('2d');

… und schon kann es losgehen.

Die richtige Framerate abpassen

Das Ziel ist also nun, jedes Frame des Videos abzupassen, zu kopieren und zu modifizieren. Das ist schwieriger als man vielleicht zunächst meint, denn herkömmliche JavaScript-Timing-Mechanismen (setTimeout() und setInterval()) sind nicht besonders exakt; mit ihnen kann man nicht garantieren, dass man jedes Frame erwischt oder dass man nicht vielleicht unnötigerweise manche Frames zweimal kopiert. Die richtige Pfad führt über eine Funktion namens requestAnimationFrame() (Specs), die auf das nächste vom Browser gerenderte Frame wartet und dann einen Callback ausführt. Dieses Werkzeug sorgt, rekursiv angewendet, für einen präzisen Animations-Loop.

Der Haken an der Sache ist, dass requestAnimationFrame() eine sehr sehr neue Erfindung ist und in den diversen Browsern nur mit Vendor-Prefix zu finden ist – wenn überhaupt. Damit wir uns mit all diesen Varianten keinen Wolf programmieren setzen wir eine kleine Hilfsfunktion ein (adaptiert von Paul Irish) die uns eine verzweiflungsfreie API bereitstellt:

// Hack für requestAnimationFrame in allen Browsern
var animate = (function(){
    return window.requestAnimationFrame    ||
        window.webkitRequestAnimationFrame ||
        window.mozRequestAnimationFrame    ||
        window.oRequestAnimationFrame      ||
        window.msRequestAnimationFrame     ||
        function(callback){
            setTimeout(callback, 1000 / 60);
        };
})();

Hier wird dafür gesorgt, dass wir unter animate die bestmögliche Implementierung von requestAnimationFrame() vorfinden, die unser Browser zu bieten hat; zur Not auch eine Variante mit Vendor-Prefix oder wenn alle Stricke reißen auch ein herkömmlicher setTimeout().

Damit wir auch eine Animationsschleife erhalten, müssen wir animate wieder und wieder aufrufen, wobei jeder Aufruf der Funktion ein einzelnes Frame abhandelt. Auch diese Funktionalität stecken wir wieder in eine Hilfsfunktion:

// Die Kopier-Schleife
function loop(){
    if(!film.paused){
        animate(loop);
    }
}

Hier wird loop() immer wieder von sich selbst aufgerufen – jeweils durch unseren requestAnimationFrame() getunnelt und nur so lange, wie das Video auch läuft (film.paused nicht true ist). Fehlt nur noch der Startschuss für die Kopier-Schleife, wofür uns praktischerweise das Video-Element ein passendes Event bereitstellt:

// Die Kopier-Schleife starten
film.addEventListener('play', function(){
	loop();
}, false);

Zu diesem Zeitpunkt können wir unsere Animationsschleife starten, laufen lassen und stoppen, wobei freilich noch nicht wirklich etwas passiert – dazu müssen wir noch ein paar weitere Funktionen programmieren.

Pixel kopieren …

Auf ein Canvas-Element kann man mit der drawImage() ganz einfach fertige Grafiken zeichen – einfach neben den Ziel-Koordinaten ein HTML-Element angeben, das als Datenquelle dienen soll und fertig! Als Quelle können neben <img>-Elementen auch Canvas- oder Video-Elemente fungieren, so dass es ein leichtes ist, die Videoframes auf die Zwischenablage zu kopieren. Um an die Pixeldaten des Frames zu kopieren, müssen wir die Methode getImageData() auf der Zwischenablage bemühen. Diese gibt ein so geanntes ImageData-Objekt zurück, dass die Farbwerte jedes einzelnen Pixels enthält. Wenn wir dieses Objekt nun mit getImageData() auf die Ziel-Canvas schreiben …

// Diese Funktion kopiert Pixel von "film" auf "zwischenablage", dann auf "ziel"
function copy(){
    zwischenablage.drawImage(film, 0, 0);
    var bilddaten = zwischenablage.getImageData(0, 0, 320, 180);
    ziel.putImageData(bilddaten, 0, 0);
}

… und den Aufruf der copy()-Funktion in den Animationsloop einbauen …

// Die Kopier-Schleife
function loop(){
    if(!film.paused){
        copy(); // Frames kopieren
        animate(loop);
    }
}

… haben wie schon mal zumindest einen schönen sauberen Klon des Videos erzeugt. Wegen des Doppelschritts beim Kopieren haben wir durch das ImageData-Objekt Zugriff auf die Farbwerte jedes einzelnen Pixels in jedem Videoframe, so dass es uns ein leichtes ist, ein bisschen mit den Farben zu spielen.

… und verändern

Ein ImageData-Objekt enthält neben den Angaben width und height (geben die Maße des Ausschnitts an) ein Array, das der Reihe nach alle Farbwerte aller Pixel enthält. Der erste Eintrag im Array ist der Rot-Wert des ersten Pixels, der zweite Eintrag ist der Grün-Wert des ersten Pixels, an dritter Stelle folgt der Blau-Wert des ersten Pixels, dann kommt der Alpha-Wert des ersten Pixels und dann der Rot-Wert des zweiten Pixels … und so weiter. Diese Werte effektvoll zu manipulieren ist natürlich sehr einfach:

// Wendet den Effekt an
function effekt(bilddaten){
    var pixel = bilddaten.data;
    var i = 0;
    var r, g, b, new_r, new_g, new_b;
    while(i < pixel.length){
        // R, G und b holen...
        r = pixel[i],
        g = pixel[i + 1],
        b = pixel[i + 2];
        // Manipulieren...
        new_r = Math.min(255, r * 0.393 + g * 0.769 + b * 0.189),
        new_g = Math.min(255, r * 0.349 + g * 0.686 + b * 0.168),
        new_b = Math.min(255, r * 0.272 + g * 0.534 + b * 0.131);
        // ... und speichern!
        pixel[i] = new_r;
        pixel[i + 1] = new_g;
        pixel[i + 2] = new_b;
        // Auf zum nächsten Pixel - den Alphawert einfach überspringen
        i += 4;
    }
    return bilddaten;
}

Die Funktion rattert einmal durch das Bilddaten-Array, spielt an den RGB-Werten der Pixel herum, überspringt den Alpha-Wert und gibt am Ende ein komplett überarbeitetes ImageData-Objekt zurück. Dieses müssen wir dann nur noch abbilden, d.h. wir müssen die Funktion effekt() in copy() einmal auf die Bilddaten anwenden

// Diese Funktion kopiert Pixel von "film" auf "zwischenablage", dann auf "ziel"
function copy(){
    zwischenablage.drawImage(film, 0, 0);
    var bilddaten = zwischenablage.getImageData(0, 0, 320, 180);
    bilddaten = effekt(bilddaten); // Effekt anwenden
    ziel.putImageData(bilddaten, 0, 0);
}

Und das war es dann auch schon! Das Ganze läuft halbwegs performant in jedem Browser, der Canvas- und Video-Element unterstützt, was heutzutage ja auf alles jenseits der Kreidezeit-Fraktion aus dem Hause Microsoft (IE 6 - 8) zutrifft.

Ausblick

Ein paar Pixel zu verdrehen ist also augenscheinlich nicht besonders schwer. Wollte man wirklich Videodaten erst im Browser generieren, wäre das aber im Prinzip auch kein Hexenwerk; mit der createImageData()-Methode des 2D-Kontext (Spezifikationen) kann man leere Bilddatensätze erstellen und diese dann mit Farbwerten für die diversen Pixel befüllen. Die Herausforderung liegt tatsächlich eher in der Codierung und Decodierung von Bilddaten, denn man will schließlich in Sachen Wiedergabe weder von dem löchrigen Codec-Support der Browser abhängig sein, noch möchte man darauf verzichten, bei Effektgeneratoren wie unserer kleinen Demo am Ende fertige Filme abzuspeichern. In diesem Bereich liegen die wahren Herausforderungen. Die Werkzeuge sind jedoch alle da. Es müsste nur mal jemand etwas daraus machen.