HTML-in-Canvas で DOM を画像化し、動画にも書き出す
Zennからの転載です。オリジナル:https://zenn.dev/chot/articles/html-in-canvas-dom-to-video
はじめに
ここでは、提案中の API である HTML-in-Canvas で DOM を画像化する方法と、その応用として CSS アニメーションを動画としてエクスポートする方法を紹介します。
WICG/html-in-canvasHTML-in-Canvas とは
HTML-in-Canvas は、<canvas> の子に配置した DOM を、ブラウザのレンダリング結果のまま canvas に描画する API です。この記事では 2D コンテキストに描画します。
これまで DOM を画像化する方法には、次のようなものがありました。
- SVG の
foreignObjectに DOM を埋め込んで画像として描画する。フォント、画像、スタイルシートをすべてインライン化する必要があり、CSS にも未対応の部分がある - html2canvas のように、DOM を解析して Canvas API で再描画する。CSS の機能を 1 つずつ再実装することになり、ブラウザの表示と少しずつずれる
HTML-in-Canvas では、ブラウザのレンダラーによる描画結果を使うので、CSS を再実装する必要も、フォントや画像をインライン化する必要もありません。ただし、後述するように画面の表示と完全に同じにはなりません。
有効化する方法
2026 年 9 月時点では、Chrome のフラグで有効化します。explainer では、次の 2 つを両方有効にするよう書かれています。
chrome://flags/#canvas-draw-elementchrome://flags/#enable-experimental-web-platform-features
DOM を画像化する
最小構成のコードは次のとおりです。
<canvas id="canvas" content="drawable" style="width: 400px; height: 300px">
<div id="card" drawable>Hello</div>
</canvas>
<script type="module">
const canvas = document.getElementById("canvas");
const card = document.getElementById("card");
const ctx = canvas.getContext("2d");
canvas.width = 400 * devicePixelRatio;
canvas.height = 300 * devicePixelRatio;
await new Promise((resolve) => {
canvas.addEventListener("paint", resolve, { once: true });
canvas.requestPaint();
});
ctx.reset();
ctx.drawElementImage(card, 0, 0);
const blob = await new Promise((resolve) => canvas.toBlob(resolve));
</script><canvas content="drawable">: canvas の子を描画可能にするdrawable属性: 描画対象の要素に付与する。付与した要素はisolation: isolateになり、子孫の containing block になるpaintイベント: ブラウザはレンダリング更新のたびにdrawable要素のスナップショットを記録する。スナップショットが変化したときに、その直後に発火するdrawElementImage(): 要素の最新のスナップショットを canvas に描画する
drawElementImage() は、最後に記録されたスナップショットを描画します。呼び出した時点の DOM の状態は、スナップショットに記録されるまで描画に反映されません。そのため、DOM を変更してから描画する場合は、requestPaint() を呼び出して次の paint イベントを待ちます。requestPaint() は、要素に変化がなくても次のレンダリング更新で paint イベントを発火させるメソッドです。
一度もスナップショットが記録されていない要素を描画しようとすると、例外が発生します。
Failed to execute 'drawElementImage' on 'CanvasRenderingContext2D': No cached paint record for element.描画範囲
描画されるのは、drawable 要素の border box です。drawable 要素自身の transform は適用されず、rotate(45deg) を指定しても回転前の矩形として描画されます。要素全体を変形する場合は、canvas 側で ctx.rotate() などを使います。
子孫要素の transform は、画面の表示と同じように描画されます。drawable 要素の子に transform: translate(50px, 50px) rotate(45deg) を指定すると、移動・回転した状態で描画されました。
box-shadow のように border box の外にはみ出す部分は、そのままではクリップされます。はみ出す部分も描画するには、元の矩形を指定します。元の矩形は、要素の border box の左上を原点とする CSS px で指定します。描画サイズを省略すると、元の矩形のサイズから次の節と同じ規則で決まります。
// 110×100 の要素を、右に 20px はみ出す box-shadow を含めて描画する
ctx.drawElementImage(shadow, 0, 0, 130, 100, 0, 0);描画サイズを指定する場合は、canvas のピクセル単位で指定します。canvas のサイズを devicePixelRatio に合わせている場合は、130 * devicePixelRatio のように掛けないと、画面の表示より小さく描画されます。
描画サイズと devicePixelRatio
描画サイズを省略すると、canvas を画面に表示したときに、要素が CSS で指定したサイズどおりに見えるように描画されます。描画サイズは、要素の CSS のサイズに「canvas の width ÷ canvas の CSS の幅」を掛けた値です(高さも同様)。たとえば、CSS の幅が 400px で width が 800 の canvas では、幅 200px の要素は 400 ピクセルで描画され、画面上では 200px に見えます。
canvas の width と height を、CSS のサイズに devicePixelRatio を掛けた値にしておくと、この比率は devicePixelRatio になり、要素は物理ピクセルと 1 対 1 で描画されます。既定の描画サイズは、スナップショットを ElementImage として取得する captureElementImage() の戻り値の width と height で確認できます。canvas をそのように設定し、200×100 の要素を描画した結果は次のとおりでした。
devicePixelRatio | captureElementImage() の width、height | 描画範囲 |
|---|---|---|
| 1 | 200、100 | 200×100 |
| 2 | 400、200 | 400×200 |
canvas の width と height を CSS のサイズのままにしている場合は、比率が 1 になり、要素は CSS のサイズのまま描画されます。はみ出すことはありませんが、canvas の解像度が物理ピクセルより低いので、devicePixelRatio が 2 の画面ではぼやけて表示されます。explainer の例のように、canvas に CSS のサイズを指定したうえで、width と height を devicePixelRatio に合わせておきます。CSS のサイズを指定しないと、width を大きくした分だけ canvas の表示サイズも大きくなり、比率が変わらない点に注意してください。
描画されないもの
HTML-in-Canvas は、canvas からピクセルを読み出せることを前提に設計されています。そのため、JavaScript から取得できない情報は描画されません。explainer では、次のようなものが挙げられています。
- クロスオリジンの
<img>、CSS のurl()、iframe の中身(同一オリジンの iframe は描画される) - システムカラーやテーマ
- スペルチェックの下線
- 訪問済みリンクの情報
- サブピクセルアンチエイリアス
画像で検証すると、同一オリジンの画像は描画され、クロスオリジンの画像は透明になりました。クロスオリジンでも、crossorigin="anonymous" を指定して CORS で許可された画像は描画されます。
読み込みを待つ
スナップショットは、その時点で読み込みが完了しているリソースだけで記録されます。画像とフォントの読み込みが完了してから requestPaint() を呼び出します。
await Promise.allSettled([...element.querySelectorAll("img")].map((image) => image.decode()));
await document.fonts.ready;CSS アニメーションを動画にする
DOM を画像化できるなら、それをフレームごとに繰り返して動画も作成できます。ただし、画面に表示されているアニメーションをリアルタイムでキャプチャすると、描画やエンコードが 1 フレームの時間内に終わらないときにフレーム落ちが発生します。
そこで、アニメーションの再生を止めて、フレームごとにその時刻へシークしてから描画します。CSS アニメーションは Web Animations API の Animation オブジェクトとして取得でき、一時停止して currentTime を設定すると、その時刻の状態になります。
- 要素の
getAnimations({ subtree: true })で取得したアニメーションを一時停止し、currentTimeをフレームの時刻に設定する requestPaint()を呼び出し、次のpaintイベントを待つdrawElementImage()で canvas に描画する- canvas をエンコーダに渡す
描画する時刻を自分で制御するので、1 フレームの描画に時間がかかってもフレーム落ちは発生しません。
ただし、エクスポート中はタブを表示したままにしておく必要があります。タブがバックグラウンドになるとレンダリング更新が止まり、paint イベントが発火しなくなります。このとき drawElementImage() は例外を出さずに古いスナップショットを描画するので、同じフレームが並んだ動画になります。paint イベントの待機にはタイムアウトを設定し、タイムアウトしたらエクスポートを中断するとよいでしょう。
コード
エンコードと、エンコードしたデータの MP4 ファイルへの格納(mux)には Mediabunny を使います。CanvasSource に canvas を渡すと、add() を呼び出した時点の canvas の内容を 1 フレームとしてエンコードします。この例の canvas には CSS のサイズを指定していないので、描画サイズを省略しても canvas と同じサイズで描画されますが、動画の解像度を確実に揃えるために、描画サイズを canvas のサイズに合わせて明示しています。バンドラを使わずに読み込む場合は、import map で mediabunny のパスを指定してください。
<style>
#scene {
width: 320px;
height: 180px;
background: #000;
}
.box {
width: 40px;
height: 40px;
animation: move 1s linear infinite;
}
@keyframes move {
from { translate: 0 70px; background: rgb(255, 0, 0); }
to { translate: 280px 70px; background: rgb(0, 0, 255); }
}
</style>
<canvas id="canvas" content="drawable">
<div id="scene" drawable><div class="box"></div></div>
</canvas>
<script type="module">
import { BufferTarget, CanvasSource, Mp4OutputFormat, Output, Quality } from "mediabunny";
const canvas = document.getElementById("canvas");
const scene = document.getElementById("scene");
const ctx = canvas.getContext("2d");
canvas.width = 320;
canvas.height = 180;
const paint = () =>
new Promise((resolve) => {
canvas.addEventListener("paint", resolve, { once: true });
canvas.requestPaint();
});
const fps = 30;
const frames = 60;
const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });
const source = new CanvasSource(canvas, { codec: "avc", quality: new Quality("high") });
output.addVideoTrack(source, { frameRate: fps });
await output.start();
for (let i = 0; i < frames; i++) {
for (const animation of scene.getAnimations({ subtree: true })) {
animation.pause();
animation.currentTime = (i / fps) * 1000;
}
await paint();
ctx.reset();
ctx.drawElementImage(scene, 0, 0, canvas.width, canvas.height);
await source.add(i / fps, 1 / fps);
}
await output.finalize();
const mp4 = new Blob([output.target.buffer], { type: "video/mp4" });
</script>エクスポートした MP4 からフレームを抽出して並べると、次のようになりました。
エクスポートした MP4 は、60 フレーム、2 秒でした。Windows の Chrome Canary で、CanvasSource の設定に hardwareAcceleration: "prefer-software" を追加してソフトウェアエンコーダを使い、Mediabunny でデコードしてボックスの位置の色を取得すると、0 秒のフレームで 255, 0, 2、0.5 秒のフレームで 127, 0, 127 で、アニメーションの各時刻の色(赤と、赤と青の中間の紫)と一致しました。H.264 の非可逆圧縮により、値は元の色から 2 程度ずれています。
ただし、hardwareAcceleration を指定しない既定の設定では、環境によってハードウェアエンコーダが使われ、色がさらにずれます。同じ環境で既定の設定のままエクスポートすると、0 秒のフレームが 232, 0, 1、0.5 秒のフレームが 119, 0, 125 になりました。色を正確に残したい場合は、ソフトウェアエンコーダを指定します。
シークできるアニメーション
currentTime でシークできるのは、getAnimations() で取得できるアニメーション、つまり CSS アニメーション、CSS トランジション、element.animate() で作成したアニメーションです。requestAnimationFrame() や setTimeout() で駆動している処理は、この方法では止まりません。そうした処理は、フレームの時刻を引数に受け取って状態を生成する関数に書き換え、手順 1 の前に呼び出します。
アニメーションは、DOM やクラスを変更した後に取得します。クラスの付与で開始するアニメーションは、スタイルが計算されるまで Animation オブジェクトが存在しないためです。getAnimations() はスタイルを計算してから結果を返すので、DOM を変更した直後に呼び出せば新しいアニメーションも含まれます。
まとめ
- HTML-in-Canvas では、
drawable要素の最新のスナップショットが描画される。DOM を変更したらrequestPaint()を呼び出し、次のpaintイベントを待ってから描画する - 描画範囲は
drawable要素自身の transform を適用する前の border box。canvas のwidthとheightはdevicePixelRatioに合わせておく - クロスオリジンの画像は描画されない。画像のデコードとフォントの読み込みを待ってからスナップショットを記録させる
- CSS アニメーションは、一時停止して
currentTimeでシークしてから描画すれば、フレーム落ちのない動画にできる。エクスポート中はタブをバックグラウンドにしない