stories

コード公開!FlowPickを3ヶ月で作り上げた全記録

不満を感じて自分で作った個人開発者が、問題発見からオープンソース公開までの全記録:webRequest検知、FFmpeg WASMの苦労、TSToMP4Muxerの独自開発、3層ディスク書き込み戦略の実際の開発ストーリー。
FlowPick チーム
16 分で読了
# オープンソース # 個人開発 # wasm # アーキテクチャ # 開発ログ

コードを GitHub にプッシュし、リポジトリを公開しました。

この記事は祝うためではなく、3ヶ月で踏んだ数々の罠を記録しておくためのものです。将来忘れたときに見返せるように、そして同じニーズを持つ人の参考になれば。技術的な詳細にも触れますが、学術的には書きません——普通の言葉で、技術文書ではありません。


きっかけ:数ヶ月我慢し続けた体験

昨年末、あるオンライン講座プラットフォームで技術講座を受講していました。プラットフォームはダウンロード不可。でも私はオフラインで見たい派——通勤の地下鉄は電波が入らないし、カフェのWi-Fiは不安定、帰宅中に特定の部分を繰り返し見たいのにシークバーを操作するのは面倒。

Video DownloadHelperを試しましたが、HLSストリームにはCoAppのインストールが必要。CoAppを入れたら入れたで、暗号化された講座を処理しようとしてエラー。オンラインツールもいくつか試しましたが、使えるものは速度制限があるか、ファイルが大きいと落ちるか、サーバー中継が入っていて不安。

youtube-dlは一部対応できましたが、そのプラットフォームはyt-dlpが認識せず——抽出プラグインがないか、Cookieが渡せない。

数ヶ月放置した後、ある週末にふと思いました。「これってM3U8を解析してセグメントをダウンロードして結合するだけの流れだよな。ブラウザでできるのか?」

MDNを調べてみると、Web Crypto API、File System Access API、WebAssemblyの3つがここ数年で安定しており、Chromeでの互換性は問題ないレベル。じゃあ作ろう。

1週目:「検出できるか」をクリア

あらゆるツールが最初に解決すべき問題:ページにどんな動画があるかをどう知るか。

ブラウザ拡張機能には webRequest というAPIがあり、タブが送信するすべてのネットワークリクエストを監視できます——すべてのリクエストです。ネットワーク層で、DOM層ではありません。プレーヤーがM3U8やMPDリクエストを送信した瞬間、拡張機能がキャプチャします。

拡張機能 → webRequest.onBeforeRequest
         → Content-Typeでフィルタリング
         → application/x-mpegurlにヒット → HLSとして記録
         → application/dash+xmlにヒット  → DASHとして記録
         → video/*にヒット               → 直接リンク動画として記録

この部分は予想より早く書けました。2日後には、拡張機能のポップアップに現在のページの全動画ストリームを一覧表示するプロトタイプができました。Bilibiliでテストすると——映像ストリーム、音声ストリーム、広告ストリームがざっと表示され、全エントリにURLとファイルサイズ付き。

細かい点で手こずりました:M3U8にはマスタープレイリストとメディアプレイリストの2種類があります。マスターは目次で、利用可能な全画質を列挙し、それぞれがメディアプレイリストを指しています。メディアプレイリストこそが実際のセグメントリストです。最初は両方を表示していたため、ユーザーには大量のエントリが見え、どれを選べばいいかわからない状態。後に修正:マスタープレイリストを識別したら、中の画質オプションを自動解析し、人間が理解できる解像度の一覧だけを表示。生のURL階層は表示しません。

3週目:最初の本当に難しい問題——ファイルをどこに書き込むか

数百のセグメントをダウンロードするのは非同期並列処理の問題で、難しくありません。難しいのはデータをどこに書き込むか。

ブラウザのJavaScriptにはファイルシステムへの直接アクセス権限がありません。最も簡単な方法は、全データを1つのBlobにまとめてダウンロードをトリガーすること——URL.createObjectURL(blob) と非表示の <a> タグを使います。

まずこの方法でプロトタイプを動作確認しました。500MBの動画は問題なし。800MB、まあまあ。1GB、Chromeが少し不安定に——理由は明らかで、Blobモードはファイル全体をメモリに読み込む必要があり、1GBの動画ならブラウザが1GBのArrayBufferを握り続けることに。ページ自体と拡張機能のオーバーヘッドを加えると、メモリ負荷が非常に大きい。1.5GB以上は完全にフリーズしました。

Blobモードにハードリミットを設定:1.5GBは処理拒否、800MBから警告。これは問題解決ではなく、問題の範囲を明確にしただけです。

本当の解決策は File System Access API(FSA)。Chrome 86からサポートされ、JavaScriptがファイルハンドルを取得し、WritableStream を通じて継続的に書き込めます。ネイティブプログラムがファイルに書き込むのと同じ——メモリ上には現在書き込み中のデータブロックのみで、ファイル全体のサイズに依存しません。

const writable = await handle.createWritable()
// 16MBのバッファを設定
const stream = new WritableStream({
  async write(chunk) { await writable.write(chunk) },
  async close() { await writable.close() }
}, { highWaterMark: 16 * 1024 * 1024 })

これにより、5GBの動画でも500MBの動画でも、メモリ使用量はほぼ変わりません。

ただしFirefoxはFSA APIをサポートしておらず、Safariも非対応。フォールバック手段が必要でした。

最終的に3層ディスク書き込み戦略に落ち着きました:

  1. 優先:FSA API(Chrome/Edge、サイズ制限なし)
  2. フォールバック:StreamSaver.js(Service Workerに依存、互換性がやや広い)
  3. 最終手段:Blobモード(全ブラウザ対応、ただし1.5GB制限)

どの層を使うかは実行時検出で決定し、ユーザーは裏側の仕組みを意識する必要はありません。

5週目:本当のボス戦——セグメントのMP4結合

セグメントのダウンロードはできる。ファイル書き込みもできる。でもHLSのセグメントはTS形式(MPEG Transport Stream)で、ほとんどの人が欲しいのはMP4。

数百の .ts ファイルをバイナリ結合すると、大きな .ts ファイルになります。VLCなら再生可能ですが、Windows標準プレーヤーは認識せず、動画編集ソフトにインポートできず、Bilibiliにアップロードするとフォーマットエラー。実際には使えるのに、ユーザー体験は最悪です。

TSからMP4への変換は本質的にリマックス(remux):TS内の映像ストリームと音声ストリームを取り出し、MP4コンテナ構造に格納する処理です。再エンコード不要なので理論上は高速で、品質劣化もありません。

問題は:ブラウザ内で誰がこれを行うのか?

答えは1年前にすでにありました——FFmpegがWebAssemblyにコンパイルされたのです。いくつかのオープンソースプロジェクトがこれを行い、最も有名なのが @ffmpeg/ffmpeg。WASMはブラウザのJavaScript環境でネイティブに近い速度のバイナリコードを実行でき、FFmpegはリマックスをネイティブサポートしています。

理論上、必要なのは:

  1. FFmpeg WASMをロード
  2. セグメントをFFmpegの仮想ファイルシステムに書き込む(メモリ上にファイルシステムをエミュレート)
  3. 以下のコマンドを実行:
ffmpeg -f concat -safe 0 -i filelist.txt \
  -c copy \
  -movflags +faststart \
  -bsf:a aac_adtstoasc \
  output.mp4

-c copy はストリームコピーで、再エンコードなし、コンテナのみ変更。-bsf:a aac_adtstoasc はAAC音声をTS用のADTSパッケージングからMP4用のASCパッケージングに変換。これがないと一部のプレーヤーで音声に問題が出ます。-movflags +faststart はMP4のインデックス(moovアトム)をファイル先頭に移動し、プログレッシブ再生に対応させます。

話だけ聞くと素晴らしい。実際にはいくつもの罠がありました:

罠その1:FFmpeg WASMの初回ロードに10〜30秒。

WASMコアファイルは約30MBで、ネットワークからダウンロードしてコンパイルする必要があります。ユーザーがダウンロードボタンをクリックし、20秒待っても何も起きない——最悪の体験です。

解決策:ダウンロード開始と同時にFFmpegのロードを開始し、セグメントダウンロードと並行実行。セグメントがすべてダウンロード完了する頃には、FFmpegも通常は初期化完了しています。待ち時間はほぼ感じません。ブラウザのキャッシュ機構により、2回目以降のロードは2〜5秒です。

罠その2:WASMの仮想ファイルシステムはメモリ上にある。

FFmpegがファイルを処理するには、まずセグメントを仮想ファイルシステムに書き込む必要があります。1080Pの1時間動画で600〜800MBのセグメント、すべてWASMメモリに書き込むと……FFmpeg自身のランタイムメモリ(200〜400MB)と合わせて、WASMの4GBアドレス空間制限を超えやすく、そのままクラッシュします。

この問題は大容量ファイルでは偶発的ではなく必然的に発生します。FFmpeg WASMに依存しない大容量ファイル向けの手法が必要でした。

7週目:TSToMP4Muxerの開発

約2週間かけて、純粋なJavaScriptによるTS→MP4ストリーミングリマックス処理 TSToMP4Muxer を開発しました。

仕組みは:全セグメントのダウンロード完了を待たず、ダウンロードしながら解析しディスクに書き込む。TS形式の各データパケットは固定188バイトで、パケット単位で読み取り、PES(Packetized Elementary Stream)データを抽出し、リアルタイムでMP4のBox形式に変換、Streams APIを通じて継続的にディスクに書き込みます。

セグメント0 ダウンロード完了
  → 188バイトTSパケットを解析
  → 映像/音声PESを抽出
  → MP4 mdat Boxに書き込み
セグメント1 ダウンロード完了
  → 追記を継続
...
セグメントN 書き込み完了
  → moov Box(インデックス)を書き込み
  → ファイルを閉じる

これによりメモリ上には現在処理中のセグメントのみが存在し、ファイルサイズと完全に分離されます。また純粋なJSのため、FFmpeg WASMファイルのロードが不要で、30MBのロードオーバーヘッドと初期化時間を削減できます。

代償としてカバー範囲が狭く——単一TS→MP4のみで、DASHの音声・映像結合(2入力の同期処理が必要)や各種特殊フォーマットには対応できません。しかしHLSダウンロードという最も一般的なユースケースにおいては、TSToMP4Muxerがより優れた手法です。

現在のロジック:通常のHLSはTSToMP4Muxerを優先し、ストリーミング処理で高速かつ省メモリ。DASHや2ストリームの結合が必要な場合はFFmpeg WASMを使用。2GB超のHLSファイルもTSToMP4Muxerを使用し、WASMのメモリオーバーフローを回避します。

これを作るのにかなりの時間がかかりました。MP4形式のBox構造には仕様がありますが、仕様書は600ページ以上。リマックスに関連する数十ページだけ読みました。ftypmoovmvhdtrakmdiaminfstbl——これらのBoxのネスト階層が正しくないと、プレーヤーが読み取れません。デバッグの流れは:書き出す→VLCで開く→エラー内容を確認→仕様書を調べる→修正→繰り返し。20回以上繰り返しました。

最も挫折しかけた瞬間:動画は再生できるのに、タイムラインが壊れている——シークバーを動かすとランダムな位置にジャンプし、期待した時間ではありません。原因は stts(Sample to Time)と ctts(Composition Time Offset)のタイムスタンプ計算で、BフレームのDTS/PTSオフセットを処理していなかったこと。H.264にはBフレームがあり、デコード順(DTS)と表示順(PTS)が異なるため、ctts に各フレームの表示時間オフセットを個別記録する必要があります。問題を特定し修正するのに2日かかりました。

9週目:AES-128暗号化——想像より簡単だった

暗号化動画の処理が最も難しい部分だと思っていましたが、結果的にTSToMP4Muxerよりずっと簡単でした。

HLSの暗号化形式はM3U8内で明確です:

#EXT-X-KEY:METHOD=AES-128,URI="https://api.example.com/key?token=abc",IV=0x00000000000000000000000000000001

ロジックは:

  1. #EXT-X-KEY を検出し、Key URIとIVを解析
  2. Key URIにリクエストして16バイトの鍵を取得(ブラウザ拡張機能からのリクエストはユーザーの現在のCookieを含むため、ログインが必要な鍵も取得可能)
  3. ダウンロード完了した各セグメントに対し、Web Crypto API でAES-128-CBC復号
const keyBytes = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['decrypt'])
const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: ivBuffer }, keyBytes, encryptedData)

crypto.subtle はブラウザ組み込みのWeb Crypto APIで、ハードウェアアクセラレーションが効くため復号速度はボトルネックになりません。数ミリ秒で1セグメント処理できます。

実際に時間がかかったのはIVの処理です。HLS仕様にこうあります:M3U8にIVが明示されていない場合、各セグメントのIVはセグメントのシーケンス番号を使用(MSB形式、16バイト)。最初はこのルールを処理しておらず、全セグメントに最初のIVを使用。暗号化講座の復号で音声は正常だが映像が乱れる状態に。RFC 8216を調べてこのルールを発見し、追加して解決。

対応不可: Widevine、PlayReady、FairPlay。これらはDRMで、鍵はOSまたはハードウェア内にあり、JavaScriptからは一切アクセス不可。この種のコンテンツに遭遇した場合、FlowPickはユーザーに「非対応」と正直に伝えるのみで、回避する方法は一切ありません。NetflixやDisney+がこの種のプラットフォームで、設計対象外です。

11週目:DASHの音声・映像結合

DASHはもう一つの主要ストリーミング形式で、YouTubeやBilibiliの一般投稿で使用されています。HLSとの核心的な違い:音声と映像は完全に独立した2つのストリーム

これは設計上の欠陥ではなく、意図的な設計です——プラットフォームは映像トラックを1セットだけ保存し、複数言語の音声トラックを組み合わせることで、ストレージコストが数分の1になります。しかしダウンロードツールにとっては、2系統のデータを同時にダウンロードして結合する必要があります。

2系統のデータ結合にはFFmpeg WASMを使用:

ffmpeg
  -f concat -safe 0 -i video_list.txt
  -f concat -safe 0 -i audio_list.txt
  -c:v copy -c:a copy
  -movflags +faststart
  output.mp4

細かい点:DASHのセグメント形式はFMP4(Fragmented MP4)で、HLSのTSではありません。FMP4の特殊な点は、各セグメントファイルが単独で再生可能なこと。ただし正しく解析するには、最初に init segment(コーデックパラメータを含む初期化セグメント、ftyp + moov Box)が必要です。init segmentがないと、FFmpegは動画の解像度、フレームレート、コーデックを認識できず、後続のデータセグメントを処理できません。

そのためDASHの処理フローはHLSより1ステップ多い:最初にinit segmentをダウンロードし、その後すべてのデータセグメントをダウンロード。結合時にinit segmentを先頭に配置する必要があります。最初はinit segmentの処理を忘れていて、FFmpegで結合したMP4を開くと「ファイル破損」エラー。原因に気づくまで2日かかりました。

DASHの並列ダウンロード戦略も調整が必要——映像セグメントと音声セグメントを同時にダウンロードし、映像を先に全部ダウンロードしてから音声ではありません。これで総ダウンロード時間が約半分に短縮されます。Worker Poolの設計がこれをサポート:2系統のタスクを並列実行し、共通のスレッドプールを使用。空いている方が次のセグメントを取得します。

並列ダウンロード:Worker Pool(事前分割ではない)

並列ダウンロードについて個別に説明したいことがあります。実装の詳細で2回修正したからです。

最も直感的な方法:200セグメントを4等分し、4つのWorkerがそれぞれ1区分を担当。この方法の問題は負荷が不均等なこと——HLSセグメントは等サイズではなく、動画の冒頭と末尾のセグメントは小さく、中間は大きい傾向があります。事前分割では特定のWorkerが大きなセグメント群で詰まり、残り3つのWorkerはとっくに待機状態に。

FlowPickは共有カウンター方式を採用:

let nextIndex = 0
const worker = async () => {
  while (nextIndex < segments.length) {
    const index = nextIndex++  // アトミックに次のタスクを取得
    await downloadAndProcess(segments[index])
  }
}
// N個のworkerを起動、すべてnextIndexを共有
await Promise.all(Array.from({ length: concurrency }, () => worker()))

各Workerは現在のセグメント完了後すぐに次を取得し、他のWorkerを待たず、大きなセグメントでも小さなセグメントでも関係ありません。これにより自然に動的負荷分散を実現し、総完了時間は理論上の最適値に近づきます。

並列数はデフォルトで2、最大8まで調整可能。なぜデフォルトで8にしないのか?一部のCDNでは高並列がレート制限や429エラーを引き起こし、かえって遅くなるからです。4〜6並列が通常最もコスパの良い範囲で、8並列から収穫逓減が発生します。

リトライ機構は指数バックオフ:失敗後400ms待機、再失敗で800ms、さらに失敗で1600ms、3回すべて失敗で中断。ほとんどのネットワーク変動は1回目か2回目のリトライで回復します。4xxエラー(403、404)はリトライしません——サーバーが明示的に拒否しているため、リトライに意味はありません。

メモリ管理:ブラウザのクラッシュ防止

ダウンロードの最終段階で、メモリ管理が最も頭を悩ませた部分です。

問題は:ダウンロードするファイルのサイズが事前にわからないこと。ユーザーが1080Pのライブアーカイブを選択した場合、2GBかもしれないし20GBかもしれない。全セグメントをダウンロードし終えるまで確定しません。

ダウンロード前のサンプリング推定を実装:

// まず max(1, 全体の10%) 個のセグメントをダウンロードし、平均サイズを取得、総数を掛けて推定
const sampleCount = Math.min(5, Math.max(1, Math.floor(totalCount * 0.1)))

推定後、サイズに応じてディスク書き込み戦略を選択:800MB以下は制限なし、800MB〜1.5GBはストリーミング書き込み必須、1.5GB超はBlobモード拒否。

しかし推定だけでは不十分で、実行時にも監視が必要。ダウンロード中に割り当て済みArrayBufferの総量を継続的に集計し、600MB超で警告、1200MB超で新規セグメントのダウンロードを一時停止。ダウンロード済みセグメントを優先的にディスク書き込みしメモリ解放してから再開。推定が大きく外れてもバックアップ策があります。

作っては削除した機能

3ヶ月の間にいくつかの機能を途中まで作って削除しました:

トランスコード:一時は「ダウンロード時に720Pに変換」をサポートし、ファイルサイズを削減しようと考えました。後にブラウザ内でのトランスコードが遅すぎると判明——FFmpeg WASMでのリマックスは許容範囲ですが、再エンコードは全フレームのデコードと再エンコードが必要で、CPU使用率が100%になり、1GB動画で30分かかる可能性があります。ユーザー体験として許容不可。最終的にFlowPickはリマックスのみ(コンテナ変更のみ、コーデック変更なし)。トランスコードしたい場合はコマンドラインのFFmpegを使うよう案内します。

リアルタイムプログレスバーのETA(残り時間推定):初版は瞬間速度を使用していましたが、プログレスバーが激しくブレました——CDNセグメントのサイズが不均一で、瞬間速度が0.5MB/sと50MB/sの間で跳ねる。5秒のスライディングウィンドウ平均に変更したらかなり安定しました。

複数ファイルの一括ダウンロード:当初は実装しない予定でしたが、全エピソードをまとめてダウンロードしたいという要望があり、シンプルなキューを追加。各ダウンロードタスクをキューで順次実行し、並列には非対応(2つの動画を同時ダウンロードすると帯域幅とFFmpegメモリがパンクします)。

3ヶ月の成果

コード量は約1.2万行のTypeScript。拡張機能 + オンラインツール + ランディングページ。

できること:

  • HLS/M3U8ダウンロード、AES-128自動復号
  • DASH/MPDダウンロード、音声・映像自動結合
  • 直接リンク動画の一括検知
  • 画像の一括検出とダウンロード
  • 音声リソース検出
  • MP4出力、サイズ制限なし(Chrome/Edge)

できないこと(手抜きではなく、本当に不可能):

  • Widevine/PlayReady/FairPlay DRM——ハードウェアレベルの保護、JSから鍵にアクセス不可
  • 動画トランスコード——ブラウザの計算能力不足、体験が悪すぎる
  • MKV/AVIへのフォーマット変換——FFmpeg WASM統合は現在MP4/TSパスのみ

コードはオープンソース化済みで、リポジトリは GitHub にあります。拡張機能は3ブラウザで公開済み:ChromeウェブストアEdgeアドオンFirefoxアドオン。バグ発見やアイデアがあれば、IssueまたはPRを歓迎します。


おすすめ記事