ダウンロードエンジンアーキテクチャ
FlowPick のダウンロードエンジンはシステム全体の中核であり、ストリーミングメディアセグメントのダウンロード、復号、マージ、ディスクへの書き込みを担当します。本ドキュメントは内部メカニズムを深く理解したい開発者と上級ユーザー向けです。

アーキテクチャ概要
ダウンロードエンジンは3つのコアモジュールで構成され、データはパイプライン方式で各モジュールを順次流れます:
┌─────────────────────────────────────────────────┐
│ ダウンロードエンジン │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ セグメント │→│ セグメント │→│ ファイル書き込み │ │
│ │ ダウンロー │ │ 処理 │ │ │ │
│ │ ドモジュール│ │ モジュール │ │ モジュール │ │
│ │ │ │ │ │ │ │
│ │ · 並列 │ │ · 復号 │ │ · FSA ストリーム│ │
│ │ · リトライ│ │ · 連結 │ │ · StreamSaver │ │
│ │ · 速度制限│ │ · リマックス│ │ · Blob 兜底 │ │
│ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ メモリセキュリティマネージャー │ │
│ │ · サイズ見積もり · 閾値チェック · 戦略選択 │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
データフロー全景
ユーザーがダウンロードをクリックしてからファイルがディスクに書き込まれるまでの完全なチェーン:
ユーザーがダウンロードをクリック
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. マニフェスト│ ──→ │ 2. セグメント │ ──→ │ 3. セグメント │
│ 解析 │ │ ダウンロード │ │ 処理 │
│ │ │ │ │ │
│ · M3U8 を │ │ · Worker Pool│ │ · AES 復号 │
│ ダウンロード│ │ · 並列制御 │ │ · TS 連結 │
│ · セグメント │ │ · 指数バックオフ│ │ · FFmpeg リマックス│
│ リストを解析│ │ · 進捗報告 │ │ · 音声/動画マージ│
│ · キーを抽出 │ │ │ │ │
│ · サイズを見積│ │ │ │ │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 6. 完了通知 │ ←── │ 5. クリーン │ ←── │ 4. ファイル │
│ │ │ アップ │ │ 書き込み │
│ · デスクトップ│ │ │ │ │
│ 通知 │ │ · メモリ解放 │ │ · FSA ストリーム│
│ · パスコピー │ │ · 一時ファイル│ │ · StreamSaver│
│ · キュー進行 │ │ 削除 │ │ · Blob 兜底 │
│ │ │ · 状態リセット│ │ │
└──────────────┘ └──────────────┘ └──────────────┘
エンジンのライフサイクル
ダウンロードエンジンの1回の完全な実行は以下の状態遷移を経ます:
[アイドル]
│ ユーザーがダウンロードをトリガー
▼
[初期化] ──── WASM の読み込み、API 利用可能性チェック、書き込み戦略の選択
│
▼
[マニフェスト解析] ── M3U8/MPD をダウンロードして解析し、セグメントリストとキーを抽出
│
▼
[セグメントダウンロード] ── Worker Pool で並列ダウンロード、リアルタイム進捗報告
│
▼
[セグメント処理] ── 復号 → 連結/リマックス → ディスクに書き込み
│
├── 成功 → [クリーンアップ] → [完了] → [アイドル]
│
├── キャンセル → [クリーンアップ] → [アイドル]
│
└── 失敗 → [クリーンアップ] → [エラー] → [アイドル]
各状態遷移は対応するライフサイクルフックをトリガーし、UI 層はこれらのフックを監視して画面状態を更新します。UI とエンジンのインタラクションの詳細については、プロジェクトアーキテクチャ — データフロー を参照してください。
セグメントダウンロードモジュール
並列ダウンローダー
セグメントダウンロードは Worker Pool モードで並列制御を実装しています:
const downloadSegmentsConcurrently = async (
segments: SegmentInfo[],
onSegmentDownloaded: (buffer: ArrayBuffer, index: number) => void
) => {
const concurrency = Math.min(config.concurrency, 8)
let nextIndex = 0
const worker = async () => {
while (nextIndex < segments.length) {
const index = nextIndex++
const buffer = await downloadWithRetry(segments[index]!)
onSegmentDownloaded(buffer, index)
}
}
const workers = Array.from(
{ length: Math.min(concurrency, segments.length) },
() => worker()
)
await Promise.all(workers)
}
設計要点:
- 共有の
nextIndexカウンタを使用してタスクを割り当て、事前分割による負荷不均衡を回避 - Worker 数はセグメント総数を超えず、アイドル Worker の作成を回避
- 各 Worker は独立して動作し、単一セグメントの失敗は他の Worker に影響しない
Worker Pool の深掘り
Worker Pool モードのコアメリットは動的負荷分散にあります。事前にセグメント配列を N 等分する場合と異なり、共有カウンタにより以下を保証します:
事前分割(非推奨):
Worker 1: [セグメント 0-49] ← これらのセグメントが大きい場合、Worker 1 がボトルネックに
Worker 2: [セグメント 50-99] ← 早く完了してからアイドル待機する可能性あり
共有カウンタ(FlowPick 採用):
Worker 1: セグメント 0 → セグメント 3 → セグメント 5 → ...
Worker 2: セグメント 1 → セグメント 4 → セグメント 7 → ...
Worker 3: セグメント 2 → セグメント 6 → セグメント 8 → ...
各 Worker は現在のセグメントを完了すると直ちに次の未割当てセグメントを取得し、すべてのセグメント処理が完了するまで続きます。このモードはセグメントサイズ不均一のシーンに自然に適応します(例:HLS ストリームの先頭・末尾セグメントは通常小さく、中間セグメントは大きい)。
config.concurrency で制御され、上限は 8 です。ユーザーは 設定リファレンス でデフォルト値を調整でき、ダウンロード画面でリアルタイムに変更できます。一括ダウンロードシーンでのキュースケジューリングについては、一括ダウンロード — 並列数選択ガイド を参照してください。非同期ジェネレーターモード
超大容量ファイル(セグメント数 > 500)の場合、エンジンは非同期ジェネレーター(AsyncGenerator)モードを使用して、すべてのセグメントデータを一度にメモリに読み込むことを回避します:
async function* segmentGenerator(
segments: SegmentInfo[],
signal?: AbortSignal
): AsyncGenerator<ArrayBuffer> {
for (const segment of segments) {
if (signal?.aborted) break
const buffer = await downloadWithRetry(segment)
yield buffer
}
}
配列モードとの違い:
| 次元 | 配列モード | ジェネレーターモード |
|---|---|---|
| メモリピーク | すべてのセグメントが同時にメモリにある | 現在処理中のセグメントのみがメモリにある |
| 適用シーン | セグメント数 < 500 | セグメント数 > 500 |
| 進捗トラッキング | 総数既知、正確なパーセント | 総数既知、正確なパーセント |
| キャンセル応答 | 現在のバッチ完了を待つ必要あり | 即座に応答 |
エンジンはセグメント数に基づいて自動的にモードを選択し、ユーザーの介入は不要です。超大容量ファイルダウンロードの実際のシーンについては、ライブ配信アーカイブ保存 を参照してください。
リトライメカニズム
セグメントダウンロード失敗時には指数バックオフリトライを採用します:
const downloadWithRetry = async (
segment: SegmentInfo,
maxRetries = 3
): Promise<ArrayBuffer> => {
let lastError: unknown
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await downloadAndDecryptSegment(segment)
} catch (e) {
lastError = e
if (attempt < maxRetries - 1) {
// 指数バックオフ:400ms, 800ms, 1600ms
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 400))
}
}
}
throw lastError
}
リトライ戦略:
| 試行回数 | 遅延 | 累計待機時間 |
|---|---|---|
| 第1回失敗 | 400ms | 400ms |
| 第2回失敗 | 800ms | 1200ms |
| 第3回失敗 | 1600ms | 2800ms |
リトライがトリガーされない場合:
- HTTP 4xx クライアントエラー(403、404 など)—— リトライは無意味
- CORS エラー —— 戦略的問題、リトライしても結果は変わらない
- 暗号化非サポートエラー —— リトライでは解決できない
エラー分類
class FetchError extends Error {
constructor(
message: string,
public status: number,
public url: string
) {
super(message)
this.name = 'FetchError'
}
}
エラーは HTTP ステータスコード別に分類処理されます:
| ステータスコード | エラータイプ | ユーザープロンプト |
|---|---|---|
| 403 | 認証/認可失敗 | "アクセス拒否、URL が有効かどうか確認してください" |
| 404 | リソース不存在 | "セグメント未検出、ストリームの有効期限切れの可能性があります" |
| 502/503/504 | サーバーエラー | "サーバーが一時的に利用不可です、後でもう一度お試しください" |
| CORS | クロスドメイン制限 | "クロスドメインリクエストがブロックされました、拡張機能バージョンの使用をお勧めします" |
| Network | ネットワーク中断 | "ネットワーク接続失敗、ネットワークを確認してください" |
webRequest 権限経由で同一オリジンポリシーをバイパスできます。より多くのトラブルシューティング方法については よくある問題のトラブルシューティング を参照してください。CORS の技術的原理と制限については 既知の制限 — ブラウザ制限 を参照してください。セグメント処理モジュール
AES-128 復号
暗号化された HLS ストリームの場合、FlowPick は Web Crypto API を使用してブラウザ内で復号します:
const decryptAES128 = async (
encryptedData: ArrayBuffer,
key: ArrayBuffer,
iv?: Uint8Array
): Promise<ArrayBuffer> => {
const keyBytes = await crypto.subtle.importKey(
'raw', key,
{ name: 'AES-CBC' },
false,
['decrypt']
)
const ivBuffer = iv ? new Uint8Array(iv) : new Uint8Array(16)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv: ivBuffer },
keyBytes,
encryptedData
)
return decrypted
}
復号フロー:
- M3U8 の
#EXT-X-KEYタグからキー URI と IV を抽出 - キーファイルをダウンロード(通常は 16 バイトのバイナリファイル)
- AES-128-CBC モードを使用して各セグメントを復号
- IV が指定されていない場合、セグメント番号を IV として使用(HLS 仕様のデフォルト動作)
パフォーマンス考慮事項:
- Web Crypto API はハードウェアアクセラレーションを使用しており、復号速度は通常ボトルネックにならない
- 各セグメントは独立して復号可能であり、ダウンロードと並行して実行できる
- キーは一度だけダウンロードすればよく、メモリにキャッシュされる
TS セグメント連結
TS 出力フォーマットの場合、セグメントはバイナリで直接連結されます:
// 疑似コード
const merged = new Uint8Array(totalSize)
let offset = 0
for (const segment of segments) {
merged.set(new Uint8Array(segment), offset)
offset += segment.byteLength
}
この方式は CPU オーバヘッドゼロ、速度はメモリコピ速度のみに依存します。
FFmpeg WASM リマックス
MP4 出力フォーマットの場合、FFmpeg WASM を使用してコンテナ変換を行います。詳細は フォーマット変換 ドキュメントを参照してください。
DASH ストリームの特殊処理
DASH ストリームと HLS ストリームはセグメント処理において顕著な違いがあります:
HLS ストリーム処理:
セグメント 0 → セグメント 1 → セグメント 2 → ... → 連結 → 出力
DASH ストリーム処理(音声/動画分離):
初期化セグメント → 動画セグメント 0 → 動画セグメント 1 → ... ─┐
├→ FFmpeg マージ → 出力
初期化セグメント → 音声セグメント 0 → 音声セグメント 1 → ... ─┘
DASH の FMP4(Fragmented MP4)フォーマットは特殊な処理が必要です:
- 初期化セグメント(
ftyp+moovbox)はファイル先頭に配置する必要がある - メディアセグメント(
moof+mdatbox)は順番に追記 - 音声/動画分離時、動画トラックと音声トラックをそれぞれダウンロードし、最後に FFmpeg でマージ
ファイル書き込みモジュール
書き込みモジュールは優先順位に従って戦略を選択し、可能な限り多くのブラウザで動作することを保証します。

戦略1:File System Access API(最適)
async function createFSAStream(
filename: string,
dirHandle?: FileSystemDirectoryHandle | null
): Promise<WritableStream<Uint8Array>> {
let handle: FileSystemFileHandle
if (dirHandle) {
// 既にディレクトリ権限がある場合、直接ファイルを作成
handle = await dirHandle.getFileHandle(filename, { create: true })
} else {
// ポップアップでユーザーに保存場所を選択させる
handle = await window.showSaveFilePicker!({
suggestedName: filename,
types: [{
description: 'Video',
accept: { 'video/mp4': ['.mp4'] }
}]
})
}
const writable = await handle.createWritable()
return new WritableStream<Uint8Array>({
async write(chunk) { await writable.write(chunk) },
async close() { await writable.close() },
async abort(reason) { await writable.abort(reason) }
}, {
highWaterMark: 16 * 1024 * 1024 // 16MB バッファ
})
}
メリット:
- ストリーミング書き込み、メモリ消費量一定(バッファ 16MB のみ)
- 任意サイズのファイルをサポート
- ディレクトリ永続化後、繰り返しポップアップ不要
制限:
- Chrome 86+ および Edge 86+ のみサポート
- ユーザージェスチャーが必要(
showDirectoryPickerはユーザークリックイベント内で呼び出す必要がある)
戦略2:StreamSaver.js(代替案)
async function createStreamSaverStream(
filename: string,
fileSize?: number
): Promise<WritableStream<Uint8Array>> {
const ss = await getStreamSaver()
const fileStream = ss.createWriteStream(filename, { size: fileSize })
return fileStream
}
メリット:
- ストリーミング書き込み、メモリ消費量低い
- FSA API より互換性が高い
制限:
- Service Worker サポートが必要
mitm.htmlとstreamsaver-sw.jsが正しくデプロイされている必要がある- 一部の企業ネットワーク環境で Service Worker がブロックされる可能性がある
戦略3:Blob 兜底
// 疑似コード
const blob = new Blob([mergedData], { type: 'video/mp4' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
制限:
- ファイル全体をメモリに読み込む
- 硬上限 1.5GB(
MEMORY_CONFIG.maxBlobSize) - 800MB 超過時にコンソール警告(
MEMORY_CONFIG.warnBlobSize)
戦略比較まとめ
| 次元 | FSA API | StreamSaver.js | Blob |
|---|---|---|---|
| メモリ消費 | 16MB(一定) | 低(ストリーミング) | ファイルサイズと同等 |
| ファイルサイズ上限 | 制限なし | 制限なし | 1.5GB |
| ブラウザ要件 | Chrome/Edge 86+ | Service Worker サポート | すべてのブラウザ |
| ディレクトリ永続化 | サポート | 非サポート | 非サポート |
| ダウンロード体験 | 最良 | 良好 | 一般 |
メモリセキュリティ管理

サイズ見積もり
ダウンロード開始前、エンジンはサンプリングで総ファイルサイズを見積もります:
async function estimateSegmentsSize(
segments: ArrayBuffer[] | AsyncGenerator<ArrayBuffer>,
totalCount: number
): Promise<{ estimatedSize: number; isEstimate: boolean }> {
// サンプリング数:min(5, max(1, totalCount * 0.1))
const sampleCount = Math.min(5, Math.max(1, Math.floor(totalCount * 0.1)))
// 最初の数セグメントをダウンロードして平均サイズを計算
let sampledTotal = 0
for (let i = 0; i < sampleCount; i++) {
sampledTotal += sampleSegment.byteLength
}
const avgSample = sampledTotal / sampleCount
return {
estimatedSize: Math.round(avgSample * totalCount),
isEstimate: true
}
}
サンプリング戦略説明:
- サンプリング数は
min(5, max(1, セグメント総数 × 10%))を取り、小さいストリームは少なくとも1個、大きいストリームは最大5個をサンプリング - サンプリング結果は
isEstimate: trueとマークされ、UI 層はこれに基づき「約 XX MB」と表示(正確な値ではなく) - HLS ストリームの場合、Master Playlist で
BANDWIDTH属性が宣言されている場合、エンジンはその値を優先的に見積もり参考として使用
戦略選択ロジック
見積もりサイズ < 800MB → 任意の戦略を使用可能
見積もりサイズ 800MB-1.5GB → ストリーミング書き込み優先、Blob モードで警告表示
見積もりサイズ > 1.5GB → 強制的にストリーミング書き込み、Blob モード拒否
メモリ設定定数
const MEMORY_CONFIG = {
maxBlobSize: 1500 * 1024 * 1024, // 1.5GB 硬制限
warnBlobSize: 800 * 1024 * 1024, // 800MB 警告閾値
}
ランタイムメモリ監視
ダウンロード前の見積もりに加え、エンジンは実行中も継続的にメモリプレッシャーを監視します:
const checkMemoryPressure = (): 'normal' | 'warning' | 'critical' => {
// 現在割り当て済みのセグメントバッファ合計サイズを確認
const allocatedMB = allocatedBuffers.reduce((sum, buf) => sum + buf.byteLength, 0) / 1048576
if (allocatedMB > 1200) return 'critical'
if (allocatedMB > 600) return 'warning'
return 'normal'
}
メモリプレッシャーが critical レベルに達した場合、エンジンは以下を行います:
- 新しいセグメントダウンロードリクエストを一時停止
- 既にダウンロードされたセグメントのディスクへの書き込みを優先
- 書き込み完了済みのセグメントバッファを解放
進捗トラッキング
ダウンロード進捗はリアルタイムで UI に報告され、以下の指標が含まれます:
進捗データ構造
interface DownloadProgress {
percentage: number // 完了パーセント (0-100)
downloadedBytes: number // ダウンロード済みバイト数
totalBytes: number // 推定総バイト数
speed: number // 現在の速度 (bytes/sec)
eta: number // 推定残り時間 (秒)
currentPhase: DownloadPhase // 現在のフェーズ
}
type DownloadPhase =
| 'parsing' // マニフェスト解析中
| 'downloading' // セグメントダウンロード中
| 'processing' // セグメント処理(復号/マージ)中
| 'writing' // ファイル書き込み中
| 'completed' // 完了
| 'error' // エラー発生
| 'cancelled' // ユーザーによりキャンセル
進捗計算方法
パーセンテージ計算:
全体パーセンテージ = (現在のフェーズ基本値 + フェーズ内進捗) / 総フェーズ重み
フェーズ重み:
- 解析:5% (0% ~ 5%)
- ダウンロード:80% (5% ~ 85%)
- 処理・書き込み:15% (85% ~ 100%)
速度計算:
const calculateSpeed = (
downloadedBytes: number[],
windowSize: number = 10
): number => {
if (downloadedBytes.length < 2) return 0
const recent = downloadedBytes.slice(-windowSize)
const timeDiff = (recent.length - 1) * intervalMs
const bytesDiff = recent[recent.length - 1] - recent[0]
return bytesDiff / timeDiff
}
スライディングウィンドウ(デフォルト10サンプル)を使用して瞬間的な速度変動を平滑化します。
ETA 推定:
const calculateETA = (
remainingBytes: number,
speed: number
): number => {
if (speed <= 0) return Infinity
return remainingBytes / speed
}
UI 更新頻度
進捗イベントの発火頻度:
| 条件 | 発火間隔 |
|---|---|
| 通常時 | 500ms ごと |
| 最終フェーズ(90%+) | 200ms ごと |
| 完了/エラー | 即座 |
過度な UI 更新を避け、レスポンシブなフィードバックを保証します。
エラーハンドリング
ダウンロードエンジンは多層エラーハンドリングメカニズムを実装しています:
エラーレベル
enum ErrorLevel {
Warning = 'warning', // 回復可能、継続可能
Error = 'error', // 回復不能、再試行可能
Fatal = 'fatal' // 致命的、即座に停止
}
エラーカテゴリー
| カテゴリー | 例 | レベル | 対処 |
|---|---|---|---|
| ネットワークエラー | タイムアウト、接続切断 | Warning | 自動リトライ |
| HTTP エラー | 403、404、5xx | Error | 上位層に報告 |
| 復号エラー | キーダウンロード失敗 | Error | 中止して報告 |
| メモリエラー | メモリ不足 | Fatal | 直ちに停止 |
| ユーザーキャンセル | - | Info | 正常終了 |
エラー伝播チェーン
低層(Worker)
↓ FetchError / CryptoError
中層(セグメント処理)
↓ SegmentError / MergeError
高層(エンジンコーディネーター)
↓ DownloadError
UI 層
↓ ユーザーにフレンドリーなプロンプトを表示
各層でエラーをキャッチし、コンテキスト情報を付加して上位層に伝播させます。
パフォーマンス最適化
並列チューニング
最適な並列数は環境によって異なります:
| 環境 | 推奨並列数 | 理由 |
|---|---|---|
| 高速ネットワーク(100Mbps+) | 6-8 | ネットワーク帯域幅を最大活用 |
| 一般ネットワーク(10-50Mbps) | 4-6 | バランスの取れた負荷 |
| 低速ネットワーク(<10Mbps) | 2-4 | 過負荷回避 |
| CDN 制限のあるサイト | 2-3 | レート制限回避 |
メモリ最適化
ストリーミング処理:
- FSA または StreamSaver 使用時、メモリ消費は 16MB バッファに固定
- Blob モードのみ全ファイルをメモリに保持
早期書き込み:
- セグメント処理完了後、即座にディスクに書き込み
- バッファを循環使用し、ピークメモリを抑制
GC フレンドリー:
- 大きな ArrayBuffer を使用後、参照を解除して GC を促進
Transferable Objectsを使用してゼロコピ転送
デバッグと診断
ログレベル
enum LogLevel {
Debug = 0, // 開発者向け詳細情報
Info = 1, // 通常操作ログ
Warn = 2, // 注意が必要な状況
Error = 3, // エラー発生
None = 4 // ログ無効
}
開発者ツール統合
Chrome DevTools Console で詳細ログを有効にする:
// コンソールで実行
localStorage.setItem('flowpick_debug', 'true')
ページをリロード
有効化すると、以下のログが出力されます:
[FlowPick] [Engine] Initializing with strategy: fsa
[FlowPick] [Engine] Manifest parsed: 180 segments, estimated 450MB
[FlowPick] [Download] Starting worker pool with concurrency: 6
[FlowPick] [Download] Segment 42/180 downloaded (23%), speed: 2.5 MB/s
[FlowPick] [Merge] Using TSToMP4Muxer for streaming remux
[FlowPick] [Engine] Download completed: 450MB in 185s
パフォーマンスプロファイリング
Performance API を使用して各フェーズの所要時間を計測:
const perfMarks = [
'engine:init',
'manifest:parse',
'segments:download:start',
'segments:download:end',
'segments:process:start',
'segments:process:end',
'file:write:start',
'file:write:end'
]
Chrome DevTools → Performance パネルでタイムラインを視覚化できます。