X/Twitter DM モックアップツール開発ログ
目的
Mockly (https://www.getmockly.com/) を参考に、X/Twitter の DM 画面をモックアップできるツールを作成する。
必要な機能
- People セクション: Sender/Receiver の名前を動的に変更できる ✅ 実装済み
- Messages セクション: メッセージを動的に追加・編集・削除できる ✅ 実装済み
- アニメーション付き動画/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);
}
失敗の理由:
html2canvasがopacity: 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で未来/現在/過去の要素を個別に制御- 既存のメッセージが動かない
結果: スムーズなアニメーションで、既存のメッセージ位置が固定される
根本的な問題の分析
- html2canvas の制約:
- 処理が重い(1フレーム100-500ms)
- リアルタイムアニメーションキャプチャに不向き
- DOM を canvas にレンダリングする間接的な方法
- 重要: インラインスタイル
opacity: 0を正しくキャプチャしない場合がある
- ブラウザのセキュリティ制約:
file://プロトコルでは Web Worker が使えない- 外部スクリプトの CORS 制限
- 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 でヘッドレスブラウザを使用
- サーバー側で動画を生成
- クライアントはダウンロードのみ
使用ライブラリ
| ライブラリ | バージョン | 用途 | 問題 |
|---|---|---|---|
| html2canvas | 1.4.1 | DOM → Canvas | 処理が遅い |
| gif.js | 0.2.0 | GIF エンコード | Worker が file:// で動かない |
| gifenc | 1.0.3 | GIF エンコード | パレット管理が難しい |
ファイル構成
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動画出力 で成功。
html2canvasのopacityキャプチャ問題を回避するため、要素を「隠す」のではなく「存在させない」アプローチを採用- GIF ではなく WebM 動画として出力(256色制限を回避)
canvas.captureStream(0)+MediaRecorderで動画を生成
動作環境
- ローカルサーバー必須:
file://プロトコルでは一部機能が制限される - 推奨:
python -m http.server 8000またはnpx serve .でローカルサーバーを起動
今後の改善候補
- Canvas直接描画に切り替える - html2canvas を使わず高速化
- Webサーバーにデプロイ - ユーザーがサーバーを起動する必要がなくなる
- MP4出力対応 - WebM より互換性が高い(要サーバーサイド処理)