[Astro] #192 Ruffle をセルフホストして作るブラウザ版 Flash プレイヤー「SWF Reel」の実装

[Astro] #192 Ruffle をセルフホストして作るブラウザ版 Flash プレイヤー「SWF Reel」の実装

はじめに

Flash の SWF ファイルを、ブラウザの中で再生するツールを作りました。

再生エンジンは自作ではなく、Rust 製のオープンソースのエミュレーター Ruffle を自分のサイトに置いて使っています。この記事は、その包み方(読み込み・設定・出力)の実装をまとめたものです。

  • 再生: SWF のドロップ(複数可)、一覧での切り替え、一時停止、全画面、音量
  • 設定: 拡大方法、画質、描画方式、リンクの扱い、ネットワーク、背景色
  • 出力: スクリーンショット(PNG)、録画(WebM、音なし)
  • 調査: ファイル情報、ActionScript の trace() 出力
  • ファイルはブラウザの中で再生し、サイトへは送りません(SWF 自身が外へ通信する作りの場合を除く)

スクリーンショット

SWF Reel

動画

SWF Reel

サンプル

サンプルデータは以下のサイトよりお借りしています。

構成

パス中身
src/pages/swf-reel.astro画面(3 ペイン)
public/tool/swf-reel/swf-reel.jsアプリ本体(素の JS、1 ファイル)
public/tool/swf-reel/ruffle/Ruffle の selfhosted 一式

Ruffle は npm の @ruffle-rs/ruffle(0.7.1、MIT OR Apache-2.0)から取得しました。npm pack で展開すると、次のファイルが入っています。

  • ruffle.js(約 466KB)
  • core.ruffle.*.js 2 本
  • *.wasm 2 本(各 約 14MB)
  • LICENSE_MIT / LICENSE_APACHE

.map は置いていません。WASM が 2 本あるため、リポジトリは約 29MB 増えます。1 ファイルずつは Cloudflare Pages の 25MiB の上限の下です。更新するときは、ruffle/ を丸ごと置き換えます。

名前は、Ruffle が別プロジェクトの名前なので使わず、「SWF Reel」にしました。画面のフッターとヘルプに、Ruffle で再生していること・ライセンス・Ruffle プロジェクトとは関係がないことを書いています。

Ruffle の読み込み

ruffle.js を <script> で直接書かず、swf-reel.js から、最初のファイルが来たときに読み込みます。WASM が約 14MB あるので、使わない人には読ませないためです。

window.RufflePlayer = window.RufflePlayer || {};
window.RufflePlayer.config = Object.assign({
  publicPath: '/tool/swf-reel/ruffle/',
  polyfills: false,      // ページ内の <embed>/<object> を書き換えない
  logLevel: 'warn',
  splashScreen: false,
  unmuteOverlay: 'hidden'
}, window.RufflePlayer.config || {});

// ruffle.js を <script> として足して、onload を待つ
const api = window.RufflePlayer.newest();
  • publicPath は、core.ruffle.*.js と *.wasm を探す場所です。末尾の / を付けています。
  • 読み込み方は import() ではなく、<script> を足す形です。/public の JS を import() すると、Vite の dev サーバーが拒否するためです。
  • WASM は 2 本ありますが、どちらを読むかは Ruffle が決めます。

プレイヤーの作り方

SWF は File のまま持ち、再生するときに Blob URL にして渡します。ファイルを切り替えるたびに、プレイヤーの要素を作り直します。前の設定や状態が残らないようにするためです。

const url = URL.createObjectURL(item.file);
const p = api.createPlayer();
stage.appendChild(p);
await p.load({
  url,
  autoplay: 'off',            // 後で play() する(理由は TRACE の節)
  unmuteOverlay: 'hidden',
  splashScreen: false,
  warnOnUnsupportedContent: false,
  allowFullscreen: true,
  scale: S.scale,
  forceScale: true,           // SWF 自身の指定より、こちらの SCALE を優先
  openUrlMode: S.url,
  allowNetworking: S.net,
  logLevel: 'warn',
  // 以下は設定されたときだけ
  // quality, preferredRenderer, backgroundColor
});

操作に使った Ruffle のプレイヤー要素の API は、load play pause isPlaying volume metadata loadedConfig enterFullscreen traceObserver です。suspend / resume は、このビルドにはありませんでした。

設定の値

設定値
scaleshowAll / noBorder / exactFit / noScale
qualitylow / medium / high / best(未設定で SWF の指定)
preferredRendererwebgpu / wgpu-webgl / canvas(未設定で自動)
openUrlModeallow / confirm / deny
allowNetworkingall / internal / none

openUrlMode の既定は allow です(confirm ではありません)。SWF が外のページを自由に開けてしまうので、このツールの初期値は confirm にしています。

設定を変えると、同じファイルを最初から読み込み直します(実行中のプレイヤーの設定は、後から変えられないため)。設定と音量は localStorage に保存します。

metadata は、読み込みのあとで埋まる

load() が解決した直後は、player.metadata が空です。ステージの大きさ・fps・フレーム数・SWF バージョン・AS3 かどうか・展開後のサイズは、少し後に入ります。情報欄は 100ms ごとに最大 15 秒、入るまで待ってから更新します。

function waitMetadata(p, token) {
  return new Promise(resolve => {
    let tries = 0;
    (function poll() {
      if (token !== loadToken) return resolve(null);   // 別のファイルに切り替わった
      const m = p.metadata;
      if (m) return resolve(m);
      if (++tries > 150) return resolve(null);
      setTimeout(poll, 100);
    })();
  });
}

loadToken は、読み込みのたびに増やす番号です。ファイルを続けて切り替えたとき、古い読み込みの結果で画面を上書きしないために使っています。

TRACE(ActionScript の trace())

player.traceObserver に関数を入れると、trace() の出力が届きます。ただし、load() の前に設定しても効きません(インスタンスが無いため、無視されます)。load() の後に設定すると、最初のフレームの trace() を取りこぼす可能性があります。

そこで、autoplay: 'off' で読み込み、observer を設定してから play() する順にしています。

await p.load(config);          // autoplay: 'off' なので、まだ止まっている
p.traceObserver = onTrace;     // ここで設定
p.play();

出力は最大 2000 行まで持ち、描画は requestAnimationFrame でまとめます。

スクリーンショット

Ruffle の描画先は、プレイヤー要素の shadowRoot の中の <canvas> です。WebGL(wgpu-webgl)のとき、この canvas の中身はフレームが画面に出たあと消えるので、drawImage で読むと透明になることがあります。

対策は 2 つです。

  1. requestAnimationFrame の中でコピーし、中身のあるフレームが取れるまで、最大 30 フレーム再試行する
  2. 一時停止中は何度やっても取れないので、PAUSE を押した瞬間に 1 コマ保存しておき、停止中のスクリーンショットはそれを使う
function grabFrame(c, tries, cb) {
  requestAnimationFrame(() => {
    const tmp = document.createElement('canvas');
    tmp.width = c.width; tmp.height = c.height;
    const ctx = tmp.getContext('2d', { willReadFrequently: true });
    ctx.drawImage(c, 0, 0);
    // 5 点をサンプルして、すべて透明なら「空」
    // 空なら tries を減らして再試行、0 になったら null を返す
  });
}

30 フレーム試しても空なら、「RENDERER を Canvas 2D にして、もう一度試してください」と表示します。canvas 描画のときは、バッファが残るので普通に取れます。

録画

canvas.captureStream(60) と MediaRecorder を使います。形式は WebM で、video/webm;codecs=vp9 → vp8 → video/webm の順に、使えるものを選びます(8Mbps、1 秒ごとに区切って保存)。

音は入りません。 Ruffle の音は Web Audio の内側にあり、外から取り出せないためです。ボタンの名前も「REC (WebM, no audio)」にしています。

検証

ヘッドレスのブラウザ(Chromium 153、WebGL はソフトウェア描画)

手で作った最小の SWF(背景色だけのもの、trace() を 2 回出すもの、800×200、壊れたファイル)で、37 項目を確認しました。

  • 再生した画面の中央の画素が、SWF の背景色になる
  • 複数ファイルの一覧・前 / 次・削除・全部消して空に戻る
  • ステージの大きさ・fps・AS の種類が表示される
  • trace() の 2 行が TRACE に出る
  • 一時停止の表示が切り替わり、音量が player.volume に入る
  • スクリーンショット(動作中・一時停止中とも、PNG の中身が SWF の色)
  • 録画(WebM を保存し、<video> で読み直して、1 秒後の画素が SWF の色)
  • 設定が loadedConfig に入り、canvas 描画でも描ける
  • .swf 以外のファイルを弾く、F1 / Esc、コンソールとページのエラーが 0 件
  • 外部ホストへの通信をすべて遮断した状態で、上のすべてが通る

実機(Windows の Chrome、Astro の dev サーバー)

sample-360p.swf(7.32MB、636×360、25fps、1345 フレーム、AS1/AS2)が再生され、情報欄も表示されました。描画は wgpu-webgl、WebAssembly 拡張が ON でした。

制限と未確認

  • 外のファイルを読む SWF は、動かないことがあります。 ファイルを Blob URL で渡すので、相対パスで画像・XML・別の SWF を読むゲームは、読み込めません。
  • ActionScript 3 の SWF は、部分的な対応です。 Ruffle 側の制限です。情報欄の ActionScript の欄で、AS1/AS2 と AS3 を見分けられます。
  • ヘッダーに書かれた長さと、実際のファイルサイズが合わない(途中で切れた)ファイルは、Ruffle 自身のエラー画面が出ます。
  • 未確認:
    • 音(ヘッドレスでは確認できません)
    • 実機でのスクリーンショットと録画
    • ゲーム系の SWF(キー・マウス操作)
    • AS3 の SWF
    • Quest・スマホ・Safari・Firefox
    • RENDERER を WebGPU にした場合
    • 窓モード
    • デプロイ先での動作

まとめ

  • Ruffle は、ruffle.js と *.wasm を自分のサイトに置き、publicPath を指定すれば、そのまま動きます。
  • 実装で注意した点は、次の 5 つです。
    • openUrlMode の既定は allow
    • traceObserver は load() の後でないと効かない
    • metadata は読み込みの後に埋まる
    • WebGL の canvas は、フレームの外では読めない
    • 録画に音は入らない
  • ファイルは、ブラウザの中で再生します。
  • 再生エンジンは Ruffle(MIT OR Apache-2.0)です。Ruffle プロジェクトとは関係のない、個人の作品です。