Satu slider, tiga renderer: bagaimana kami membangun filter video bergaya Instagram dengan shader GPU untuk pratinjau, matriks warna Skia untuk di tempat lain, dan FFmpeg untuk ekspor โ dan menjaga semuanya menceritakan kisah visual yang sama.
Filter terlihat mudah. Filter video tidak.
Menerapkan nada sepia pada gambar di Flutter itu mudah โ cukup bungkus dengan widget ColorFiltered dan selesai. Menerapkannya pada video adalah hal yang sama sekali berbeda:
- Pratinjau harus berjalan pada 30โ60 fps di atas video yang sedang diputar, di perangkat apa pun yang dimiliki pengguna.
- Pengguna harus dapat menyeret slider (kecerahan, kontras, rona, suhuโฆ) dan melihat hasilnya secara langsung.
- Dan bagian yang kejam: ketika mereka menekan Ekspor, MP4 yang sudah tersemat harus terlihat seperti yang mereka pratinjau โ tetapi renderer pratinjau Anda (Flutter/GPU) dan renderer ekspor Anda (FFmpeg) adalah mesin yang sama sekali berbeda yang belum pernah saling mengenal.
Posting ini adalah tentang bagaimana kami membangun pipeline tersebut dalam editor video Flutter: katalog filter dengan preset dan penyesuaian per-parameter, pratinjau langsung yang memilih renderer termurah yang dapat melakukan pekerjaan, dan jalur ekspor FFmpeg yang mereproduksi tampilan. Pelajaran utamanya di awal:
Setiap filter akhirnya diimplementasikan tiga kali โ sebagai konfigurasi shader GPU, sebagai matriks warna Skia 4ร5, dan sebagai string filter FFmpeg โ semuanya digerakkan oleh satu peta parameter bersama. Mempertahankan konsistensi visual dari tiga reimplementasi tersebut adalah rekayasa sejati.
Arsitektur: satu peta parameter, tiga renderer
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ shared parameter map โ
โ {filterId, params, 0โ1 โ
โ intensity} โ
โโโโโโโฌโโโโโโโโฌโโโโโโโโฌโโโโโโโ
โ โ โ
โโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโโ
โผ โผ โผ
Pratinjau shader GPU Skia ColorFilter.matrix FFmpeg -vf string
(flutter_gpu_video_ (pratinjau langsung utama, (ekspor โ satu-satunya
filter, klip lokal) fallback universal) hal yang disimpan pengguna)
Ketiga jalur ada karena masing-masing memiliki keunggulan:
- Shader GPU (flutter_gpu_video_filters) memberikan efek kualitas shader sejati โ distorsi, blur, tone mapping โ tetapi paket ini merender ke permukaannya sendiri dan bekerja paling baik dengan file video lokal.
- Skia ColorFilter.matrix โ matriks warna 4ร5 yang membungkus widget video dengan ColorFiltered โ hampir gratis, berfungsi pada widget video apa pun (termasuk aliran jaringan), dan mencakup dua lusin filter yang paling sering digunakan: kecerahan, kontras, sepia, skala abu-abu, rona, duotoneโฆ
- FFmpeg adalah satu-satunya renderer yang keluarannya benar-benar disimpan pengguna. Ekspor mendapatkan kembali setiap tampilan sebagai string filter-graph (eq=, hue=, colorchannelmixer=โฆ).
Sebuah router strategi kecil memutuskan per-filter mesin pratinjau mana yang akan digunakan:
// Strategi pratinjau mana yang dapat merender filter ini?
static FilterStrategy getFilterStrategy(String filterId) {
if (_colorFilterIds.contains(filterId)) return FilterStrategy.colorFilter;
if (_customPainterFilterIds.contains(filterId)) return FilterStrategy.customPainter;
return FilterStrategy.exportOnly;
}
- colorFilter โ bungkus pemutar dalam ColorFiltered(colorFilter: getColorFilter(id, params))
- customPainter โ tumpuk overlay CustomPainter pada video (vinyet, pikselasi, halftone โ hal-hal yang tidak dapat diekspresikan oleh matriks warna)
- exportOnly โ tampilkan video asli ditambah lencana yang memberitahukan kepada pengguna bahwa efek tersebut muncul dalam ekspor
Router itu adalah keputusan paling hemat biaya dalam sistem: ia memberikan sekitar 24 filter pratinjau real-time gratis di setiap perangkat, dan menyimpan mesin berat untuk filter yang membutuhkannya.
1. Katalog filter, preset, dan penyesuaian
Setiap filter dalam katalog adalah item deklaratif kecil โ ID, nama tampilan, kategori, dan (ketika jalur GPU mendukungnya) sebuah factory untuk konfigurasi shader:
const GpuVideoFilterItem({
required this.id,
required this.name,
required this.icon,
required this.category,
this.createConfiguration, // () => GPUFilterConfiguration, saat GPU-capable
});
GPUFilterConfiguration? getConfiguration() => createConfiguration?.call();
Preset hanyalah bundel parameter bernama yang menunjuk ke filter โ tampilan cepat "P1โP5" di UI:
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}),
];
Penyesuaian adalah deskriptor parameter bertipe dengan rentang, nilai default, dan bahkan petunjuk gradien sehingga setiap slider dapat merender trek yang bermakna (hitamโputih untuk kecerahan, pelangi untuk rona):
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)];
Nilai slider saat ini berada dalam objek state yang imut, diinisialisasi dari nilai default:
factory FilterAdjustmentState.fromDefaults(String filterId, List<FilterParameter> parameters) {
return FilterAdjustmentState(
filterId: filterId
,
parameterValues: Map.fromEntries(parameters.map((p) => MapEntry(p.id, p.defaultValue)))
,
);
}
Dan satu detail UX yang lebih penting dari kelihatannya: pembaruan slider langsung menulis ke state (sehingga thumb mengikuti jari), tetapi render ulang pratinjau ditunda 300 ms. Tanpa penundaan, menyeret slider akan membangun kembali subtree video yang difilter puluhan kali per detik dan menyeretnya tersendat-sendat; dengan penundaan, pratinjau akan langsung menyesuaikan ke nilai akhir saat jari melambat.
Apa yang disimpan pada klip sengaja dibuat minimal:
class Filters {
final String? adjust; // id filter yang dipilih secara manual, mis. 'sepia'
final String? presets; // id preset, mis. 'P1'
final double? intensity; // 0.0โ1.0; null berarti 1.0 (hanya disimpan jika < 1.0)
final Map<String, double>? parameters; // nilai slider per-filter
}
2. Pratinjau Langsung, jalur demi jalur
Yang pekerja keras: matriks warna 4ร5
Sebagian besar katalog adalah matematika warna, dan matriks warna Skia melakukan matematika warna secara gratis. Manajer filter mengubah ID filter + parameter menjadi ColorFilter.matrix:
static ColorFilter getColorFilter(String filterId, [Map<String, double> parameters = const {}]) {
final matrix = _getColorMatrixWithParams(filterId, parameters);
return ColorFilter.matrix(matrix);
}
Matriks filter berparameter dibangun secara dinamis. Dua contoh โ dan perhatikan ร255, karena offset matriks berada dalam ruang warna 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];
Intensitas filter โ slider global "seberapa banyak tampilan ini" โ diimplementasikan sebagai interpolasi langsung antara matriks efek dan matriks identitas:
final interpolatedMatrix = List<double>.generate(20, (i) {
return identityMatrix[i] + (effectMatrix[i] - identityMatrix[i]) * intensity;
});
Satu baris kode itu memberikan setiap filter warna kontrol kekuatan 0โ100% secara gratis tanpa pekerjaan shader atau pipeline tambahan.
Jalur GPU yang sebenarnya
Untuk klip lokal, editor menukar permukaan yang difilter GPU sejati dari flutter_gpu_video_filters. Karena widget diberi kunci, mengubah klip atau filter menyebabkan seluruh permukaan dihancurkan dan dibuat ulang (controller paket tidak menyukai peralihan konfigurasi panas di tengah jalan):
return GPUVideoSurfacePreview(
key: ValueKey(filterKey), // '${clipIndex}_${filterId}' โ paksa pembuatan ulang penuh
configuration: _gpuFilterConfiguration!,
onViewCreated: (controller, sizeStream) async {
_gpuPreviewController = controller;
if (_mainController != null && _isPlaying) {
_mainController!.pause(); // hindari pemutaran duplikat (dan audio ganda!)
}
await controller.setVideoSource(FileInputSource(File(clipInfo.assetPath!)));
if (mounted) setState(() => _isGpuPreviewInitialized = true);
},
);
Konfigurasi shader dirakit per filter dari konfigurasi bertipe paket, diisi oleh peta parameter yang sama yang ditulis oleh slider:
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);
Dua pelajaran siklus hidup yang dibayar dengan jam debugging:
- Jeda pemutar yang mendasarinya sebelum permukaan GPU dimulai. Pratinjau GPU memutar video itu sendiri; lupakan jeda dan Anda akan mendapatkan dua decoder yang memutar klip yang sama โ termasuk audio yang digandakan, sedikit bergeser.
- Buang dengan benar saat perubahan filter: disconnect() konfigurasi lama, buat parameter pratinjau baru, connect() yang baru, dan dispose() controller (ditambah batalkan langganan size-stream) saat meninggalkan layar.
Curveball desktop
Di desktop, backend video merender ke tekstur eksternal yang melewati komposit lapisan Skia โ jadi membungkus pemutar dalam ColorFiltered secara diam-diam tidak melakukan apa-apa; tidak ada lapisan Skia untuk diubah. Solusinya adalah memaksa rasterisasi lapisan yang mendasarinya (mis. BackdropFilter yang disisipkan di atas SizedBox.expand) sehingga matriks warna memiliki piksel aktual untuk dioperasikan. Jika filter Anda "bekerja di Android tetapi tidak di desktop," ini hampir pasti alasannya.
3. Ekspor: membangun kembali tampilan di FFmpeg
Pratinjau disewa; ekspor dimiliki. Pada waktu ekspor, model Filter yang disimpan diselesaikan (filter yang dipilih secara manual lebih diutamakan daripada preset; ID preset dipetakan kembali ke filter + parameternya) dan diterjemahkan ke dalam string filter 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 tidak memiliki eksposur โ palsukan dengan 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 ... โ kembalikan '' (hanya pratinjau)
}
Intensitas saat ekspor: trik split/blend
Ingat pratinjau mengimplementasikan intensitas dengan melakukan lerping matriks warna menuju identitas. FFmpeg tidak memiliki "matrix lerp" โ tetapi ia memiliki komposit stream. Jadi ekspor intensitas parsial memisahkan video, memfilter satu cabang, dan memadukannya kembali di atas yang asli pada opasitas yang disimpan:
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(' ');
}
Secara matematis tidak identik dengan matrix lerp, tetapi secara persepsi hasilnya mendekati โ dan berfungsi untuk string filter apa pun, bukan hanya matriks warna.
Pass filter adalah satu re-encode terisolasi dalam rantai ekspor yang lebih panjang (canvas-fit โ filter โ efek โ overlay โ mix audio โ concat), dengan -c:a copy menjaga audio tidak tersentuh sampai tahap mixing khusus, dan durasi sumber diperiksa di setiap tahap untuk menangkap drift lebih awal.
4. Tabel paritas: di mana tiga renderer setuju โ dan di mana mereka tidak
Ini adalah bagian yang tidak pernah ditulis siapa pun. Satu nilai yang disimpan, tiga interpretasi:
| Parameter | Rentang Slider | Shader GPU | Matriks Skia | Ekspor FFmpeg |
|---|---|---|---|---|
| kecerahan | โ1 โฆ 1 | native โ1โฆ1 | offset = v ร 255 | eq=brightness=v |
| kontras | 0.5 โฆ 2 | native | diagonal v, offset re-centered | eq=contrast=v |
| saturasi | 0 โฆ 2 | native | Rec.709 luma-weighted blend | eq=saturation=v |
| eksposur | โ1 โฆ 1 | linear gain | linear gain | dipalsukan via kurva gamma |
| rona | โ180ยฐ โฆ 180ยฐ | native | full cos/sin rotation matrix | hue=h=v |
| sepia | intensity | shader | matrix .393/.769/.189โฆ | colorchannelmixer โ koefisien yang sama, paritas yang tepat |
| intensitas | 0 โฆ 1 | preset multiplier | lerp matrix โ identity | split + blend=all_opacity |
Pelajaran paritas yang sulit didapat:
- Pilih satu filter sebagai kebenaran dasar Anda. Sepia mencapai paritas persis karena koefisien 3ร3 yang identik muncul di matriks Skia dan colorchannelmixer FFmpeg. Bangun yang itu terlebih dahulu dan gunakan untuk memvalidasi pipeline Anda secara menyeluruh.
- Waspadai ketidakcocokan unit. Offset kecerahan Skia berada di ruang 0โ255 sementara shader GPU bekerja di โ1โฆ1 โ nilai slider yang sama secara dramatis lebih agresif pada satu jalur kecuali Anda menormalisasinya dengan sengaja.
- Beberapa pemetaan adalah perkiraan, dan itu adalah keputusan. eq FFmpeg tidak memiliki kontrol eksposur, jadi ekspor mendekati eksposur dengan kurva gamma terbalik. Pratinjau gain linear vs. ekspor kurva gamma menyimpang di bayangan. Dapat diterima? Mungkin โ tetapi putuskan secara sadar dan tuliskan.
- Perhatikan parameter yang diam-diam menurun. Dalam versi awal, slider Kelvin white-balance menggerakkan pratinjau GPU dengan indah โ sementara ekspor mengeluarkan suhu warna hard-coded. Pengguna menyeret slider yang tidak melakukan apa-apa pada video akhirnya. Audit setiap parameter di ketiga jalur.
- Filter khusus pratinjau harus mengatakannya. Efek distorsi dan gaya (swirl, bulge, toon, kuwaharaโฆ) hanya ada sebagai shader GPU; terjemahan ekspornya mengembalikan string kosong dan pipeline hanya melewati video asli. UI harus mengungkapkan ini secara jujur โ ketidakcocokan diam-diam antara pratinjau dan ekspor adalah cara tercepat untuk kehilangan kepercayaan pengguna.
5. Catatan kinerja dari produksi
- Jangan GPU-filter apa yang bisa dirender matriks. Router strategi mengirimkan sekitar 24 filter warna melalui ColorFiltered โ secara efektif gratis, berfungsi pada aliran jaringan, berfungsi di mana pun Skia mengkomposit. Permukaan GPU dicadangkan untuk file lokal dan efek khusus shader.
- Kunci permukaan GPU; jangan mutasi. ValueKey('${clipIndex}_${filterId}') dan pembuatan ulang penuh mengalahkan upaya untuk menukar konfigurasi shader secara langsung pada kontroler aktif.
- Tunda slider (sekitar 300 ms) โ perbarui state segera untuk responsif thumb, render ulang pratinjau yang difilter secara lambat.
- Lewati rendering thumbnail per-filter kecuali Anda membutuhkannya. Carousel filter kami menggunakan ubin ikon alih-alih 30 thumbnail video yang difilter; menghasilkan dan menyimpan cache pratinjau yang difilter secara nyata per filter per klip adalah biaya yang sangat besar pada perangkat kelas bawah untuk keuntungan UX yang marginal.
- Pertahankan secara jarang. Intensitas hanya ditulis ketika < 1.0; null berarti "kekuatan penuh." Pilihan kecil seperti ini menjaga JSON per-klip tetap ramping ketika proyek memiliki lusinan klip.
Pelajaran yang dipetik
- Tidak ada "implementasi filter" tunggal. Terimalah bahwa pratinjau dan ekspor adalah mesin yang berbeda; rancang satu peta parameter kanonik dan perlakukan setiap renderer sebagai proyeksi darinya.
- Rute berdasarkan kemampuan. Strategi tiga tingkat (matriks warna โ pelukis kustom โ GPU/hanya ekspor) memberikan pratinjau real-time gratis untuk sebagian besar filter dan menyimpan jalur mahal untuk efek yang membutuhkannya.
- Intensitas adalah fitur premium termurah yang akan Anda kirimkan โ matrix lerp di pratinjau, split/blend di FFmpeg.
- Paritas adalah permukaan pengujian. Render satu frame melalui setiap jalur dengan parameter yang sama dan bandingkan. Ketidakcocokan yang akan Anda temukan (unit, gamma vs. gain, konstanta hard-coded) adalah persis yang akan ditemukan pengguna untuk Anda.
- Jujurlah dalam UI. Jika filter hanya ada saat ekspor โ atau hanya dalam pratinjau โ beri label. Kepercayaan bertahan dari fitur yang hilang; itu tidak bertahan dari kejutan.
Hasilnya: sistem filter dengan pratinjau langsung pada segala hal mulai dari ponsel Android murah hingga build desktop, penyesuaian tingkat slider dengan umpan balik instan, preset bernama, dan ekspor yang terlihat seperti yang dilihat pengguna โ dibangun dari satu peta parameter dan tiga renderer yang diselaraskan dengan cermat.

