Un curseur, trois moteurs de rendu : comment nous avons construit des filtres vidéo de type Instagram avec des shaders GPU pour la prévisualisation, des matrices de couleur Skia pour partout ailleurs, et FFmpeg pour l'exportation — tout en maintenant une cohérence visuelle.
Les filtres semblent faciles. Les filtres vidéo ne le sont pas.
Appliquer un ton sépia à une image en Flutter est un jeu d'enfant — il suffit de l'envelopper dans un widget ColorFiltered et le tour est joué. L'appliquer à une vidéo est une tout autre affaire :
- La prévisualisation doit fonctionner à 30–60 ips au-dessus d'une vidéo en lecture, sur n'importe quel appareil de l'utilisateur.
- L'utilisateur doit pouvoir faire glisser des curseurs (luminosité, contraste, teinte, température…) et voir le résultat en direct.
- Et le plus difficile : lorsqu'ils cliquent sur Exporter, le fichier MP4 encodé doit ressembler à ce qu'ils ont prévisualisé — mais votre moteur de rendu de prévisualisation (Flutter/GPU) et votre moteur de rendu d'exportation (FFmpeg) sont des moteurs complètement différents qui n'ont jamais eu connaissance l'un de l'autre.
Cet article explique comment nous avons construit ce pipeline dans un éditeur vidéo Flutter : un catalogue de filtres avec des préréglages et des ajustements par paramètre, une prévisualisation en direct qui choisit le moteur de rendu le moins cher capable de faire le travail, et un chemin d'exportation FFmpeg qui reproduit l'apparence. La leçon principale à retenir est la suivante :
Chaque filtre est finalement implémenté trois fois — en tant que configuration de shader GPU, en tant que matrice de couleur Skia 4x5, et en tant que chaîne de filtre FFmpeg — le tout étant piloté par une carte de paramètres partagée unique. Maintenir la cohérence visuelle de ces trois réimplémentations est le véritable défi d'ingénierie.
L'architecture : une carte de paramètres, trois moteurs de rendu
┌────────────────────────────┐
│ carte de paramètres partagée │
│ {filterId, params, 0–1 │
│ intensity} │
└─────┬───────┬───────┬──────┘
│ │ │
┌──────────────┘ │ └───────────────┐
▼ ▼ ▼
Prévisualisation shader GPU Skia ColorFilter.matrix Chaîne FFmpeg -vf
(flutter_gpu_video_ (prévisualisation en direct (exportation — la seule
filters, clips locaux) principale, solution de repli universelle) chose que les utilisateurs conservent)
Les trois chemins existent parce que chacun excelle dans un domaine :
- Les shaders GPU (flutter_gpu_video_filters) offrent de véritables effets de qualité shader — distorsions, flous, mappage de tons — mais le package rend sur sa propre surface et fonctionne mieux avec les fichiers vidéo locaux.
- Le Skia ColorFilter.matrix — une matrice de couleur 4x5 enveloppant le widget vidéo avec ColorFiltered — est pratiquement gratuit, fonctionne sur n'importe quel widget vidéo (y compris les flux réseau) et couvre les deux douzaines de filtres les plus utilisés : luminosité, contraste, sépia, niveaux de gris, teinte, duotones…
- FFmpeg est le seul moteur de rendu dont les utilisateurs conservent réellement la sortie. L'exportation redérive chaque apparence en tant que chaîne de graphe de filtres (eq=, hue=, colorchannelmixer=…).
Un petit routeur de stratégie décide par filtre quel moteur de prévisualisation utiliser :
// 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 → enveloppe le lecteur dans ColorFiltered(colorFilter: getColorFilter(id, params))
- customPainter → superpose un calque CustomPainter sur la vidéo (vignette, pixélisation, demi-teinte — des choses qu'une matrice de couleur ne peut pas exprimer)
- exportOnly → affiche la vidéo originale plus un badge indiquant à l'utilisateur que l'effet apparaît dans l'exportation
Ce routeur est la décision la plus rentable du système : il offre une prévisualisation en temps réel véritablement gratuite pour environ 24 filtres sur chaque appareil, et réserve la machinerie lourde aux filtres qui en ont besoin.
1. Le catalogue de filtres, préréglages et ajustements
Chaque filtre du catalogue est un petit élément déclaratif — un identifiant, un nom d'affichage, une catégorie, et (lorsque le chemin GPU le prend en charge) une fabrique pour la configuration du shader :
const GpuVideoFilterItem({
required this.id,
required this.name,
required this.icon,
required this.category,
this.createConfiguration, // () => GPUFilterConfiguration, when GPU-capable
});
GPUFilterConfiguration? getConfiguration() => createConfiguration?.call();
Les préréglages sont simplement des ensembles de paramètres nommés pointant vers un filtre — les aperçus rapides "P1–P5" dans l'interface utilisateur :
static List<FilterPreset> get allPresets => [
const FilterPreset(id: 'P1', name: 'Vintage Chaleureux',
filterId: 'sepia', parameters: {'intensity': 0.8}),
const FilterPreset(id: 'P2', name: 'Noir & Blanc Classique',
filterId: 'grayscale', parameters: {'intensity': 1.0}),
const FilterPreset(id: 'P3', name: 'Couleurs Vives',
filterId: 'vibrance', parameters: {'vibrance': 0.5}),
const FilterPreset(id: 'P4', name: 'Contraste Élevé',
filterId: 'contrast', parameters: {'contrast': 1.4}),
const FilterPreset(id: 'P5', name: 'Cinématique',
filterId: 'vignette', parameters: {'vignetteStart': 0.3, 'vignetteEnd': 0.75}),
];
Les ajustements sont des descripteurs de paramètres typés avec des plages, des valeurs par défaut, et même un indice de dégradé pour que chaque curseur puisse afficher une piste significative (noir→blanc pour la luminosité, un arc-en-ciel pour la teinte) :
case 'brightness':
return [const FilterParameter(id: 'brightness', displayName: 'Luminosité',
minValue: -1.0, maxValue: 1.0, defaultValue: 0.0,
gradientType: FilterParameterGradientType.blackToWhite)];
case 'contrast':
return [const FilterParameter(id: 'contrast', displayName: 'Contraste',
minValue: 0.5, maxValue: 2.0, defaultValue: 1.0,
gradientType: FilterParameterGradientType.grayToWhite)];
case 'hue':
return [const FilterParameter(id: 'hue', displayName: 'Teinte',
minValue: -180.0, maxValue: 180.0, defaultValue: 0.0, unit: '°',
gradientType: FilterParameterGradientType.rainbow)];
Les valeurs actuelles des curseurs résident dans un objet d'état immuable, initialisé à partir des valeurs par défaut :
factory FilterAdjustmentState.fromDefaults(String filterId, List<FilterParameter> parameters) {
return FilterAdjustmentState(
filterId: filterId,
parameterValues: Map.fromEntries(parameters.map((p) => MapEntry(p.id, p.defaultValue))),
);
}
Et un détail UX qui compte plus qu'il n'y paraît : les mises à jour du curseur écrivent l'état immédiatement (de sorte que le pouce suit le doigt), mais le re-rendu de la prévisualisation est temporisé de 300 ms. Sans cette temporisation, faire glisser un curseur reconstruirait le sous-arbre vidéo filtré des dizaines de fois par seconde et le glissement saccaderait ; avec elle, la prévisualisation s'ajuste à la valeur finale dès que le doigt ralentit.
Ce qui est persisté sur le clip est délibérément minimal :
class Filters {
final String? adjust; // ID du filtre choisi manuellement, ex: 'sepia'
final String? presets; // ID du préréglage, ex: 'P1'
final double? intensity; // 0.0–1.0 ; null signifie 1.0 (enregistré uniquement si < 1.0)
final Map<String, double>? parameters; // valeurs des curseurs par filtre
}
2. Prévisualisation en direct, chemin par chemin
Le moteur principal : une matrice de couleur 4x5
La majeure partie du catalogue est constituée de calculs de couleurs, et les matrices de couleur Skia effectuent ces calculs gratuitement. Le gestionnaire de filtres transforme un ID de filtre + paramètres en un ColorFilter.matrix :
static ColorFilter getColorFilter(String filterId, [Map<String, double> parameters = const {}]) {
final matrix = _getColorMatrixWithParams(filterId, parameters);
return ColorFilter.matrix(matrix);
}
Les matrices des filtres paramétrés sont construites dynamiquement. Deux exemples — et notez le ×255, car les décalages de matrice sont dans l'espace colorimétrique 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];
L'intensité du filtre — le curseur global « quelle proportion de ce look » — est implémentée comme une interpolation linéaire entre la matrice d'effet et la matrice d'identité :
final interpolatedMatrix = List<double>.generate(20, (i) {
return identityMatrix[i] + (effectMatrix[i] - identityMatrix[i]) * intensity;
});
Cette seule ligne offre à chaque filtre de couleur un contrôle gratuit de la force de 0 à 100 %, sans travail supplémentaire de shader ou de pipeline.
Le véritable chemin GPU
Pour les clips locaux, l'éditeur utilise une véritable surface filtrée par GPU de flutter_gpu_video_filters. Le widget étant doté d'une clé, la modification du clip ou du filtre entraîne la destruction et la recréation de toute la surface (le contrôleur du package n'aime pas les changements de configuration à chaud en cours de route) :
return GPUVideoSurfacePreview(
key: ValueKey(filterKey), // '${clipIndex}_${filterId}' — force la recréation complète
configuration: _gpuFilterConfiguration!,
onViewCreated: (controller, sizeStream) async {
_gpuPreviewController = controller;
if (_mainController != null && _isPlaying) {
_mainController!.pause(); // évite la lecture en double (et l'audio doublé !)
}
await controller.setVideoSource(FileInputSource(File(clipInfo.assetPath!)));
if (mounted) setState(() => _isGpuPreviewInitialized = true);
},
);
La configuration du shader est assemblée par filtre à partir des configurations typées du package, alimentée par la même carte de paramètres que celle dans laquelle les curseurs écrivent :
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);
Deux leçons de cycle de vie payées en heures de débogage :
- Mettez en pause le lecteur sous-jacent avant que la surface GPU ne démarre. La prévisualisation GPU lit la vidéo elle-même ; oubliez la pause et vous obtiendrez deux décodeurs lisant le même clip — y compris un audio doublé, légèrement décalé.
- Libérez correctement les ressources lors d'un changement de filtre : déconnectez() l'ancienne configuration, créez de nouveaux paramètres de prévisualisation, connectez() la nouvelle, et disposez() le contrôleur (plus annulez l'abonnement au flux de taille) en quittant l'écran.
Le coup inattendu du bureau
Sur les ordinateurs de bureau, le backend vidéo rend dans une texture externe qui contourne la composition des couches de Skia — ainsi, envelopper le lecteur dans ColorFiltered ne fait silencieusement rien ; il n'y a pas de couche Skia à transformer. La solution consiste à forcer la rastérisation de la couche sous-jacente (par exemple, un BackdropFilter interposé sur un SizedBox.expand) afin que la matrice de couleur ait des pixels réels sur lesquels opérer. Si vos filtres « fonctionnent sur Android mais pas sur les ordinateurs de bureau », c'est presque certainement la raison.
3. Exportation : reconstruire l'apparence dans FFmpeg
La prévisualisation est louée ; l'exportation est possédée. Au moment de l'exportation, le modèle Filters sauvegardé est résolu (un filtre choisi manuellement a priorité sur un préréglage ; les IDs des préréglages sont remappés à leur filtre + paramètres) et traduit en une chaîne de filtre 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; // L'eq de FFmpeg n'a pas d'exposition — simulez-la avec le 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'";
// tourbillon / déformation / toon / kuwahara / hachures... → retourne '' (aperçu seulement)
}
Intensité à l'exportation : l'astuce du split/blend
Rappelez-vous que la prévisualisation implémente l'intensité en interpolant la matrice de couleur vers l'identité. FFmpeg n'a pas de « lerp de matrice » — mais il dispose de la composition de flux. Ainsi, une exportation à intensité partielle divise la vidéo, filtre une branche et la mélange à nouveau sur l'original à l'opacité enregistrée :
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(' ');
}
Ce n'est pas mathématiquement identique à l'interpolation matricielle, mais perceptuellement, le résultat est proche — et cela fonctionne pour n'importe quelle chaîne de filtre, pas seulement pour les matrices de couleur.
Le passage du filtre est un ré-encodage isolé dans une chaîne d'exportation plus longue (ajustement toile → filtre → effets → superpositions → mixage audio → concaténation), avec -c:a copy qui maintient l'audio intact jusqu'à l'étape de mixage dédiée, et la durée source sondée à chaque étape pour détecter rapidement les décalages.
4. Le tableau de parité : où les trois moteurs de rendu s'accordent — et où ils ne s'accordent pas
C'est la partie dont personne ne parle. Une valeur stockée, trois interprétations :
| Paramètre | Plage du curseur | Shader GPU | Matrice Skia | Exportation FFmpeg |
|---|---|---|---|---|
| brightness | −1 … 1 | native −1…1 | décalage = v × 255 | eq=brightness=v |
| contrast | 0.5 … 2 | native | diagonale v, décalage recentré | eq=contrast=v |
| saturation | 0 … 2 | native | mélange pondéré par la luminance Rec.709 | eq=saturation=v |
| exposure | −1 … 1 | gain linéaire | gain linéaire | simulé via courbe gamma |
| hue | −180° … 180° | native | matrice de rotation complète cos/sin | hue=h=v |
| sepia | intensity | shader | matrice .393/.769/.189… | colorchannelmixer — mêmes coefficients, parité exacte |
| intensity | 0 … 1 | multiplicateur préréglé | interpolation linéaire de la matrice → identité | division + blend=all_opacity |
Leçons de parité durement acquises :
- Choisissez un filtre comme référence. Le sépia atteint une parité exacte car les coefficients 3x3 identiques apparaissent à la fois dans la matrice Skia et dans le colorchannelmixer de FFmpeg. Construisez celui-là en premier et utilisez-le pour valider votre pipeline de bout en bout.
- Méfiez-vous des incohérences d'unités. Le décalage de luminosité Skia est dans l'espace 0-255 tandis que le shader GPU fonctionne en −1…1 — la même valeur de curseur est considérablement plus agressive sur un chemin à moins d'une normalisation délibérée.
- Certaines correspondances sont des approximations, et c'est une décision. L'eq de FFmpeg n'a pas de contrôle d'exposition, donc l'exportation l'approche avec une courbe gamma inverse. La prévisualisation à gain linéaire par rapport à l'exportation à courbe gamma diverge dans les ombres. Acceptable ? Peut-être — mais décidez-le consciemment et documentez-le.
- Surveillez les paramètres qui se dégradent silencieusement. Dans une version antérieure, le curseur Kelvin de la balance des blancs pilotait parfaitement la prévisualisation GPU — tandis que l'exportation émettait une température de couleur codée en dur. L'utilisateur faisait glisser un curseur qui n'affectait pas sa vidéo finale. Auditez chaque paramètre sur les trois chemins.
- Les filtres de prévisualisation seulement doivent l'indiquer. Les effets de distorsion et de stylisation (tourbillon, déformation, toon, kuwahara…) n'existent qu'en tant que shaders GPU ; leur traduction à l'exportation renvoie une chaîne vide et le pipeline laisse passer la vidéo originale. L'interface utilisateur doit le divulguer honnêtement — une incohérence silencieuse entre la prévisualisation et l'exportation est le moyen le plus rapide de perdre la confiance d'un utilisateur.
5. Notes de performance de la production
- Ne filtrez pas par GPU ce qu'une matrice peut rendre. Le routeur de stratégie envoie environ 24 filtres de couleur via ColorFiltered — c'est pratiquement gratuit, fonctionne sur les flux réseau, et partout où Skia effectue la composition. La surface GPU est réservée aux fichiers locaux et aux effets uniquement basés sur les shaders.
- Clétez la surface GPU ; ne la modifiez pas. Utiliser ValueKey('${clipIndex}_${filterId}') et recréer entièrement est plus efficace que d'essayer d'échanger à chaud les configurations de shader sur un contrôleur en direct.
- Temporisez les curseurs (≈300 ms) — mettez à jour l'état immédiatement pour un pouce réactif, et re-rendez la prévisualisation filtrée de manière paresseuse.
- Ignorez le rendu des vignettes par filtre, sauf si nécessaire. Notre carrousel de filtres utilise des tuiles d'icônes au lieu de 30 vignettes vidéo filtrées ; générer et mettre en cache de véritables prévisualisations filtrées par filtre et par clip représente un coût trompeusement élevé sur les appareils bas de gamme pour un gain UX marginal.
- Persistez de manière éparse. L'intensité n'est écrite que lorsque < 1.0 ; null signifie « pleine puissance ». De petits choix comme celui-ci maintiennent le JSON par clip léger lorsque les projets comportent des dizaines de clips.
Leçons apprises
- Il n'y a pas d'« implémentation de filtre » unique. Acceptez que la prévisualisation et l'exportation soient des moteurs différents ; concevez une carte de paramètres canonique unique et traitez chaque moteur de rendu comme une projection de celle-ci.
- Acheminez par capacité. Une stratégie à trois niveaux (matrice de couleur → custom painter → GPU/exportation seulement) offre une prévisualisation en temps réel gratuite à la plupart des filtres et réserve le chemin coûteux aux effets qui le justifient.
- L'intensité est la fonctionnalité premium la moins chère que vous livrerez jamais — une interpolation matricielle en prévisualisation, un split/blend dans FFmpeg.
- La parité est une surface de test. Rendez une image à travers chaque chemin avec les mêmes paramètres et comparez. Les incohérences que vous trouverez (unités, gamma vs gain, constantes codées en dur) sont exactement celles que les utilisateurs auraient trouvées pour vous.
- Soyez honnête dans l'interface utilisateur. Si un filtre n'existe qu'à l'exportation — ou seulement en prévisualisation — étiquetez-le. La confiance survit aux fonctionnalités manquantes ; elle ne survit pas aux surprises.
Le résultat : un système de filtres avec prévisualisation en direct sur tout, d'un téléphone Android économique à une version de bureau, des ajustements au niveau des curseurs avec retour instantané, des préréglages nommés, et des exportations qui ressemblent à ce que l'utilisateur a vu — construit à partir d'une carte de paramètres unique et de trois moteurs de rendu soigneusement conciliés.

