Skip to content
Go back

Twitter時代に使えたビデオクリップ機能をChrome拡張で再現してみた

公開:

旧Twitterの「ブラウザでサクッと動画を切り抜ける」クリップ機能、あれ地味に神でしたよね。 ゲームのクリップを上げるだけなら十分すぎる体験だったのに、いつの日か機能自体消えてしまいました。

無いなら作ればいい、ということで Chrome拡張で“当時の体験”を復活させました。
どうせなら「新しい技術で殴りたい」ので、ffmpeg.wasm + WebCodecs でフロント完結の動画加工処理を実装しました。

目次

Open 目次

作ったモノ

Easy Video Trimmer for X.com

設計

全体のワークフローはこんな感じです。

easy-video-trimmer-sequence

実装に苦労したポイント

X.com に加工した動画を“直接”添付したい

こだわりポイントその1です。

毎回「トリミングした MP4 をいったんダウンロード → 手動で添付」というフローになるのを避けたかったため、 トリミング完了後の MP4 を、X の投稿フォームに自動で差し込む 作りにしました。

具体的には、chrome.scripting.executeScriptworld: "MAIN" で実行し、 ページ側コンテキストで input[data-testid="fileInput"]DataTransfer 経由で File を差し込むようにしています。

Chrome 拡張のコンテンツスクリプトは isolated world で動作するため、ページ側の JavaScript とは実行コンテキストが分離されています。 さらに X.com は CSP がかなり厳しく、任意の <script> をインラインで差し込むようなことも基本的にはできません。
そこで executeScript を使ってメインワールド側で処理を実行することで、“X 側の DOM / イベントハンドラ”と連携できるようにしました。

const [result] = await chrome.scripting.executeScript({
  target: { tabId },
  world: "MAIN",
  args: [base64, fileName, "video/mp4"],
  func: (b64, name, type) => {
    // base64 → Uint8Array
    const bin = atob(b64);
    const bytes = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) {
      bytes[i] = bin.charCodeAt(i);
    }

    // executeScript の引数は構造化複製できる必要があるため、
    // トリミング済みの Blob はいったん base64 に変換してから渡し、
    // メインワールド側で Blob / File に復元しています。
    const blob = new Blob([bytes], { type });
    const file = new File([blob], name, { type });

    // DataTransfer 経由で <input type="file"> に差し込む
    const dt = new DataTransfer();
    dt.items.add(file);

    const input =
      document.querySelector('input[data-testid="fileInput"]') ||
      document.querySelector('input[type="file"][accept*="video"]');

    if (!input) {
      return { attached: false, reason: "Tweet file input not found" };
    }

    input.files = dt.files;
    input.dispatchEvent(new Event("change", { bubbles: true }));
    input.dispatchEvent(new Event("input", { bubbles: true }));

    return { attached: true };
  },
});

world: "MAIN" のコードはページと同じ世界で動きますが、逆に chrome.* API は使えないので、引数と戻り値でやり取りする設計にしています(ここも地味に罠でした)。

これに伴い、manifest.json には以下のように権限を追加しています。

"permissions": ["scripting"]
"host_permissions": ["https://x.com/*"]

ffmpeg.wasm でエンコードすると遅すぎる

こだわりポイントその2です。

まず一番のボトルネックは、WASM 版 ffmpeg のエンコード速度がネイティブ版と比べて桁違いに遅いことでした。

タスクネイティブ ffmpeg 6.1ffmpeg.wasm 0.12.x
720p MPEG-2 → H.264(約 4 分の動画)約 500 fps約 40 fps

公式ドキュメントでも「ネイティブより遅い」「マルチスレッド版でも限界がある」前提が書かれています。
参照: https://ffmpegwasm.netlify.app/docs/performance/

遅くなる根本原因はシンプルに言うと次の 2 点です。

  1. WASM からは GPU エンコーダが使えない
    • ffmpeg.wasm はブラウザ上の WebAssembly なので、 NVENC / QuickSync / VideoToolbox などのハードウェアエンコーダにアクセスできない。
    • そのため、エンコード速度はCPUのスペック依存になる
  2. マルチスレッドにも制約がある
    • Chromium 環境では 4 thread までしか正常に動かないバグがある
    • つまり、CPU が強くてもフルで使えない

この結果として、長尺の動画をエンコードしようとすると、待ち時間が現実的でないレベルに伸びる。
日常的に使うツールとしては厳しいと判断。

解決策として、WebCodecs + ffmpeg.wasm のハイブリッド構成にしました。

クリップ先頭の数秒だけ壊れる問題(mid-GOP罠)

こだわりポイントその3、いちばんハマったやつです。

ffmpeg.wasm-c copy して切り出した MP4 を WebCodecs で再エンコードすると、 クリップの先頭 1〜2 秒だけブロックノイズ/カクつきが出ることがありました。

原因はざっくり言うと「GOP(キーフレーム)境界を無視して中途半端な位置から始まる」ケースです。
-c copy は再エンコードしないので速い反面、切り出し位置によっては先頭のデコードが不安定になりやすいです。

ここで効いたのが、オプションの置き方と -copyinkf でした。

修正前(出力側 seek)

ffmpeg -i input.mp4 -ss 00:36:18 -to 00:39:50 -c copy out.mp4

修正後(入力側 seek + copyinkf)

# 修正後(入力側 seek + キーフレーム寄せ + copyinkf)
ffmpeg -ss 00:36:18 -i input.mp4 -to 00:39:50 -c copy -copyinkf out.mp4

ポイントは 2 つです。

  1. -ss を 入力オプション(-i の前)にすると、基本的には「近いシークポイントに飛ぶ」動きになります。(ffmpeg は demuxer レベルで seek します。)
  2. -copyinkf は「ストリームコピー時、先頭で見つかった 非キーフレームもコピーする」というオプション

おわりに

新しい技術に触れながら何かを作ってみると学びが多くて楽しいですね。
X.com 側のDOMや仕様変更で壊れる可能性はあるので、そこは保守運用で対応します(あまりにも変更が多かったら終わり。イーロン、UIを変えまくるなよ)。

また何かあれば追記します。


Share this post on:

Previous Post
Discord に Youtube Music で再生してる曲を表示させる方法
Next Post
株で200万円損切りした話。