Video + Canvas = magie

Videopřehrávač, zabudovaný přímo do specifikace HTML5, není jen pokusem nahradit „asfaltovou skvrnu“ přehrávačů ve Flashi. To, že video není přehráváno v pluginu, znamená, že s ním lze přímo manipulovat pomocí webových technik (JavaScript apod.) Dnes si ukážeme některé efekty, které lze s přehrávaným videem provádět.

Znáte elementy <video> a <canvas>, ale víte, že jsou navrženy tak, aby spolu dokázaly spolupracovat? Ve skutečnosti je jejich kombinace naprosto úžasná. Ukážu vám několik velmi jednoduchých příkladů s těmito dvěma elementy, které vám, jak doufám, naznačí skvělé možnosti budoucích webových projektů. (Všechny tyto příklady běží ve všech moderních prohlížečích s výjimkou Internet Exploreru.)

Nejprve základy

Pokud s HTML5 teprve začínáte, tak možná nejste důvěrně obeznámeni s elementem video a s jeho použitím. Ukážeme si jednoduchý příklad, který budeme používat v dalších ukázkách:

<video controls loop>
    <source src=video.webm type=video/webm>
    <source src=video.ogg type=video/ogg>
    <source src=video.mp4 type=video/mp4>
</video>

Tag <video> obsahuje dva atributy: controls a loop. controls říká prohlížeči, aby zobrazil standardní sadu ovládacích prvků – přehrání, pauzu, posuvník s pozicí, ovladač hlasitosti apod. loop říká, že video hraje v nekonečné smyčce.

Uvnitř elementu <video> máme vnořené tři prvky <source>, přičemž každý ukazuje na totéž video, jen v jiném formátu. Prohlížeč je prochází a použije první, který rozezná a který umí přehrát.

Podívejte se na kód v akci při přehrávání úvodní znělky kresleného seriálu.

Poznámka k fallback řešení: Všechna tato dema předpokládají, že váš prohlížeč má podporu pro <video>, což neplatí pro IE8 a nižší. Běžné je specifikovat nouzové řešení (fallback) pomocí Flashe či podobné technologie, ale to nám zde příliš nepomůže – všechny techniky, které si budeme ukazovat, jsou založeny na interakci elementů  <video> a <canvas>, které s Flashem ani jiným pluginem nedokážete. Proto v příkladech není žádné non- <video> řešení. Jsou ale použity vícenásobné zdroje s různými formáty souborů, aby všechny prohlížeče, co zvládají <video>, byly schopny obsah přehrát.

Čas na jednoduchý příklad

Když víme, jak si pustit video, tak je čas přidat nějaké srandičky pomocí  <canvas>. Nejprve se podívejte na demo a pak se vraťte, probereme si kód. Počkám tu…

Hotovo? Skvělé! A teď: Jak to funguje? čekáváte stovky řádků v JavaScriptu, že? Jestli jste podváděli a koukali se do zdrojového kódu, tak už víte, jak snadné to je.

Začínáme s jednoduchým HTML:

<!DOCTYPE html>
<title>Video/Canvas Demo 1</title>
<canvas id=c></canvas>
<video id=v controls loop>
    <source src=video.webm type=video/webm>
    <source src=video.ogg type=video/ogg>
    <source src=video.mp4 type=video/mp4>
</video>

Stejný kód pro přehrávání videa jako v prvním příkladu, ale nyní s elementem  <canvas>. Vypadá trošku prázdný a zbytečný, ale za chvíli ho zapojíme do akce.

Teď přidáme trochu stylů, abychom dostali věci na správné místo:

<style>
body {
    background: black;
}
#c {
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    width: 100%;
    height: 100%;
}
#v {
    position: absolute;
    top: 50%;
    left: 50%;
    margin: -180px 0 0 -240px;
}
</style>

Pouze umístíme video na střed obrazovky a roztáhneme canvas přes celou výšku a šířku. Protože je canvas první, bude za videem, na pozadí, což je přesně to, kde jej chceme mít.

A teď trocha magie. Čáry máry fuk…

<script>
document.addEventListener('DOMContentLoaded', function(){
    var v = document.getElementById('v');
    var canvas = document.getElementById('c');
    var context = canvas.getContext('2d');
    var cw = Math.floor(canvas.clientWidth / 100);
    var ch = Math.floor(canvas.clientHeight / 100);
    canvas.width = cw;
    canvas.height = ch;
    v.addEventListener('play', function(){
        draw(this,context,cw,ch);
    },false);
},false);
function draw(v,c,w,h) {
    if(v.paused || v.ended) return false;
    c.drawImage(v,0,0,w,h);
    setTimeout(draw,20,v,c,w,h);
}
</script>

Prohlédněte si ten kód, nechte ho na sebe působit. Tak krátký, tak ladný, tak hezký…

Ale teď vážně. Pojďme si ho rozebrat:

    var v = document.getElementById('v');
    var canvas = document.getElementById('c');
    var context = canvas.getContext('2d');
    var cw = Math.floor(canvas.clientWidth / 100);
    var ch = Math.floor(canvas.clientHeight / 100);
    canvas.width = cw;
    canvas.height = ch;

Tato část je prostá. Připravíme si elementy canvas a video a získáme 2D kontext pro canvas, abychom do něho mohli malovat. Pak jednoduše spočítáme rozměry pro canvas a zadáme velikost plochy, do níž budeme kreslit. Samotný element <canvas> už je pomocí CSS roztažen na celou plochu, takže tímto postupem docílíme, že každý bod na kreslicím plátně bude odpovídat 100×100 pixelům  na obrazovce.

Ten poslední kousek asi bude chtít trochu vysvětlení, pokud nejste s canvasem naprostí kamarádi. Za normálních okolností je u elementu <canvas> kreslicí povrch (tedy oblast, do níž je kresleno, drawing-surface) shodný s obrazovkou. To znamená, že pokud namalujete čáru 50 pixelů dlouhou do canvasu, bude na obrazovce 50px dlouhá čára. Ale nemusí to platit vždy – můžete nastavit rozměry pomocí vlastností width a height nezávisle na zobrazovaných rozměrech, nastavených v CSS (situace je analogická s obrázky). Prohlížeč pak automaticky přepočítá zobrazení elementu tak, aby kreslicí plocha odpovídala zobrazované velikosti. V našem případě je kreslicí plocha canvasu velmi malá (třeba 10×7 pixelů) a pomocí CSS je roztažena na celou obrazovku, takže každý pixel kreslicí plochy je nafouknutý na cca stopixelový čtvereček. To způsobuje právě onen „cool“ efekt (pixellate).

    v.addEventListener('play', function(){
        draw(v,context,cw,ch);
    },false);

Další jednoduchá část. Přiřazujeme obsluhu události „play“ u elementu video. Tato událost je vyvolána vždy, když uživatel spustí přehrávání. Jediné, co se v této obsluze odehraje, je zavolání funkce draw() s patřičnými parametry: samotné video, kreslicí kontext a výška a šířka plátna.

function draw(v,c,w,h) {
    if(v.paused || v.ended) return false;
    c.drawImage(v,0,0,w,h);
    setTimeout(draw,20,v,c,w,h);
}

První řádek prostě ukončí funkci, pokud je video zastavené nebo skončilo. Není třeba zatěžovat CPU, když se nic neděje. Třetí řádek nastaví opětovné zavolání téže funkce po uplynutí 20ms, což poskytuje prohlížeči spoustu času na jinou práci, a přitom je obnovovací frekvence okolo 50fps, což je víc než dostatečná rychlost.

Druhý řádek je celé to kouzlo – vykreslí aktuální snímek z videa přímo do canvasu. Ano, je to přesně tak jednoduché, jak to vypadá. Jen předáme element video (na místě obrázku) a určíme umístění a rozměry prostoru, do něhož budeme v canvasu kreslit. Zde tedy vyplňujeme celý canvas, ale prostor může být menší (i větší!) než samotný canvas.

Zdej e použit další trik. Pamatujete, jak je canvas maličký? Video je zhruba 20× větší než rozměry canvasu, tak jak tedy…? Naštěstí se o to stará funkce drawImage()  – automaticky změní rozměry předaného obrázku tak, aby se vešel do zadaného prostoru. To znamená, že se nemusíme starat o dopočítávání pixelů, protože to za nás udělá prohlížeč (resp. CPU tam, kde je 2D grafika akcelerovaná). Ještě tento trik několikrát použijeme…

A to je vše, přátelé. Celé demo zabírá 20 řádků snadno čitelného JavaScriptu, který vytváří zajímavý efekt na pozadí. Velikost „pixelů“ můžete snadno upravit na řádcích, kde se nastavují hodnoty cw a ch.

Přímá manipulace s obrazovými body

Předchozí demo bylo sice hezké, ale veškerou práci za nás dělal prohlížeč. Prohlížeč zmenšil video na rozměry canvasu a canvas roztáhl na celou obrazovku, a to vše automaticky. Pojďme si tu těžkou práci zkusit sami. Podívejte se na demonstraci, kde uvidíte tento postup v akci – v reálném čase budeme převádět video na černobílé.

HTML kód zůstane téměř stejný:

<video id=v controls loop>
    <source src=video.webm type=video/webm>
    <source src=video.ogg type=video/ogg>
    <source src=video.mp4 type=video/mp4>
</video>
<canvas id=c></canvas>

Nic nového. Podívejme se na skript.

document.addEventListener('DOMContentLoaded', function(){
    var v = document.getElementById('v');
    var canvas = document.getElementById('c');
    var context = canvas.getContext('2d');
    var back = document.createElement('canvas');
    var backcontext = back.getContext('2d');
    var cw,ch;
    v.addEventListener('play', function(){
        cw = v.clientWidth;
        ch = v.clientHeight;
        canvas.width = cw;
        canvas.height = ch;
        back.width = cw;
        back.height = ch;
        draw(v,context,backcontext,cw,ch);
    },false);
},false);
function draw(v,c,bc,w,h) {
    if(v.paused || v.ended) return false;
    // First, draw it into the backing canvas
    bc.drawImage(v,0,0,w,h);
    // Grab the pixel data from the backing canvas
    var idata = bc.getImageData(0,0,w,h);
    var data = idata.data;
    // Loop through the pixels, turning them grayscale
    for(var i = 0; i < data.length; i+=4) {
        var r = data[i];
        var g = data[i+1];
        var b = data[i+2];
        var brightness = (3*r+4*g+b)>>>3;
        data[i] = brightness;
        data[i+1] = brightness;
        data[i+2] = brightness;
    }
    idata.data = data;
    // Draw the pixels onto the visible canvas
    c.putImageData(idata,0,0);
    // Start over!
    setTimeout(function(){ draw(v,c,bc,w,h); }, 0);
}

Skript je o něco delší, protože teď už doopravdy něco děláme. Ale stále je to velmi prosté!

document.addEventListener('DOMContentLoaded', function(){
    var v = document.getElementById('v');
    var canvas = document.getElementById('c');
    var context = canvas.getContext('2d');
    var back = document.createElement('canvas');
    var backcontext = back.getContext('2d');
    var cw,ch;
    v.addEventListener('play', function(){
        cw = v.clientWidth;
        ch = v.clientHeight;
        canvas.width = cw;
        canvas.height = ch;
        back.width = cw;
        back.height = ch;
        draw(v,context,backcontext,cw,ch);
    },false);

Toto je skoro to samé jako v předchozím příkladu, ale se dvěma rozdíly.

V první řadě jsme si vytvořili druhý canvas a získali jeho 2D kontext. Toto je „záložní canvas“, který použijeme pro potřebné operace před vykreslením výsledku do viditelného canvasu. Záložní canvas nemusí být ve skutečnosti ani vložen do dokumentu, stačí že existuje „někde ve skriptu“. Tuto strategii použijeme i v dalších příkladech, a je obecně velmi použitelná. (Ve světě počítačové grafiky se této technice říká double buffering.)

Druhá změna je ta, že velikost canvasů je nastavena až ve chvíli, kdy je spuštěné video. Důvod je ten, že ve chvíli, kdy je vyvolána událost DOMContentLoaded, nemá element <video> pravděpodobně ještě načtený obsah, takže stále používá přednastavenou velikost elementu. Ale ve chvíli, kdy je video připravené přehrání, je velikost videa známa a rozměry elementu jsou nastaveny správně. Můžeme tudíž nastavit oba canvasy stejně velké jako samotné video.

function draw(v,c,bc,w,h) {
    if(v.paused || v.ended) return false;
    bc.drawImage(v,0,0,w,h);

Stejně jako v předchozí ukázce začíná i tentokrát funkce draw() kontrolou toho, zda video běží, a pokud ano, kopírujeme snímek do canvasu. Kopírujeme do záložního canvasu, který – připomeňme si – existuje pouze „ve skriptu“ a není zobrazen v dokumentu. Viditelný canvas je určen pro upravené snímky, takže si načítáme zdrojová data do záložního.

    var idata = bc.getImageData(0,0,w,h);
    var data = idata.data;

Zde je první novinka. Můžete malovat do canvasu buď pomocí běžných kreslicích metod, nebo můžeme upravit jednotlivé body v bitmapě, k níž přistoupíme přes objekt ImageData. getImageData() vrací bitmapu (pixely) z dané oblasti v canvasu. V tomto případě z celého canvasu.

Pozor! pokud budete zkoušet tyto příklady na svém desktopu, můžete se setkat s problémem. Element <canvas> sleduje, kde se berou data, co mají být vykreslena, a pokud zjistí, že pocházejí z objektu, který je načten z jiné domény (například pokud element <video>, který vykreslujeme do canvasu, přehrává videosoubor z jiného webu), poznačí si je. Z takto poznačeného canvasu nelze kopírovat pixely. Naneštěstí jsou URL se schématem file: brány v tomto případě jako „cross-origin“, takže příklady nelze spustit přímo na desktopu. Musíte si buď spustit místní webserver, nebo soubory nahrát někam na web.

    for(var i = 0; i < data.length; i+=4) {
        var r = data[i];
        var g = data[i+1];
        var b = data[i+2];

Je načase zmínit se stručně o objektu ImageData. Vrací pixely tak, aby bylo snadné s nimi pracovat. Pokud máme, řekněme, canvas 100×100px, obsahuje 10 000 pixelů celkem. Objekt  ImageData bude obsahovat 40 000 prvků, protože pixely budou rozloženy na jednotlivé barevné složky (s hodnotami 0–255) a uloženy za sebou (v pořadí r, g, b, a). Každá skupina čtyř prvků tedy popisuje jeden pixel, stačí tedy pole procházet s krokem 4, jako zde.

        var brightness = (3*r+4*g+b)>>>3;
        data[i] = brightness;
        data[i+1] = brightness;
        data[i+2] = brightness;

Zde je zjednodušeným způsobem spočítán jas bodu z barevných složek. Lidské oko je nejcitlivější na zelenou, méně na červenou, nejmíň pak na modrou barvu. Vzorec zjednodušeně přepočítá jas jednotilvých složek podle těchto vah na celkový „jas šedi“. Tato hodnota je pak přiřazena všem barevným složkám daného pixelu. Jak pravděpodobně všichni víme, pokud mají červená, zelená i modrá složka stejnou hodnotu, získáme barvu na škále šedi (od černé po bílou). Během celého procesu je kompletně ignorována čtvrtá hodnota, tedy průhlednost (alpha), protože je zkrátka 255 – neprůhledná barva.

    idata.data = data;

Umístíme upravený pixel zpět do objektu ImageData…

    c.putImageData(idata,0,0);

…a nakonec celý ImageData umístíme do viditelného canvasu! Nemusíme dělat žádné složité vykreslování – jen získáme pixely, upravíme je a umístíme je zpět do viditelného canvasu. Tak snadné to je.

Závěrečná poznámka: manipulace s obrazovými body v reálném čase je jedna z těch mála situací, kde opravdu záleží na optimalizaci. Jak vidíte, nevytahovali jsme obrazová data z objektu ImageData a přistupovali jsme k nim přímo přes idata.data[i]. V původním kódu jsem dělil hodnotu jasu osmi a počítal jsem zaokrouhlenou hodnotu, což bylo o něco pomalejší než prostý bit shift o tři místa. V normálním kódu jsou takovéhle triky naprosto nevýznamné, ale když je děláte milionkrát za sekundu, nasčítá se každé drobné zdržení do viditelného zpoždění (video má rozlišení 480×360, tedy obsahuje cca 200 000 pixelů a každý z nich je zpracován individuálně zhruba stokrát za sekundu).

Pokročilejší manipulace s obrazovými body

Můžete samosebou pracovat s více body najednou a udělat tak komplexnější efekty. Na konci předchozího oddílu jsme si řekli, že na rychlosti záleží, ale možná budete překvapeni, jaké efekty lze dosáhnout s trochou tvořivosti. Jak je vidět v příkladu, můžeme v reálném čase vytvořit efekt zvaný reliéf (emboss), při němž je barva každého bodu počítána z hodnot několika sousedních bodů.

Zde je kód. HTML a většina úvodního kódu je stejná jako u předcházejícího příkladu, takže si probereme pouze funkci  draw():

function draw(v,c,bc,cw,ch) {
    if(v.paused || v.ended) return false;
    // First, draw it into the backing canvas
    bc.drawImage(v,0,0,cw,ch);
    // Grab the pixel data from the backing canvas
    var idata = bc.getImageData(0,0,cw,ch);
    var data = idata.data;
    var w = idata.width;
    var limit = data.length
    // Loop through the subpixels, convoluting each using an edge-detection matrix.
    for(var i = 0; i < limit; i++) {
        if( i%4 == 3 ) continue;
        data[i] = 127 + 2*data[i] - data[i + 4] - data[i + w*4];
    }
    // Draw the pixels onto the visible canvas
    c.putImageData(idata,0,0);
    // Start over!
    setTimeout(draw,20,v,c,bc,cw,ch);
}

Opět si jej projděme:

function draw(v,c,bc,cw,ch) {
    if(v.paused || v.ended) return false;
    // First, draw it into the backing canvas
    bc.drawImage(v,0,0,cw,ch);
    // Grab the pixel data from the backing canvas
    var idata = bc.getImageData(0,0,cw,ch);
    var data = idata.data;

Začátek je stejný jako u předchozího příkladu: kontrolujeme, zda video běží, vykreslíme snímky do skrytého canvasu a přistoupíme k datům.

var w = idata.width;

Důležitost tohoto řádku vyžaduje trochu vysvětlení. Funkci jsme si předali šířku canvasu (v proměnné cw), tak proč znovu měřit šířku? Dobrá… ve skutečnosti jsem trošku zalhal, když jsem popisoval, jak velké bude pole pixelů. Prohlížeč může přiřadit jeden pixel canvasu do jednoho pixelu ImageData, ale lze nastavit i „oversampling“. Prohlížeč pak může každému bodu canvasu přiřadit blok 2×2 pixelů v ImageData, nebo 3×3, nebo ještě víc!

Pokud použije “úložný prostor s vyšším rozlišením“ (high-resolution backing store), získáme lepší obraz, protože artefakty vzniklé aliasingem (např. u diagonálních čar) budou mnohem menší a málo zřetelné. Znamená to ale i to, že canvas se 100×100 pixely bude představovat objekt ImageData.data nikoli se 40 000 hodnotami, namísto toho bude hodnot 160 000. Dotazem na šířku ImageData se ujistíme, že procházíme daty správně bez ohledu na to, jestli prohlížeč použil nízké nebo vysoké rozlišení.

Je velmi důležité, abyste používali tento postup vždy, když budete potřebovat zjistit rozměry objektu ImageData. Pokud na to budou vývojáři kašlat a budou prostě předpokládat, že ImageData má stejný rozměr jako canvas, pak budou prohlížeče nuceny použít vždy nízké rozlišení, aby v nich fungovaly špatně napsané skripty!

    var limit = data.length;
    for(var i = 0; i < limit; i++) {
        if( i%4 == 3 ) continue;
        data[i] = 127 + 2*data[i] - data[i + 4] - data[i + w*4];
    }

Nejprve si uložíme velikost pole do proměnné (viz výše odstavec o optimalizaci). Pak jen procházíme jednotlivými barevnými složkami pixelů (přeskakujeme alfakanál) a děláme jednoduchou matematickou operaci, kdy hledáme rozdíl mezi hodnotou pro daný pixel, hodnotou pro pixel  o řádek níž a pro pixel vpravo, a nakonec je srovnán s „průměrnou šedou“ hodnotou. Na místech, která jsou obklopená pixely s podobnou barvou, je tak rozdíl malý a barva blízká průměrné hodnotě. Tam, kde se barva mění prudce (hrany objektů apod.), je i barva výsledného pixelu jasná nebo naopak tmavá.

Zde je další trik: Protože porovnáváme hodnoty pixelů pouze s těmi, které ještě nebyly zpracovány (vpravo, dole), můžeme vypočítané hodnoty ukládat rovnou zpět do pole, protože tím algoritmus nijak neovlivníme. Nemusíme si proto alokovat další velké pole, které by ukládalo mezihodnoty.

    c.putImageData(idata,0,0);
    setTimeout(draw,20,v,c,bc,cw,ch);

Nakonec vykreslíme modifikovaný objekt do viditelného canvasu a nastavíme opětovné volání za 20ms.

Souhrn

Prošli jsme si základy kombinování HTML5 elementů  <canvas> a <video>. Ukázky byly velmi jednoduché, ale ilustrovaly základní techniky, které budete potřebovat, když se rozhodnete vytvořit vlastní videoefekty:

  1. Můžete vykreslovat snímky přímo do canvasu.
  2. Když vykreslujete obrázek do canvasu, prohlížeč změní jeho rozměry tak, jak je potřeba.
  3. Když zobrazujete canvas, prohlížeč opět přizpůsobí velikost, pokud se liší velikost na obrazovce od velikosti vnitřní reprezentace plátna.
  4. Můžete měnit přímo hodnotu pixelů v canvasu pomocí přístupu k objektu ImageData, změně hodnot a uložení zpět.

Další informace k tématu:

Text je překladem článku video + canvas = magic, který napsal Tab Atkins Jr. pro web HTML5Doctor. Překlad vychází s autorovým laskavým souhlasem pod licencí CC-BY-SA-NC.

Začal programovat v roce 1984 s programovatelnou kalkulačkou. Pokračoval k BASICu, assembleru Z80, Forthu, Pascalu, Céčku, dalším assemblerům, před časem v PHP a teď by rád neprogramoval a radši se věnoval starým počítačům.

Komentáře: 12

Přehled komentářů

koudi Re: Video + Canvas = magie
JAM3SoN Re: Video + Canvas = magie
anonymous Diky
nikdo videoediting
KapitánRUM BRUM!
Xjmeno363 Re: BRUM!
Michal Zahradnicek Optimalizacia vypoctu ciernobieleho obrazu
koroptev Re: Optimalizacia vypoctu ciernobieleho obrazu
Michal Zahradnicek Re: Optimalizacia vypoctu ciernobieleho obrazu
eL Skepticky pohled
sa037 zátěž CPU
koroptev neni neco spatne
Zdroj: https://www.zdrojak.cz/?p=3367