1つのスライダー、3つのレンダラー:プレビューにはGPUシェーダー、その他の場所にはSkiaカラーマトリックス、エクスポートにはFFmpegを使用してInstagram風ビデオフィルターを構築し、それらすべてが同じ視覚的ストーリーを伝えるように維持した方法。
フィルターは簡単に見える。ビデオフィルターは違う。
Flutterで画像にセピアトーンを適用するのは、ColorFilteredウィジェットでラップするだけの1行のコードで完了します。しかし、それをビデオに適用するのは全く別の話です:
- プレビューは、ユーザーが所有するあらゆるデバイスで、再生中のビデオの上に30〜60 fpsで動作する必要があります。
- ユーザーはスライダー(明るさ、コントラスト、色相、色温度など)をドラッグし、結果をリアルタイムで確認できる必要があります。
- そして厳しい点が、ユーザーがエクスポートをタップした際、焼き付けられたMP4はプレビューで見たものと全く同じでなければなりません。しかし、プレビューレンダラー(Flutter/GPU)とエクスポートレンダラー(FFmpeg)は、互いに連携したことのない完全に異なるエンジンです。
この投稿では、Flutterビデオエディターでそのパイプラインを構築した方法について説明します。プリセットとパラメーターごとの調整を備えたフィルターカタログ、最も安価なレンダラーを選択するライブプレビュー、そしてその外観を再現するFFmpegエクスポートパスです。まず、最も重要な教訓は次のとおりです。
すべてのフィルターは最終的に、GPUシェーダー構成、Skia 4×5カラーマトリックス、およびFFmpegフィルターステートメントとして3回実装され、すべて1つの共有パラメーターマップによって駆動されます。これら3つの再実装の視覚的な一貫性を維持することが真のエンジニアリングです。
アーキテクチャ:1つのパラメーターマップ、3つのレンダラー
┌────────────────────────────┐
│ 共有パラメーターマップ │
│ {filterId, params, 0–1 │
│ 強度} │
└─────┬───────┬───────┬──────┘
│ │ │
┌──────────────┘ │ └───────────────┐
▼ ▼ ▼
GPUシェーダープレビュー Skia ColorFilter.matrix FFmpeg -vf文字列
(flutter_gpu_video_ (主要なライブプレビュー, (エクスポート — ユーザーが
filters, ローカルクリップ) 普遍的なフォールバック) 保持する唯一のもの)
3つのパスが存在するのは、それぞれに利点があるからです:
- GPUシェーダー (flutter_gpu_video_filters) は、歪み、ぼかし、トーンマッピングといった真のシェーダー品質のエフェクトを提供しますが、このパッケージは独自のサーフェスにレンダリングし、ローカルビデオファイルで最もよく機能します。
- Skia ColorFilter.matrix — ColorFilteredでビデオウィジェットをラップする4x5カラーマトリックス — はほぼ無料で、あらゆるビデオウィジェット(ネットワークストリームを含む)で動作し、明るさ、コントラスト、セピア、グレースケール、色相、デュオトーンなど、最もよく使われる20種類以上のフィルターをカバーします。
- FFmpegは、ユーザーが実際に保持する唯一のレンダラーです。エクスポートは、すべての外観をフィルターグラフ文字列(eq=、hue=、colorchannelmixer=…)として再導出します。
小さなストラテジールーターが、フィルターごとにどのプレビューエンジンを使用するかを決定します:
// このフィルターをレンダリングできるプレビュー戦略はどれか?
static FilterStrategy getFilterStrategy(String filterId) {
if (_colorFilterIds.contains(filterId)) return FilterStrategy.colorFilter;
if (_customPainterFilterIds.contains(filterId)) return FilterStrategy.customPainter;
return FilterStrategy.exportOnly;
}
- colorFilter → プレイヤーをColorFiltered(colorFilter: getColorFilter(id, params))でラップする
- customPainter → ビデオにCustomPainterオーバーレイを重ねる(ビネット、ピクセル化、ハーフトーンなど、カラーマトリックスでは表現できないもの)
- exportOnly → 元のビデオと、エフェクトがエクスポートに表示されることをユーザーに知らせるバッジを表示する
このルーターはシステム内で最も費用対効果の高い決定です。約24種類のフィルターに、あらゆるデバイスで本当に無料のリアルタイムプレビューを提供し、必要なフィルターのために重い処理機構を確保します。
1. フィルターカタログ、プリセット、および調整
カタログ内の各フィルターは、ID、表示名、カテゴリ、そして(GPUパスがサポートしている場合)シェーダー構成のためのファクトリという、小さな宣言的アイテムです:
const GpuVideoFilterItem({
required this.id,
required this.name,
required this.icon,
required this.category,
this.createConfiguration, // () => GPUFilterConfiguration, GPU対応の場合
});
GPUFilterConfiguration? getConfiguration() => createConfiguration?.call();
プリセットは、フィルターを指す名前付きパラメーターバンドル、つまりUIの「P1~P5」クイックルックです:
static List<FilterPreset> get allPresets => [
const FilterPreset(id: 'P1', name: 'Warm Vintage',
filterId: 'sepia', parameters: {'intensity': 0.8}),
const FilterPreset(id: 'P2', name: 'Classic B&W',
filterId: 'grayscale', parameters: {'intensity': 1.0}),
const FilterPreset(id: 'P3', name: 'Vivid Colors',
filterId: 'vibrance', parameters: {'vibrance': 0.5}),
const FilterPreset(id: 'P4', name: 'High Contrast',
filterId: 'contrast', parameters: {'contrast': 1.4}),
const FilterPreset(id: 'P5', name: 'Cinematic',
filterId: 'vignette', parameters: {'vignetteStart': 0.3, 'vignetteEnd': 0.75}),
];
調整は、範囲、デフォルト値、さらにはグラデーションのヒントを持つ型付けされたパラメーター記述子です。これにより、各スライダーは意味のあるトラック(明るさには黒→白、色相には虹色)をレンダリングできます:
case 'brightness':
return [const FilterParameter(id: 'brightness', displayName: 'Brightness',
minValue: -1.0, maxValue: 1.0, defaultValue: 0.0,
gradientType: FilterParameterGradientType.blackToWhite)];
case 'contrast'
:
return [const FilterParameter(id: 'contrast', displayName: 'Contrast',
minValue: 0.5, maxValue: 2.0, defaultValue: 1.0,
gradientType: FilterParameterGradientType.grayToWhite)];
case 'hue'
:
return [const FilterParameter(id: 'hue', displayName: 'Hue',
minValue: -180.0, maxValue: 180.0, defaultValue: 0.0, unit: '°',
gradientType: FilterParameterGradientType.rainbow)];
現在のスライダー値は、デフォルト値からシードされた不変の状態オブジェクトに格納されます:
factory FilterAdjustmentState.fromDefaults(String filterId, List<FilterParameter> parameters) {
return FilterAdjustmentState(
filterId: filterId,
parameterValues: Map.fromEntries(parameters.map((p) => MapEntry(p.id, p.defaultValue))),
);
}
そして、見た目以上に重要なUXの詳細が1つあります。スライダーの更新はすぐに状態に書き込まれます(これにより、サムが指に追従します)が、**プレビューの再レンダリングは300ミリ秒でデバウンスされます**。デバウンスがない場合、スライダーをドラッグするとフィルター処理されたビデオサブツリーが1秒間に何十回も再構築され、ドラッグが途切れてしまいます。デバウンスがあれば、指が遅くなった瞬間にプレビューが最終値にスナップします。
クリップに保持されるものは意図的に最小限に抑えられています:
class Filters {
final String? adjust; // 手動で選択されたフィルターID、例: 'sepia'
final String? presets; // プリセットID、例: 'P1'
final double? intensity; // 0.0~1.0;nullは1.0を意味します(1.0未満の場合のみ保存)
final Map<String, double>? parameters; // フィルターごとのスライダー値
}
2. ライブプレビュー、パスごとに
主力:4×5カラーマトリックス
カタログのほとんどはカラー計算であり、Skiaカラーマトリックスはカラー計算を無料で実行します。フィルターマネージャーは、フィルターIDとパラメーターをColorFilter.matrixに変換します:
static ColorFilter getColorFilter(String filterId, [Map<String, double> parameters = const {}]) {
final matrix = _getColorMatrixWithParams(filterId, parameters);
return ColorFilter.matrix(matrix);
}
パラメーター化されたフィルターのマトリックスは動的に構築されます。2つの例を示します。マトリックスオフセットは0-255カラースペースに存在するため、**×255**に注意してください:
case 'brightness':
final brightness = (params['brightness'] ?? 0.0) * 255.0;
return [1,0,0,0,brightness, 0,1,0,0,brightness,
0,0,1,0,brightness, 0,0,0,1,0];
case 'contrast':
final contrast = params['contrast'] ?? 1.0;
final offset = -(0.5 * contrast) + 0.5;
return [contrast,0,0,0,offset*255, 0,contrast,0,0,offset*255,
0,0,contrast,0,offset*255, 0,0,0,1,0];
**フィルター強度** — グローバルな「この外観の度合い」スライダー — は、エフェクトマトリックスと単位行列との間の直接補間として実装されます:
final interpolatedMatrix = List<double>.generate(20, (i) {
return identityMatrix[i] + (effectMatrix[i] - identityMatrix[i]) * intensity;
});
この1行のコードにより、すべてのカラーフィルターに、追加のシェーダーやパイプライン作業なしで、0~100%の強度制御が無料で提供されます。
真のGPUパス
ローカルクリップの場合、エディターはflutter_gpu_video_filtersから真のGPUフィルター済みサーフェスに切り替えます。ウィジェットにキーが設定されているため、クリップまたはフィルターを変更すると、サーフェス全体が破棄され、再作成されます(パッケージのコントローラーは実行中のホットな構成変更を好みません):
return GPUVideoSurfacePreview(
key: ValueKey(filterKey), // '${clipIndex}_${filterId}' — 完全な再作成を強制
configuration: _gpuFilterConfiguration!,
onViewCreated: (controller, sizeStream) async {
_gpuPreviewController = controller;
if (_mainController != null && _isPlaying) {
_mainController!.pause(); // 重複再生(および倍になったオーディオ!)を避ける
}
await controller.setVideoSource(FileInputSource(File(clipInfo.assetPath!)));
if (mounted) setState(() => _isGpuPreviewInitialized = true);
},
);
シェーダー構成は、パッケージの型付けされた設定からフィルターごとに組み立てられ、スライダーが書き込むのと同じパラメーターマップによって供給されます:
case 'brightness': return GPUBrightnessConfiguration()..brightness = getParam('brightness', 0.0);
case 'contrast': return GPUContrastConfiguration()..contrast = getParam('contrast', 1.0);
case 'saturation':
return GPUSaturationConfiguration()..saturation = getParam('saturation', 1.0);
case 'hue': return GPUHueConfiguration()..hue = getParam('hue', 0.0);
case 'white_balance': return GPUWhiteBalanceConfiguration()..temperature = getParam('temperature', 5000.0);
デバッグ時間で学んだ2つのライフサイクルに関する教訓:
- GPUサーフェスが開始する前に、基になるプレーヤーを一時停止します。 GPUプレビューはビデオ自体を再生します。一時停止を忘れると、同じクリップを再生する2つのデコーダーが起動し、**倍になった、わずかにずれたオーディオ**が発生します。
- フィルター変更時に適切に破棄します:古い構成をdisconnect()し、新しいプレビューパラメーターを作成し、新しいものをconnect()し、画面を離れる際にコントローラーをdispose()します(さらにサイズストリームの購読をキャンセルします)。
デスクトップでの予期せぬ問題
デスクトップでは、ビデオバックエンドはSkiaのレイヤー合成をバイパスする**外部テクスチャ**にレンダリングします。そのため、ColorFilteredでプレーヤーをラップしても、黙って何も起こりません。変換するSkiaレイヤーがないからです。回避策は、基になるレイヤーのラスタライズを強制すること(例えば、SizedBox.expand上にBackdropFilterを挿入するなど)で、カラーマトリックスが実際に操作するピクセルを持てるようにすることです。「Androidではフィルターが動作するが、デスクトップでは動作しない」場合、これはほぼ間違いなくその理由です。
3. エクスポート:FFmpegでの外観の再構築
プレビューは一時的なものですが、エクスポートは永続的なものです。エクスポート時、保存されたFiltersモデルが解決され(手動で選択されたフィルターがプリセットよりも優先され、プリセットIDは対応するフィルターとパラメーターにマッピングし直されます)、FFmpegフィルターステートメントに変換されます:
switch (normalizedId) {
case 'grayscale': return 'hue=s=0';
case 'sepia': return 'colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131';
case 'invert': return 'negate';
case 'brightness':
final b = params?['brightness'] ?? 0.3; return 'eq=brightness=${b.toStringAsFixed(3)}';
case 'contrast':
final c = params
?['contrast'] ?? 1.5; return 'eq=contrast=${c.toStringAsFixed(3)}';
case 'saturation':
final s = params?['saturation'] ?? 1.5; return 'eq=saturation=${s.toStringAsFixed(3)}';
case 'exposure':
final e = params?['exposure'] ?? 0.4; // FFmpeg eqにはexposureがない — gammaで偽装
final gamma = e >= 0 ? (1.0 - e * 0.5).clamp(0.1, 10.0)
: (1.0 / (1.0 + (-e) * 0.5)).clamp(0.1, 10.0);
return 'eq=gamma=${gamma.toStringAsFixed(3)}';
case 'hue':
final h = params?['hue'] ?? 90.0; return 'hue=h=${h.toStringAsFixed(1)}';
case 'gaussian_blur':
final sigma = (params?['sigma'] ?? 5.0).clamp(
0.1, 50.0);
return 'gblur=sigma=${sigma.toStringAsFixed(1)}';
case 'vignette': return "vignette='PI/4'";
// スワール / バルジ / トゥーン / クワハラ / クロスハッチ ... → ''を返す(プレビューのみ)
}
エクスポート時の強度:分割/ブレンドのトリック
プレビューでは、カラーマトリックスを単位行列に向けて線形補間することで強度を実装することを思い出してください。FFmpegには「マトリックス線形補間」はありませんが、ストリーム合成機能があります。そのため、部分的な強度でのエクスポートは、**ビデオを分割し、一方のブランチをフィルター処理し、保存された不透明度で元のビデオの上にブレンドし直します**:
if (intensity >= 0.99) {
command = ['-i','"$videoPath"','-vf', filterCommand,
'-c:v','libx264','-preset',options.preset,'-crf','${options.crf}',
'-c:a','copy','-movflags','+faststart','-y','"$outputPath"'].join(' ');
} else {
final opacity = intensity.toStringAsFixed(2);
command = ['-i','"$videoPath"','-filter_complex',
'[0:v]split[orig][tofilter];'
'[tofilter]$filterCommand[filtered];'
'[orig][filtered]blend=all_mode=normal:all_opacity=$opacity[out]',
'-map','[out]','-c:v','libx264','-preset',options.preset,'-crf','${options.crf}',
'-c:a','copy','-movflags','+faststart','-y','"$outputPath"'].join(' ');
}
これはマトリックス線形補間と数学的に同一ではありませんが、知覚的には近い結果が得られます。また、カラーマトリックスだけでなく、あらゆるフィルターステートメントで機能します。
フィルターパスは、より長いエクスポートチェーン(キャンバスフィット → **フィルター** → エフェクト → オーバーレイ → オーディオミックス → 連結)における独立した再エンコードの1つです。オーディオは専用のミキシングステージまで-c:a copyによって変更されずに保持され、早期のずれを検出するために、あらゆる段階でソースのデュレーションがプローブされます。
4. パリティテーブル:3つのレンダラーが一致する点としない点
これは誰もが触れない部分です。1つの保存された値、3つの解釈:
| パラメーター | スライダー範囲 | GPUシェーダー | Skiaマトリックス | FFmpegエクスポート |
|---|---|---|---|---|
| 明るさ | −1 … 1 | ネイティブ −1…1 | オフセット = v × 255 | eq=brightness=v |
| コントラスト | 0.5 … 2 | ネイティブ | 対角線 v、オフセット再中央化 | eq=contrast=v |
| 彩度 | 0 … 2 | ネイティブ | Rec.709輝度加重ブレンド | eq=saturation=v |
| 露出 | −1 … 1 | リニアゲイン | リニアゲイン | ガンマカーブで偽装 |
| 色相 | −180° … 180° | ネイティブ | 完全なcos/sin回転マトリックス | hue=h=v |
| セピア | 強度 | シェーダー | マトリックス .393/.769/.189… | colorchannelmixer — 同じ係数、完全なパリティ |
| 強度 | 0 … 1 | プリセット乗数 | マトリックス → 単位行列 線形補間 | 分割 + blend=all_opacity |
苦労して得たパリティの教訓:
- 1つのフィルターを真理値として選択してください。 セピアは、SkiaマトリックスとFFmpegのcolorchannelmixerの両方に同一の3x3係数が現れるため、正確なパリティを達成します。まずそれを作成し、パイプラインの端から端までを検証するために使用してください。
- 単位の不一致に注意してください。 Skiaの明るさオフセットは0~255の空間ですが、GPUシェーダーは-1~1の範囲で動作します。意図的に正規化しない限り、同じスライダー値でも一方のパスでは劇的に強い効果が出ます。
- いくつかのマッピングは近似であり、それは決定事項です。 FFmpegのeqには露出制御がないため、エクスポートは逆ガンマカーブで露出を近似します。リニアゲインプレビューとガンマカーブエクスポートはシャドウ部分で乖離します。許容できるか?おそらく — しかし、意識的に決定し、記録しておくべきです。
- 静かに劣化するパラメーターに注意してください。 初期バージョンでは、ホワイトバランスのKelvinスライダーはGPUプレビューを美しく駆動しましたが、エクスポートではハードコードされた色温度が出力されました。ユーザーは最終的なビデオに何も影響しないスライダーをドラッグしていたのです。3つのパスすべてで各パラメーターを監査してください。
- プレビューのみのフィルターはその旨を明記する必要があります。 歪みや様式化エフェクト(スワール、バルジ、トゥーン、クワハラなど)はGPUシェーダーとしてのみ存在し、それらのエクスポート変換は空の文字列を返し、パイプラインは元のビデオをそのまま通過させます。UIはこれを正直に開示しなければなりません。プレビューとエクスポートの間で黙示的な不一致があると、ユーザーの信頼を失う最速の方法となります。
5. プロダクションからのパフォーマンスに関するメモ
- マトリックスでレンダリングできるものをGPUフィルターしないでください。 ストラテジールーターは、ColorFilteredを介して約24種類のカラーフィルターを処理します。これは事実上無料で、ネットワークストリームで動作し、Skiaが合成するあらゆる場所で機能します。GPUサーフェスは、ローカルファイルとシェーダーのみのエフェクトのために予約されています。
- GPUサーフェスにキーを設定し、ミューテートしないでください。 ValueKey('${clipIndex}_${filterId}')と完全な再作成は、ライブコントローラー上でシェーダー構成をホットスワップしようとするよりも優れています。
- スライダーをデバウンスする(約300ミリ秒) — レスポンシブなサムのために状態をすぐに更新し、フィルターされたプレビューは遅延レンダリングします。
- 必要でない限り、フィルターごとのサムネイルレンダリングはスキップしてください。 私たちのフィルターカルーセルは、30枚のフィルター済みビデオサムネイルの代わりにアイコンタイルを使用しています。フィルターごと、クリップごとに実際のフィルター済みプレビューを生成してキャッシュすることは、低スペックデバイスでは、わずかなUX向上に対して見かけによらず大きなコストとなります。
- 疎に保持してください。 強度は1.0未満の場合のみ書き込まれ、nullは「完全な強度」を意味します。このような小さな選択が、プロジェクトに多数のクリップがある場合にクリップごとのJSONを軽量に保ちます。
学んだ教訓
- 単一の「フィルター実装」は存在しません。 プレビューとエクスポートが異なるエンジンであることを受け入れ、1つの標準的なパラメーターマップを設計し、すべてのレンダラーをその投影として扱ってください。
- 機能によってルーティングしてください。 3段階の戦略(カラーマトリックス → カスタムペインター → GPU/エクスポートのみ)により、ほとんどのフィルターに無料のリアルタイムプレビューを提供し、価値のあるエフェクトのために高価なパスを確保します。
- 強度は、あなたがこれまでリリースする中で最も安価なプレミアム機能です — プレビューではマトリックス線形補間、FFmpegでは分割/ブレンドです。
- パリティはテストサーフェスです。 同じパラメーターを使用して各パスでフレームをレンダリングし、比較してください。あなたが見つける不一致(単位、ガンマとゲイン、ハードコードされた定数など)は、ユーザーがあなたのために見つけてくれたであろうものと全く同じです。
- UIで正直になってください。 フィルターがエクスポート時にのみ存在する場合、またはプレビュー時にのみ存在する場合、その旨を明記してください。信頼は機能の欠落には耐えられますが、予期せぬ事態には耐えられません。
その結果、安価なAndroidスマートフォンからデスクトップビルドまであらゆるものでライブプレビュー、即座のフィードバックを伴うスライダーレベルの調整、名前付きプリセット、そしてユーザーが見たものと同じように見えるエクスポートを提供するフィルターシステムが完成しました。これは、1つのパラメーターマップと、注意深く調整された3つのレンダラーから構築されています。

