Isang slider, tatlong renderer: paano namin binuo ang mga estilong-Instagram na video filter gamit ang GPU shaders para sa preview, Skia color matrices para sa iba pang bahagi, at FFmpeg para sa pag-export β at pinanatili ang mga ito na nagkukuwento ng parehong biswal na istorya.
Mukhang madali ang mga filter. Ngunit hindi ang mga video filter.
Ang paglalagay ng sepia tone sa isang imahe sa Flutter ay isang linya lang β balutin ito sa isang ColorFiltered widget at tapos ka na. Ang paglalagay nito sa isang video ay ibang laro na:
- Ang preview ay dapat tumakbo sa 30β60 fps sa ibabaw ng nagpe-play na video, sa anumang device na pagmamay-ari ng user.
- Dapat kayang i-drag ng user ang mga slider (brightness, contrast, hue, temperatureβ¦) at makita ang resulta nang live.
- At ang malupit na bahagi: kapag pinindot nila ang Export, ang naka-burn-in na MP4 ay dapat magmukhang gaya ng kanilang na-preview β ngunit ang iyong preview renderer (Flutter/GPU) at ang iyong export renderer (FFmpeg) ay ganap na magkaibang engine na hindi pa kailanman nagkaroon ng ugnayan sa isa't isa.
Ang post na ito ay tungkol sa kung paano namin binuo ang pipeline na iyon sa isang Flutter video editor: isang catalog ng filter na may mga preset at per-parameter adjustments, isang live na preview na pumipili ng pinakamurang renderer na kayang gawin ang trabaho, at isang FFmpeg export path na nagre-reproduce ng hitsura. Ang pangunahing aral sa simula:
Bawat filter ay nagtatapos na ipinatupad nang tatlong beses β bilang isang GPU shader configuration, bilang isang Skia 4Γ5 color matrix, at bilang isang FFmpeg filter string β lahat ay pinapatakbo ng isang shared parameter map. Ang pagpapanatili ng biswal na pagkakapare-pareho ng tatlong reimplementation na iyon ay ang tunay na engineering.
Ang arkitektura: isang parameter map, tatlong renderer
ββββββββββββββββββββββββββββββ
β shared parameter map β
β {filterId, params, 0β1 β
β intensity} β
βββββββ¬ββββββββ¬ββββββββ¬βββββββ
β β β
ββββββββββββββββ β βββββββββββββββββ
βΌ βΌ βΌ
GPU shader preview Skia ColorFilter.matrix FFmpeg -vf string
(flutter_gpu_video_ (primary live preview, (export β the only
filters, local clips) universal fallback) thing users keep)
Mayroong tatlong landas dahil bawat isa ay may kalamangan sa isang lugar:
- Nagbibigay ang GPU shaders (flutter_gpu_video_filters) ng tunay na shader-quality effects β mga distortions, blurs, tone mapping β ngunit ang package ay nagre-render sa sarili nitong surface at pinakamahusay na gumagana sa mga lokal na video file.
- Ang Skia ColorFilter.matrix β isang 4Γ5 color matrix na nakabalot sa video widget gamit ang ColorFiltered β ay halos libre, gumagana sa anumang video widget (kasama ang network streams), at sumasaklaw sa dalawang dosenang pinakaginagamit na filter: brightness, contrast, sepia, grayscale, hue, duotonesβ¦
- Ang FFmpeg ang tanging renderer na ang output ay talagang pinananatili ng user. Ang pag-export ay muling kumukuha ng bawat hitsura bilang isang filter-graph string (eq=, hue=, colorchannelmixer=β¦).
Isang maliit na strategy router ang nagpapasya kung aling preview engine ang gagamitin sa bawat filter:
// Which preview strategy can render this filter?
static FilterStrategy getFilterStrategy(String filterId) {
if (_colorFilterIds.contains(filterId)) return FilterStrategy.colorFilter;
if (_customPainterFilterIds.contains(filterId)) return FilterStrategy.customPainter;
return FilterStrategy.exportOnly;
}
- colorFilter β balutin ang player sa ColorFiltered(colorFilter: getColorFilter(id, params))
- customPainter β maglagay ng CustomPainter overlay sa video (vignette, pixelation, halftone β mga bagay na hindi kayang ipahayag ng color matrix)
- exportOnly β ipakita ang orihinal na video at isang badge na nagsasabi sa user na lumalabas ang epekto sa export
Ang router na iyon ang nag-iisang pinaka-cost-effective na desisyon sa sistema: nagbibigay ito ng ~24 na filter ng tunay na libreng real-time preview sa bawat device, at inilalaan ang heavyweight machinery para sa mga filter na nangangailangan nito.
1. Ang catalog ng filter, mga preset, at mga adjustment
Bawat filter sa catalog ay isang maliit na declarative item β isang id, isang display name, isang kategorya, at (kapag sinusuportahan ito ng GPU path) isang factory para sa shader configuration:
const GpuVideoFilterItem({
required this.id,
required this.name,
required this.icon,
required this.category,
this.createConfiguration, // () => GPUFilterConfiguration, when GPU-capable
});
GPUFilterConfiguration? getConfiguration() => createConfiguration?.call();
Ang mga preset ay mga pinangalanang parameter bundle na tumuturo sa isang filter β ang "P1βP5" na mabilis na tingin sa UI:
static List<FilterPreset> get allPresets => [
const FilterPreset(id: 'P1',
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}),
];
Ang mga adjustment ay typed parameter descriptors na may mga range, default, at maging isang gradient hint upang ang bawat slider ay makapag-render ng makabuluhang track (itimβputi para sa brightness, isang bahaghari para sa hue):
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)];
Ang kasalukuyang mga halaga ng slider ay nasa isang immutable state object, na galing sa mga default:
factory FilterAdjustmentState.fromDefaults(String filterId, List<FilterParameter> parameters) {
return FilterAdjustmentState(
filterId: filterId
,
parameterValues: Map.fromEntries(parameters.map((p) => MapEntry(p.id, p.defaultValue)))
,
);
}
At isang detalye ng UX na mas mahalaga kaysa sa mukha nito: ang mga update ng slider ay agad na sumusulat sa state (kaya't sinusubaybayan ng thumb ang daliri), ngunit ang preview re-render ay dina-debounce ng 300 ms. Kung walang debounce, ang pag-drag ng slider ay muling bubuo ng filtered video subtree ng dose-dosenang beses bawat segundo at magkakaroon ng pagkaantala; gamit ito, ang preview ay agad na lumalabas sa huling halaga sa sandaling bumagal ang daliri.
Ang pinananatili sa clip ay sadyang minimal:
class Filters {
final String? adjust; // manually chosen filter id, e.g. 'sepia'
final String? presets; // preset id, e.g. 'P1'
final double? intensity; // 0.0β1.0; null means 1.0 (only saved when < 1.0)
final Map<String, double>? parameters; // per-filter slider values
}
2. Live na preview, bawat landas
Ang workhorse: isang 4Γ5 color matrix
Karamihan sa catalog ay color math, at ang Skia color matrices ay gumagawa ng color math nang libre. Binabago ng filter manager ang filter id + parameters sa isang ColorFilter.matrix:
static ColorFilter getColorFilter(String filterId, [Map<String, double> parameters = const {}]) {
final matrix = _getColorMatrixWithParams(filterId, parameters);
return ColorFilter.matrix(matrix);
}
Ang mga matrices ng parameterized filters ay binuo nang dynamically. Dalawang halimbawa β at tandaan ang Γ255, dahil ang matrix offsets ay nasa 0β255 color space:
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];
Ang intensity ng filter β ang global na slider na "gaano karami ang hitsura na ito" β ay ipinatupad bilang isang direktang interpolation sa pagitan ng effect matrix at ng identity matrix:
final interpolatedMatrix = List<double>.generate(20, (i) {
return identityMatrix[i] + (effectMatrix[i] - identityMatrix[i]) * intensity;
});
Ang isang linyang iyon ay nagbibigay sa bawat color filter ng libreng 0β100% strength control nang walang dagdag na shader o pipeline work.
Ang tunay na GPU path
Para sa mga lokal na clip, ipinapalit ng editor ang isang tunay na GPU-filtered surface mula sa flutter_gpu_video_filters. Dahil naka-key ang widget, ang pagbabago sa clip o filter ay nagiging sanhi ng pagkasira at muling paglikha ng buong surface (ayaw ng controller ng package sa hot configuration switches habang tumatakbo):
return GPUVideoSurfacePreview(
key: ValueKey(filterKey), // '${clipIndex}_${filterId}' β force full recreation
configuration: _gpuFilterConfiguration!,
onViewCreated: (controller, sizeStream) async {
_gpuPreviewController = controller;
if (_mainController != null && _isPlaying) {
_mainController!.pause(); // avoid duplicate playback (and doubled audio!)
}
await controller.setVideoSource(FileInputSource(File(clipInfo.assetPath!)));
if (mounted) setState(() => _isGpuPreviewInitialized = true);
},
);
Ang shader configuration ay binuo bawat filter mula sa mga typed config ng package, na pinakain ng parehong parameter map na sinusulatan ng mga 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);
Dalawang aral sa lifecycle na binayaran ng mga oras ng pag-debug:
- I-pause ang pinagbabatayan na player bago magsimula ang GPU surface. Ang GPU preview mismo ang nagpe-play ng video; kalimutan ang pag-pause at makakakuha ka ng dalawang decoder na nagpe-play ng parehong clip β kasama ang doble, bahagyang offset na audio.
- Tamang pag-dispose sa pagbabago ng filter: disconnect() ang lumang configuration, lumikha ng bagong preview params, connect() ang bago, at dispose() ang controller (pati na rin kanselahin ang size-stream subscription) kapag umaalis sa screen.
Ang desktop curveball
Sa desktop, ang video backend ay nagre-render sa isang external texture na lumalampas sa layer compositing ng Skia β kaya't ang pagbalot sa player sa ColorFiltered ay tahimik na walang ginagawa; walang Skia layer na i-transform. Ang solusyon ay pilitin ang rasterization ng pinagbabatayan na layer (hal. isang interposed BackdropFilter sa ibabaw ng SizedBox.expand) upang ang color matrix ay magkaroon ng aktwal na pixels na gagana. Kung ang iyong mga filter ay "gumagana sa Android ngunit hindi sa desktop," halos tiyak na ito ang dahilan.
3. Export: muling pagbuo ng hitsura sa FFmpeg
Ang preview ay inupahan; ang export ay pagmamay-ari. Sa oras ng pag-export, ang naka-save na Filters model ay nireresolba (ang manu-manong napiling filter ay may prayoridad kaysa sa isang preset; ang preset ids ay ibinabalik sa kanilang filter + parameters) at isinasalin sa isang FFmpeg filter string:
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 has no exposure β fake it with 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)
}
Intensity sa export: ang split/blend trick
Tandaan na ang preview ay nagpapatupad ng intensity sa pamamagitan ng pag-lerp ng color matrix patungo sa identity. Walang "matrix lerp" ang FFmpeg β ngunit mayroon itong stream compositing. Kaya't ang isang partial-intensity export ay hinahati ang video, sinesala ang isang branch, at pinagsasama ito pabalik sa orihinal sa naka-save na opacity:
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(' ');
}
Hindi ito matematikal na magkapareho sa matrix lerp, ngunit sa pandama ay malapit ito β at gumagana ito para sa anumang filter string, hindi lang sa color matrices.
Ang filter pass ay isang nakahiwalay na re-encode sa isang mas mahabang export chain (canvas-fit β filter β effects β overlays β audio mix β concat), na may -c:a copy na pinapanatili ang audio na hindi ginalaw hanggang sa dedicated mixing stage, at ang duration ng source ay sinusuri sa bawat stage upang maagang mahuli ang drift.
4. Ang parity table: kung saan nagkakasundo ang tatlong renderer β at kung saan hindi
Ito ang bahagi na hindi isinusulat ng kahit sino. Isang nakaimbak na halaga, tatlong interpretasyon:
| Parameter | Saklaw ng slider | GPU shader | Skia matrix | FFmpeg export |
|---|---|---|---|---|
| brightness | β1 β¦ 1 | native β1β¦1 | offset = v Γ 255 | eq=brightness=v |
| contrast | 0.5 β¦ 2 | native | diagonal v, offset re-centered | eq=contrast=v |
| saturation | 0 β¦ 2 | native | Rec.709 luma-weighted blend | eq=saturation=v |
| exposure | β1 β¦ 1 | linear gain | linear gain | pineke sa pamamagitan ng gamma curve |
| hue | β180Β° β¦ 180Β° | native | full cos/sin rotation matrix | hue=h=v |
| sepia | intensity | shader | matrix .393/.769/.189β¦ | colorchannelmixer β parehong coefficients, eksaktong parity |
| intensity | 0 β¦ 1 | preset multiplier | lerp matrix β identity | split + blend=all_opacity |
Mga aral sa parity na pinaghirapan:
- Pumili ng isang filter bilang iyong ground truth. Nakakamit ng Sepia ang eksaktong parity dahil ang magkaparehong 3Γ3 coefficients ay lumalabas sa Skia matrix at sa colorchannelmixer ng FFmpeg. Buuin muna iyon at gamitin ito upang i-validate ang iyong pipeline nang buo.
- Mag-ingat sa unit mismatches. Ang Skia brightness offset ay nasa 0β255 space habang ang GPU shader ay gumagana sa β1β¦1 β ang parehong halaga ng slider ay mas agresibo sa isang path maliban kung sadyang i-normalize mo.
- Ang ilang mapping ay approximations, at iyon ay isang desisyon. Walang exposure control ang eq ng FFmpeg, kaya't ang export ay gumagamit ng inverse gamma curve upang tantyahin ang exposure. Nagkakaiba ang linear-gain preview at gamma-curve export sa mga anino. Katanggap-tanggap? Marahil β ngunit magpasya nang may kamalayan at isulat ito.
- Bantayan ang mga parameter na tahimik na bumababa ang kalidad. Sa isang naunang bersyon, ang white-balance Kelvin slider ay maganda ang pagpapagana sa GPU preview β habang ang export ay naglalabas ng isang hard-coded na color temperature. Nag-drag ang user ng slider na walang epekto sa kanilang huling video. I-audit ang bawat parameter sa lahat ng tatlong path.
- Ang mga preview-only filter ay dapat sabihin ito. Ang distortion at stylize effects (swirl, bulge, toon, kuwaharaβ¦) ay umiiral lamang bilang GPU shaders; ang kanilang export translation ay nagbabalik ng isang empty string at ang pipeline ay nagpapadaan sa orihinal na video. Dapat tapat na ibunyag ito ng UI β ang isang tahimik na hindi pagtutugma sa pagitan ng preview at export ang pinakamabilis na paraan upang mawala ang tiwala ng user.
5. Mga tala sa performance mula sa production
- Huwag i-GPU-filter ang kayang i-render ng matrix. Ipinapadala ng strategy router ang ~24 na color filter sa ColorFiltered β halos libre, gumagana sa network streams, gumagana kahit saan na may Skia composites. Ang GPU surface ay inilaan para sa mga lokal na file at shader-only effects.
- I-key ang GPU surface; huwag itong baguhin. Mas mahusay ang ValueKey('${clipIndex}_${filterId}') at buong recreation kaysa sa pagtatangkang hot-swap ang shader configurations sa isang live controller.
- Debounce sliders (β300 ms) β agad na i-update ang state para sa isang responsive thumb, i-re-render ang filtered preview nang dahan-dahan.
- Laktawan ang per-filter thumbnail rendering maliban kung kailangan mo ito. Gumagamit ang aming filter carousel ng icon tiles sa halip na 30 filtered video thumbnails; ang pagbuo at pag-cache ng tunay na filtered previews bawat filter bawat clip ay isang mapanlinlang na malaking gastos sa low-end devices para sa marginal na benepisyo sa UX.
- Mag-persist nang paisa-isa. Ang intensity ay isinusulat lamang kapag < 1.0; ang null ay nangangahulugang "full strength." Ang maliliit na pagpipilian tulad nito ay nagpapanatili sa per-clip JSON na payat kapag ang mga proyekto ay may dose-dosenang clip.
Mga natutunan
- Walang isang "filter implementation." Tanggapin na ang preview at export ay magkaibang engine; magdisenyo ng isang canonical parameter map at ituring ang bawat renderer bilang isang projection nito.
- I-ruta ayon sa kakayahan. Ang isang three-tier strategy (color matrix β custom painter β GPU/export-only) ay nagbibigay sa karamihan ng mga filter ng libreng real-time preview at inilalaan ang mamahaling path para sa mga effect na karapat-dapat dito.
- Ang intensity ang pinakamurang premium feature na iyong mailalabas β isang matrix lerp sa preview, isang split/blend sa FFmpeg.
- Ang parity ay isang test surface. Mag-render ng isang frame sa bawat path na may parehong parameter at ihambing. Ang mga hindi pagtutugma na iyong makikita (mga unit, gamma vs. gain, hard-coded constants) ay eksaktong ang mga makikita sana ng mga user para sa iyo.
- Maging tapat sa UI. Kung ang isang filter ay umiiral lamang sa export β o lamang sa preview β lagyan ito ng label. Ang tiwala ay nakakaligtas sa mga nawawalang feature; hindi ito nakakaligtas sa mga sorpresa.
Ang resulta: isang filter system na may live preview sa lahat mula sa isang budget Android phone hanggang sa isang desktop build, mga slider-level adjustment na may instant feedback, mga pinangalanang preset, at mga export na mukhang gaya ng nakita ng user β binuo mula sa isang parameter map at tatlong maingat na pinagkasundo na renderer.

