Один повзунок, три рендерери: як ми створили відеофільтри в стилі Instagram з GPU-шейдерами для попереднього перегляду, колірними матрицями Skia для всього іншого та FFmpeg для експорту — і зберегли їх усіх, щоб вони розповідали ту саму візуальну історію.
Фільтри виглядають просто. Відеофільтри – ні.
Застосування відтінку сепії до зображення у Flutter – це один рядок коду — оберніть його у віджет ColorFiltered, і готово. Застосування його до відео — це зовсім інша справа:
- Попередній перегляд має працювати з частотою 30–60 fps поверх відео, що відтворюється, на будь-якому пристрої користувача.
- Користувач повинен мати можливість перетягувати повзунки (яскравість, контрастність, відтінок, температура…) і бачити результат у реальному часі.
- І найжорстокіша частина: коли користувач натискає Експорт, вбудований MP4 має виглядати так, як він переглядав — але ваш рендерер для попереднього перегляду (Flutter/GPU) і ваш рендерер для експорту (FFmpeg) є повністю різними рушіями, які ніколи не чули один про одного.
Цей допис про те, як ми побудували цей пайплайн у відеоредакторі Flutter: каталог фільтрів з пресетами та налаштуваннями за параметрами, живий попередній перегляд, який обирає найдешевший рендерер, що може виконати роботу, та шлях експорту FFmpeg, який відтворює вигляд. Головний урок одразу:
Кожен фільтр зрештою реалізується тричі — як конфігурація GPU-шейдера, як колірна матриця Skia 4×5, і як рядок фільтра FFmpeg — усі керуються однією спільною картою параметрів. Підтримання візуальної узгодженості цих трьох реалізацій — це справжнє інженерне завдання.
Архітектура: одна карта параметрів, три рендерери
┌────────────────────────────┐
│ спільна карта параметрів │
│ {filterId, params, 0–1 │
│ intensity} │
└─────┬───────┬───────┬──────┘
│ │ │
┌──────────────┘ │ └───────────────┐
▼ ▼ ▼
Попередній перегляд GPU-шейдера Skia ColorFilter.matrix FFmpeg -vf рядок
(flutter_gpu_video_ (основний живий попередній (експорт — єдине,
filters, локальні кліпи) перегляд, універсальний що зберігають користувачі)
резервний варіант)
Три шляхи існують, тому що кожен з них виграє в чомусь:
- GPU-шейдери (flutter_gpu_video_filters) дають справжні ефекти якості шейдерів — спотворення, розмиття, тональне відображення — але пакет рендерить на свою власну поверхню і найкраще працює з локальними відеофайлами.
- Skia ColorFilter.matrix — колірна матриця 4×5, обгорнута навколо відеовіджета за допомогою ColorFiltered — майже безкоштовна, працює з будь-яким відеовіджетом (включаючи мережеві потоки) і охоплює два десятки найпопулярніших фільтрів: яскравість, контрастність, сепія, відтінки сірого, відтінок, двотонні…
- 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 overlay на відео (віньєтка, пікселізація, напівтон — те, що не може виразити колірна матриця)
- exportOnly → показує оригінальне відео плюс значок, який повідомляє користувачеві, що ефект з'явиться в експорті
Цей маршрутизатор є єдиним найбільш економічно ефективним рішенням у системі: він дає ~24 фільтрам справді безкоштовний попередній перегляд у реальному часі на кожному пристрої та резервує потужну техніку для фільтрів, яким вона потрібна.
1. Каталог фільтрів, пресети та налаштування
Кожен фільтр у каталозі — це крихітний декларативний елемент: ідентифікатор, відображуване ім'я, категорія та (коли шлях GPU підтримує це) фабрика для конфігурації шейдера:
const GpuVideoFilterItem({
required this.id,
required this.name,
required this.icon,
required this.category,
this.createConfiguration, // () => GPUFilterConfiguration, when GPU-capable
});
GPUFilterConfiguration? getConfiguration() => createConfiguration?.call();
Пресети — це просто названі набори параметрів, що вказують на фільтр — швидкі налаштування «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, яка має більше значення, ніж здається: оновлення повзунків одразу записуються в стан (щоб повзунок відстежував палець), але повторний рендеринг попереднього перегляду відкладається на 300 мс. Без цього затримки, перетягування повзунка перебудовує відфільтроване піддерево відео десятки разів на секунду, і перетягування зависає; з нею попередній перегляд переходить до кінцевого значення в момент, коли палець уповільнюється.
Те, що зберігається в кліпі, є навмисно мінімальним:
class Filters {
final String? adjust; // вручну обраний ідентифікатор фільтра, наприклад 'sepia'
final String? presets; // ідентифікатор пресету, наприклад 'P1'
final double? intensity; // 0.0–1.0; null означає 1.0 (зберігається лише коли < 1.0)
final Map<String, double>? parameters; // значення повзунків для кожного фільтра
}
2. Живий попередній перегляд, шлях за шляхом
Робоча конячка: колірна матриця 4×5
Більшість каталогу — це колірна математика, і колірні матриці Skia виконують колірну математику безкоштовно. Менеджер фільтрів перетворює ідентифікатор фільтра + параметри на ColorFilter.matrix:
static ColorFilter getColorFilter(String filterId, [Map<String, double> parameters = const {}]) {
final matrix = _getColorMatrixWithParams(filterId, parameters);
return ColorFilter.matrix(matrix);
}
Матриці параметризованих фільтрів конструюються динамічно. Два приклади — і зверніть увагу на ×255, тому що матричні зміщення існують у колірному просторі 0–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;
});
Цей один рядок надає кожному колірному фільтру безкоштовний контроль інтенсивності 0–100% без додаткової роботи з шейдерами чи пайплайном.
Справжній шлях GPU
Для локальних кліпів редактор замінює справжню GPU-відфільтровану поверхню з flutter_gpu_video_filters. Оскільки віджет має ключ, зміна кліпу або фільтра призводить до знищення та повторного створення всієї поверхні (контролер пакета не любить гарячі перемикачі конфігурації в польоті):
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);
Два життєві уроки, оплачені годинами налагодження:
- Призупиніть базовий плеєр до запуску поверхні GPU. Попередній перегляд GPU відтворює відео сам; забудьте про паузу, і ви отримаєте два декодери, що відтворюють той самий кліп — включаючи подвоєне, трохи зміщене аудіо.
- Правильно звільняйте ресурси при зміні фільтра: відключіть (`disconnect()`) стару конфігурацію, створіть нові параметри попереднього перегляду, підключіть (`connect()`) нову, і звільніть (`dispose()`) контролер (плюс скасуйте підписку на потік розмірів) при виході з екрана.
Несподіванка для настільних ПК
На настільних ПК відео-бек-енд рендерить у зовнішню текстуру, яка обходить компонування шарів Skia — тому обгортання програвача в ColorFiltered мовчки нічого не робить; немає шару Skia для трансформації. Обхідним шляхом є примусова растеризація базового шару (наприклад, проміжний BackdropFilter над SizedBox.expand), щоб колірна матриця мала реальні пікселі для роботи. Якщо ваші фільтри "працюють на Android, але не на настільних ПК", це майже напевно причина.
3. Експорт: відновлення вигляду у FFmpeg
Попередній перегляд є тимчасовим; експорт — постійним. Під час експорту збережена модель Filters розшифровується (обраний вручну фільтр має пріоритет над пресетом; ідентифікатори пресетів зіставляються з їхнім фільтром + параметрами) і перетворюється на рядок фільтра 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'";
// swirl / bulge / toon / kuwahara / crosshatch ... → return '' (preview-only)
}
Інтенсивність при експорті: трюк split/blend
Пам'ятайте, попередній перегляд реалізує інтенсивність шляхом lerping колірної матриці до одиничної. FFmpeg не має "lerp матриці" — але має компонування потоків. Отже, експорт з частковою інтенсивністю розділяє відео, фільтрує одну гілку та змішує її з оригіналом із збереженою прозорістю:
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(' ');
}
Це не математично ідентично lerp матриці, але сприйняттєво дуже близько — і працює для будь-якого рядка фільтра, а не тільки для колірних матриць.
Прохід фільтра є одним ізольованим перекодуванням у довшому ланцюжку експорту (canvas-fit → filter → effects → overlays → audio mix → concat), де -c:a copy зберігає аудіо недоторканим до спеціального етапу мікшування, а тривалість джерела перевіряється на кожному етапі, щоб рано виявити зміщення.
4. Таблиця паритету: де три рендерери збігаються — і де ні
Це та частина, про яку ніхто не пише. Одне збережене значення, три інтерпретації:
| Параметр | Діапазон повзунка | 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 | множник пресету | lerp матриця → ідентичність | split + blend=all_opacity |
Важко здобуті уроки паритету:
- Оберіть один фільтр як свою істинну основу. Сепія досягає точного паритету, оскільки ідентичні коефіцієнти 3×3 з'являються як у матриці Skia, так і в colorchannelmixer FFmpeg. Побудуйте його першим і використовуйте для перевірки свого пайплайну від початку до кінця.
- Остерігайтеся розбіжностей одиниць. Зсув яскравості Skia вимірюється в діапазоні 0–255, тоді як GPU-шейдер працює в діапазоні −1…1 — те саме значення повзунка є значно агресивнішим на одному шляху, якщо ви навмисно не нормалізуєте.
- Деякі відображення є наближеними, і це рішення. `eq` у FFmpeg не має контролю експозиції, тому експорт апроксимує експозицію за допомогою зворотної гамма-кривої. Лінійно-підсилений попередній перегляд проти гамма-кривої експорту розходяться в тінях. Прийнятно? Можливо — але вирішуйте це свідомо і запишіть.
- Слідкуйте за параметрами, які тихо погіршуються. У ранній версії повзунок Kelvin для балансу білого чудово керував попереднім переглядом GPU — тоді як експорт видавав жорстко закодовану колірну температуру. Користувач перетягував повзунок, який нічого не робив для його кінцевого відео. Перевіряйте кожен параметр на всіх трьох шляхах.
- Фільтри тільки для попереднього перегляду мають бути позначені. Ефекти спотворення та стилізації (swirl, bulge, toon, kuwahara…) існують лише як GPU-шейдери; їх переклад для експорту повертає порожній рядок, і пайплайн пропускає оригінальне відео. Інтерфейс користувача повинен чесно це розкривати — прихована невідповідність між попереднім переглядом та експортом є найшвидшим способом втратити довіру користувача.
5. Зауваження щодо продуктивності з виробництва
- Не фільтруйте GPU те, що може відтворити матриця. Маршрутизатор стратегій надсилає ~24 колірних фільтри через ColorFiltered — це фактично безкоштовно, працює з мережевими потоками, працює всюди, де Skia компонує. Поверхня GPU зарезервована для локальних файлів і ефектів, що працюють лише з шейдерами.
- Ключуйте поверхню GPU; не змінюйте її. ValueKey('${clipIndex}_${filterId}') та повне перестворення краще, ніж спроба гарячої заміни конфігурацій шейдерів на живому контролері.
- Обмежте оновлення повзунків (≈300 мс) — оновлюйте стан негайно для швидкої реакції повзунка, повторно рендерить відфільтрований попередній перегляд ліниво.
- Пропустіть рендеринг мініатюр для кожного фільтра, якщо це не потрібно. Наша карусель фільтрів використовує значки замість 30 відфільтрованих мініатюр відео; генерування та кешування реальних відфільтрованих попередніх переглядів для кожного фільтра на кліп є оманливо великою вартістю на пристроях низького класу для маргінального покращення UX.
- Зберігайте рідко. Інтенсивність записується лише тоді, коли < 1.0; null означає "повну силу". Такі дрібні вибори зберігають JSON для кожного кліпу компактним, коли проекти мають десятки кліпів.
Вивчені уроки
- Не існує єдиної "реалізації фільтра". Прийміть, що попередній перегляд та експорт є різними рушіями; розробіть одну канонічну карту параметрів і розглядайте кожен рендерер як проекцію її.
- Маршрутизація за можливостями. Трирівнева стратегія (колірна матриця → custom painter → тільки GPU/експорт) надає більшості фільтрів безкоштовний попередній перегляд у реальному часі та зберігає дорогий шлях для ефектів, які цього вимагають.
- Інтенсивність — найдешевша преміум-функція, яку ви коли-небудь випустите — matrix lerp у попередньому перегляді, split/blend у FFmpeg.
- Паритет — це тестова поверхня. Відрендеріть кадр через кожен шлях з однаковими параметрами та порівняйте. Невідповідності, які ви знайдете (одиниці, гамма проти посилення, жорстко закодовані константи), — це саме ті, які знайшли б для вас користувачі.
- Будьте чесними в інтерфейсі користувача. Якщо фільтр існує лише під час експорту — або лише в попередньому перегляді — позначте його. Довіра виживає відсутність функцій; вона не виживає несподіванок.
Результат: система фільтрів з живим попереднім переглядом на всьому, від бюджетного телефону Android до настільної збірки, налаштуваннями на рівні повзунків з миттєвим зворотним зв'язком, названими пресетами та експортом, який виглядає так, як бачив користувач — побудована на одній карті параметрів та трьох ретельно узгоджених рендерерах.

