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:
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.
Kommentare (15)
fwolf ¶
30. Juni 2011, 09:17 Uhr
Naja, Theora ist ja ein freier Videocodec, somit müsste sich der auch in JS implementieren lassen.
Nachtrag: Ein paar ältere Ansätze - obzwar mit anderem Hintergedanken; aus dem Jahr 2009 - bei Bluish Coder gefunden: Reading Ogg files with JavaScript. Ogg Theora ist wohl (auch?) als RFC definiert.
cu, w0lf.
non ¶
30. Juni 2011, 13:26 Uhr
Auf deiner Beispielseite weigert sich mein Browser (trotz deaktiviertem NoScript) das Video zu laden (FireFox 5).
Jedoch frage ich mich welchen ernsthaften Sinn das Ganze eigentlich hat? Also auf normalen Webseiten bei denen Videos eingebunden sind, spielt man ja nicht mit irgendwelchen Videoeffekten rum. Falls das Video doch irgendwelche Effekte drauf kriegen soll, dann schneidet man das Video gleich so zusammen wie man es haben will und nicht erst später beim Client.
Mir fallen nur 2 Anwendungszwecke dafür ein:
1. sinnlose Spielereien.
2. eine Vorschau in einer Browser basierten Videoschnittsoftware.
In allen anderen Fällen die mir einfallen wäre man besser damit beraten das Video gleich richtig aus zu liefern.
Ich befürchte aber, dass die meisten Webentwickler, die das benutzen werden, den Schwachsinn betreiben werden und Videos anstatt gleich richtig zu schneiden und aus zu liefern, völlig unsinniger weise beim Client dann dieses erst berechnen lassen. Besonders die Kategorie von Webentwicklern (Stümper) die jetzt schon >ausschließlich< Javascript für die Navigation in Webseiten einsetzen.
Peter ¶
30. Juni 2011, 14:01 Uhr
Zitat non:
Ups, danke für den Hinweis. Was ein fehlendes Leerzeichen im HTML doch alles ausrichten kann …
Genau an sowas dachte ich auch. Und das ist doch schon mal einiges - Bilder nach dem Upload in der Webapp bearbeiten ist heute Standard, warum sollte es das nicht auf für Videos geben?
Glaube ich nicht. Ist doch hundertfach komplizierter so etwas zu programmieren als das Filmchen vorher zu schneiden.
non ¶
30. Juni 2011, 14:44 Uhr
Zitat Peter:
Aber nur als Vorschau! Das Ergebnis sollte dann aber auf dem Server als Videodatei vorliegen und nicht bei jedem Client immer wieder aufs neue verarbeitet werden.
Zitat Peter:
Nicht, wenn es dafür eine simple API und fertige Effekte gibt, so dass man nur den Code eines Beispieles kopieren und ggf. anpassen braucht. Auch kann das durchaus einen Reiz von etwas "besonderem" haben, so dass man das unbedingt verwenden muss. Genauso wie Javascript basierte Popupmenüs (und ähnliches), die man auch unbedingt haben muss...
Peter ¶
1. Juli 2011, 15:21 Uhr
Das ist doch mal eine schöne Anwendung!
Alex ¶
1. Juli 2011, 19:46 Uhr
Ich finds extrem cool - außerdem hab ich den Film nicht gekannt =)
Patrick H. Lauke ¶
2. Juli 2011, 02:36 Uhr
Anstatt ein canvas Element fuer die Zwischenablage in den Markup einzubauen und dann per CSS verschwinden zu lassen, koennte man das sauberer machen indem man einfach programmatisch ein document.createElement macht, ohne das Teil an das eigentliche Dokument einzubinden...also ein canvas, der nur "in memory" liegt und nicht im DOM.
Francesco ¶
2. Juli 2011, 07:17 Uhr
Ist die
if
-Abfrage, obfilm.paused
nichttrue
ist, nicht überflüssig? Durch denaddEventListener
wirdloop()
(und damit auchrequestAnimationFrame
) sowieso nur gestartet, wenn der Film läuft.Peter ¶
2. Juli 2011, 09:12 Uhr
Richtig,
loop()
wird durch das Event gestartet und ruft sich dann selbst immer wieder auf. Aber wir müssenloop()
ja auch stoppen können, sonst kopiert der sich bei jedem Frame einen Wolf obwohl das Video gar nicht läuft.Chris ¶
3. Juli 2011, 18:51 Uhr
Naja die Sache mit den Bildern ist jetzt noch sooo sinnfrei wie jeder meint.
Nehmen wir mal ein ganz bekanntes Beispiel: Youtube. Beim hochladen eines Videos werden Screenhots gemacht (siehe Verlinkung von Peter). Man könnte also hoffen das man irgendwanneinmal in einem Videoportal ala Youtube selbst die Videoscreenshots und dadurch Thumbs generiert / erstellt und diese belichten, farbewerte korregieren usw... kann.
Also Sinn machts schon für zukünftige Browserapplikationen.
(btw. in der Vorschau geht das anchor nicht korrekt. Wird das schließende a gelöscht.)
Chris ¶
3. Juli 2011, 18:54 Uhr
eh, sry wegen doppelpost? irgendwie ist das kleiner-als vorm a verschwunden?..
Francesco ¶
5. Juli 2011, 15:55 Uhr
Kann man den mit fortschreitender Zeit stetig wachsenden Speicher mit JavaScript in den Griff bekommen? Mit wenig Arbeitsspeicher ausgerüstete Rechner gehen bei dem Beispiel ja relativ schnell in die Knie.
Florian ¶
5. Februar 2012, 21:35 Uhr
Funktioniert das auch mit einem gestreamten Video on the fly?
Mace ¶
10. Januar 2013, 14:08 Uhr
Heyho, vielen vielen Dank für das Tutorial
Eine Frage hätte ich allerdings,
was müsste ich machen, um das Video in ein kleines Canvas zu skalieren,
also statt 400x300 in ein 40x30, wenn die Originaldatei aber 400x300 hat?
mit besten Grüßen
Mace
Peter Kröner ¶
10. Januar 2013, 14:27 Uhr
Das ist zum Glück ganz einfach. Die drawImage-Methode kann man nicht nur mit zwei Parametern (Bild, X-Koordinate, Y-Koordinate) aufrufen, sondern auch mit fünfen. Die beiden zusätzlichen Parametern bestimmen dann die Höhe und Breite des gemalten Bildes und wenn die Quelle andere Maße hat, wird sie automatisch gestreckt oder verkleinert.
Konkret hieße des: