How it works
動画ではなく、
プログラムです。
このリールは MP4 ではなく、1つの HTML ファイルです。映像は Canvas に、音は Web Audio に、見ている人の端末がその場で描いて鳴らしています。画像も音声ファイルも読み込んでいません(外から取ってくるのは Google Fonts の2書体だけ)。
- 25秒版のサイズ
- 89 KB
- 配信時(gzip)
- 31 KB
- 行数
- 1,450
- 粒子の数
- 1,400
01時間は1本だけ
全部の絵は render(t) という1つの関数が、経過秒 t だけから描いています。前のコマの状態を持ち越さないので、どの瞬間でも同じ絵を何度でも作り直せます。一時停止も、途中からの再生も、1コマずつの書き出しも、この性質だけで成り立っています。
function tick() {
const t = (performance.now() - perfStart) / 1000; // 音が聞こえた瞬間を 0 とした経過秒
render(t); // 絵は t だけで決まる
requestAnimationFrame(tick); // 60fps で呼ばれる
}
URL に ?t=秒 を付けると、その瞬間で止まった1枚が出ます。
02等速の動きを使わない
動きはすべて「入りは速く、止まりは柔らかく」。指数カーブと、行き過ぎて揺れ戻るばね(減衰振動)の式で動かしています。主な動きは 120BPM の拍(0.5秒)の頭で始まり、見せ場の直前には 0.4 秒の無音の「溜め」を置いています。
// ばね: 経過秒 s に対して 0→1(行き過ぎて揺れ戻る)
function spring(s, w = 20, z = 0.42) {
if (s <= 0) return 0;
const wd = w * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w * s) * (Math.cos(wd * s) + (z * w / wd) * Math.sin(wd * s));
}
03音もその場で合成している
キックは周波数を急降下させたサイン波、ハイハットは高音だけ通したノイズ、残響は乱数で作ったインパルス応答への畳み込み、和音はわずかにずらしたノコギリ波。再生ボタンを押した瞬間に、映像と同じ時刻表から数百の音をまとめて予約します。
kick(at, v = 1) {
const o = osc('sine', 150, at, 0.5), g = ac.createGain();
o.frequency.exponentialRampToValueAtTime(46, at + 0.12); // 150Hz → 46Hz で「ドン」
env(g, at, v, 0.003, 0.42); o.connect(g); out(g);
}
- 映像の時計は
AudioContext.getOutputTimestamp()で「スピーカーから音が出る時刻」に合わせ、出力の遅れを打ち消しています。 - iPhone のマナーモードでも鳴るように、
navigator.audioSession.type = 'playback'を指定しています。
04Canvas 2D だけで立体に見せる
- WebGL は使っていません。コードの面の傾きや粒子の球は、自前の透視投影(奥行きで割る)で位置を出して描いています。
- 粒子 1,400 個の飛び方は、種を固定した乱数(mulberry32)で決めています。だからどのコマも再現できます。
- 場面転換の円・斜め・文字の形の窓は、クリップと裏キャンバスの合成(
destination-in)。画面の中の画面は、Canvas を自分自身に描き込んでいます。
05重くなったところと直し方
- 約500文字を1文字ずつ傾けて描いたら、GPU で1コマ数秒かかった → 起動時に1行ずつ画像へ焼き、毎コマは画像を小片に分けて傾けて置くだけにした。
- 角丸の
roundRectがある GPU で周期的なコマ落ちを起こした → 四角に置き換えてコマ落ち 0 に。 - 場面の初回に字や形を描く瞬間の引っかかり → 読み込み時に各場面を一度ずつ裏で描いておく。
実測: GPU(Radeon 780M)での実再生で 60fps・25ms を超えるコマ 0。
06それでも動画にできる
render(t) が決定的なので、ヘッドレスの Chromium で1コマずつ描いて撮り、ffmpeg に流し込めば MP4 になります。音は同じ時刻表を OfflineAudioContext で合成して WAV にし、映像と重ねます。
for (let i = 0; i <= dur * fps; i++) {
await page.evaluate(t => window.__seek(t), i / fps); // その瞬間を描く
await write(await page.screenshot({ type: 'png' })); // ffmpeg の標準入力へ
}
07ソースを読む
全部そのまま読めます。PC ならリールを開いて開発者ツールで __seek(13.7) と打つと、その瞬間に飛べます。