• #X
  • #Twitter
  • #Mockup
  • #html2canvas
  • #GIF
  • #WebM

X/Twitter DM モックアップツール開発ログ

目的

Mockly (https://www.getmockly.com/) を参考に、X/Twitter の DM 画面をモックアップできるツールを作成する。

必要な機能

  1. People セクション: Sender/Receiver の名前を動的に変更できる ✅ 実装済み
  2. Messages セクション: メッセージを動的に追加・編集・削除できる ✅ 実装済み
  3. アニメーション付き動画/GIFエクスポート: メッセージが順番に表示されるアニメーションを保存 ⚠️ 問題あり

現在の状態

動作しているもの

  • People セクション(Sender/Receiver の名前変更)
  • Messages セクション(追加・編集・削除・日時変更)
  • プレビュー表示
  • プレイボタンによるアニメーションプレビュー

問題が発生しているもの

  • GIF/動画エクスポート: 生成されたGIFがチラつく(正しくアニメーションしない)

試したアプローチと失敗の記録

試行1: MediaRecorder + canvas.captureStream() ❌

const stream = canvas.captureStream(30);
const mediaRecorder = new MediaRecorder(stream, {
    mimeType: 'video/webm;codecs=vp9'
});

// アニメーション中にフレームをキャプチャ
mediaRecorder.start();
await animateMessages();
mediaRecorder.stop();

失敗の理由:

  • html2canvas が非同期で遅い(1フレーム数十〜数百ms)
  • captureStream() はcanvasへの描画を検出してフレームを生成するが、タイミングが不安定
  • アニメーションとフレームキャプチャが同期しない
  • 結果: 1秒程度の動画しか生成されない

試行2: プリキャプチャ + sleep() で描画 ❌

// 全フレームを事前にキャプチャ
const frames = [];
for (...) {
    frameCanvas = await html2canvas(...);
    frames.push(frameCanvas);
}

// 録画しながらフレームを描画
mediaRecorder.start();
for (const frame of frames) {
    ctx.drawImage(frame, 0, 0);
    await sleep(33); // 30fps
}
mediaRecorder.stop();

失敗の理由:

  • sleep() はJavaScript実行を止めるだけで、ブラウザのレンダリングサイクルとは同期しない
  • captureStream() がフレームを正しく検出しない
  • 結果: 静止画がチカチカするだけ

試行3: gif.js ライブラリ ❌

const gif = new GIF({
    workers: 2,
    workerScript: 'https://cdnjs.cloudflare.com/...'
});

失敗の理由:

  • Web Worker を使用するが、file:// プロトコルでは外部Workerスクリプトを読み込めない
  • CORSエラー: Failed to construct 'Worker'
  • ローカルにWorkerをダウンロードしても、file:// からは読み込み不可

試行4: gifenc ライブラリ(Worker不要) ⚠️ 部分的成功

const { GIFEncoder, quantize, applyPalette } = gifenc;
const gif = GIFEncoder();

// フレームごとにパレットを作成
for (const frame of frames) {
    const palette = quantize(frameData, 256);
    const index = applyPalette(frameData, palette);
    gif.writeFrame(index, width, height, { palette, delay });
}

問題:

  • フレームごとに異なるパレット(256色)を使用
  • 色が微妙に変わり、GIFがチラつく

試行5: 共有パレット + シンプル化 ❌

// 最終フレームからパレットを作成
const palette = quantize(finalFrameData, 256);

// 全フレームで同じパレットを使用
for (const frame of frames) {
    const index = applyPalette(frameData, palette);
    gif.writeFrame(index, width, height, {
        palette: i === 0 ? palette : null,
        delay: frame.delay
    });
}

失敗の理由: GIF自体は改善されたが、根本的に html2canvas が inline style の opacity: 0 を正しくキャプチャしない問題が残る

試行6: WebM動画 + MediaRecorder ❌

const stream = canvas.captureStream(0);
const mediaRecorder = new MediaRecorder(stream, {
    mimeType: 'video/webm;codecs=vp9'
});

// フレームごとに描画
for (const frame of frames) {
    ctx.drawImage(frame, 0, 0);
    videoTrack.requestFrame();
    await sleep(33);
}

失敗の理由:

  • html2canvasopacity: 0 の要素を「見える状態」でキャプチャしてしまう
  • 日付だけアニメーションして、メッセージは最初から全部見える状態

試行7: プログレッシブDOM構築 ⚠️ 部分的成功

// 最初にメッセージエリアを空にする
previewMessages.innerHTML = '';

// 要素を一つずつ追加しながらキャプチャ
for (const element of elementsToAdd) {
    const newElement = createElementFromHTML(element.content);
    newElement.style.opacity = '0';
    previewMessages.appendChild(newElement);

    // フェードインアニメーションをキャプチャ
    for (let step = 0; step <= 5; step++) {
        newElement.style.opacity = String(step / 5);
        frameCanvas = await html2canvas(previewDeviceEl);
        frames.push(frameCanvas);
    }
}

問題:

  • アニメーションは正しく動作するが、色が薄くなる問題が発生
  • html2canvas が CSS変数 (var(--message-sent-bg)) を正しく解決できない
  • opacity のインラインスタイルも正しくキャプチャされない

試行8: onclone コールバックで強制的にスタイルを適用 ✅ 成功

const captureOptions = {
    backgroundColor: '#ffffff',
    scale: 2,
    useCORS: true,
    allowTaint: true,
    onclone: (clonedDoc) => {
        // 強制的に色とopacityを設定
        clonedDoc.querySelectorAll('.message-group.sent .message-bubble')
            .forEach(el => {
                el.style.cssText += 'background-color: #1d9bf0 !important; opacity: 1 !important;';
            });
        clonedDoc.querySelectorAll('.message-group.received .message-bubble')
            .forEach(el => {
                el.style.cssText += 'background-color: #eff3f4 !important; opacity: 1 !important;';
            });
        clonedDoc.querySelectorAll('.message-avatar')
            .forEach(el => {
                el.style.cssText += 'background-color: #1d9bf0 !important; opacity: 1 !important;';
            });
        clonedDoc.querySelectorAll('.message-group, .date-separator')
            .forEach(el => {
                el.style.cssText += 'opacity: 1 !important;';
            });
    }
};

frameCanvas = await html2canvas(previewDeviceEl, captureOptions);

成功のポイント:

  • onclone コールバックでクローンされたDOMを修正
  • !important で他のスタイルを上書き
  • CSS変数を直接の色コードに置き換え
  • opacity: 1 を強制的に設定

結果: 色が正確にキャプチャされ、WebM動画が正しく出力される

試行9: レイアウトシフト問題の修正 ✅ 成功

問題: 新しいメッセージが追加されると、既存のメッセージが上に動いてしまう(レイアウトが再計算されるため)

原因: 要素を一つずつDOMに追加すると、フレックスボックスのレイアウトが毎回再計算され、既存の要素の位置がずれる

解決策: 事前にすべての要素を非表示でDOMに追加し、レイアウトを固定してからアニメーションを開始

// Step 1: すべての要素を非表示でDOMに追加(レイアウト固定)
const addedElements = [];
for (let i = 0; i < elementsToAdd.length; i++) {
    const newElement = createElementFromHTML(element.content);
    newElement.style.opacity = '0';
    newElement.style.visibility = 'hidden';
    newElement.setAttribute('data-animate-id', i);
    previewMessages.appendChild(newElement);
    addedElements.push(newElement);
}

// レイアウトを安定させる
void previewMessages.offsetHeight;
await sleep(100);

// Step 2: 各要素を順番にアニメーション
for (let i = 0; i < addedElements.length; i++) {
    // onclone で各フレームの状態を制御
    onclone: (clonedDoc) => {
        // 未来の要素 → 非表示
        for (let k = i + 1; k < addedElements.length; k++) {
            clonedDoc.querySelector(`[data-animate-id="${k}"]`)
                .style.cssText += 'opacity: 0 !important; visibility: hidden !important;';
        }
        // 現在の要素 → アニメーション中の状態
        clonedDoc.querySelector(`[data-animate-id="${i}"]`)
            .style.cssText += `opacity: ${currentOpacity} !important; transform: translateY(${translateY}px) !important;`;
        // 過去の要素 → 完全表示
        for (let j = 0; j < i; j++) {
            clonedDoc.querySelector(`[data-animate-id="${j}"]`)
                .style.cssText += 'opacity: 1 !important; visibility: visible !important;';
        }
    }
}

成功のポイント:

  • すべての要素を最初に追加してレイアウトを固定
  • data-animate-id 属性で各要素を識別
  • onclone で未来/現在/過去の要素を個別に制御
  • 既存のメッセージが動かない

結果: スムーズなアニメーションで、既存のメッセージ位置が固定される


根本的な問題の分析

  1. html2canvas の制約:
    • 処理が重い(1フレーム100-500ms)
    • リアルタイムアニメーションキャプチャに不向き
    • DOM を canvas にレンダリングする間接的な方法
    • 重要: インラインスタイル opacity: 0 を正しくキャプチャしない場合がある
  2. ブラウザのセキュリティ制約:
    • file:// プロトコルでは Web Worker が使えない
    • 外部スクリプトの CORS 制限
  3. GIF の制約:
    • 256色パレット制限
    • フレームごとにパレットが異なると色がチラつく

解決策

html2canvas で要素を「隠す」のではなく「存在させない」

問題: element.style.opacity = '0' を設定しても、html2canvas がその要素を見える状態でキャプチャしてしまう。

解決: 要素をDOMから完全に削除し、アニメーションの進行に合わせて一つずつ追加する。

// ❌ 失敗するアプローチ
elements.forEach(el => el.style.opacity = '0');  // 隠したつもり
await html2canvas(container);  // でも見える状態でキャプチャされる

// ✅ 成功するアプローチ
container.innerHTML = '';  // 完全に空にする
await html2canvas(container);  // 空の状態がキャプチャされる
container.appendChild(newElement);  // 要素を追加
await html2canvas(container);  // 追加された要素がキャプチャされる

試していないアプローチ(今後の改善候補)

1. ローカルHTTPサーバーを使用

# Python
python -m http.server 8000

# Node.js
npx serve .
  • http://localhost:8000 でアクセスすれば、Worker が使える
  • gif.js などの高機能ライブラリが使える可能性

2. Canvas への直接描画(html2canvas を使わない)

// DOM を経由せず、直接 canvas に描画
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = '#1d9bf0';
ctx.fillText('Hello', x, y);
// ...
  • 高速(リアルタイムアニメーション可能)
  • ただし、DOM レイアウトを再実装する必要がある

3. WebCodecs API

const encoder = new VideoEncoder({
    output: (chunk) => { /* ... */ },
    error: (e) => console.error(e)
});
encoder.configure({ codec: 'vp8', width, height });
  • より低レベルな動画エンコーディング制御
  • ただしブラウザサポートが限定的(Chrome/Edge のみ)

4. PNG 連番ダウンロード + 外部ツール

// 各フレームを PNG としてダウンロード
for (let i = 0; i < frames.length; i++) {
    const link = document.createElement('a');
    link.download = `frame-${i.toString().padStart(3, '0')}.png`;
    link.href = frames[i].toDataURL();
    link.click();
}
  • ユーザーが ffmpeg などで動画に変換
  • ffmpeg -framerate 10 -i frame-%03d.png output.gif

5. 画面録画 API(getDisplayMedia)

const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
const mediaRecorder = new MediaRecorder(stream);
  • ブラウザの画面を直接録画
  • ユーザーの許可が必要

6. サーバーサイドレンダリング

  • Node.js + Puppeteer でヘッドレスブラウザを使用
  • サーバー側で動画を生成
  • クライアントはダウンロードのみ

使用ライブラリ

ライブラリバージョン用途問題
html2canvas1.4.1DOM → Canvas処理が遅い
gif.js0.2.0GIF エンコードWorker が file:// で動かない
gifenc1.0.3GIF エンコードパレット管理が難しい

ファイル構成

apps/web/content/2025-12-03/
├── message-mockup.html    # メインHTML
├── message-mockup.css     # スタイル
├── message-mockup.js      # JavaScript
├── gifenc.js              # GIF エンコーダー(ローカル)
├── gif.worker.js          # gif.js Worker(未使用)
└── mockup-development-log.md  # このドキュメント

結論

最終的な解決策

プログレッシブDOM構築 + WebM動画出力 で成功。

  • html2canvasopacity キャプチャ問題を回避するため、要素を「隠す」のではなく「存在させない」アプローチを採用
  • GIF ではなく WebM 動画として出力(256色制限を回避)
  • canvas.captureStream(0) + MediaRecorder で動画を生成

動作環境

  • ローカルサーバー必須: file:// プロトコルでは一部機能が制限される
  • 推奨: python -m http.server 8000 または npx serve . でローカルサーバーを起動

今後の改善候補

  1. Canvas直接描画に切り替える - html2canvas を使わず高速化
  2. Webサーバーにデプロイ - ユーザーがサーバーを起動する必要がなくなる
  3. MP4出力対応 - WebM より互換性が高い(要サーバーサイド処理)