Un solo deslizador, tres renderizadores: cómo construimos filtros de video al estilo Instagram con GPU shaders para la previsualización, matrices de color Skia para todo lo demás, y FFmpeg para la exportación — y logramos que todos contaran la misma historia visual.
Los filtros parecen fáciles. Los filtros de video no lo son.
Aplicar un tono sepia a una imagen en Flutter es una sola línea de código — envuélvela en un widget ColorFiltered y listo. Aplicarlo a un video es un deporte completamente diferente:
- La previsualización debe ejecutarse a 30–60 fps sobre un video en reproducción, en cualquier dispositivo que posea el usuario.
- El usuario debe poder arrastrar deslizadores (brillo, contraste, tono, temperatura…) y ver el resultado en vivo.
- Y la parte cruel: cuando pulsan Exportar, el MP4 final debe verse como lo previsualizaron — pero su renderizador de previsualización (Flutter/GPU) y su renderizador de exportación (FFmpeg) son motores completamente diferentes que nunca han oído hablar el uno del otro.
Esta publicación trata sobre cómo construimos esa pipeline en un editor de video de Flutter: un catálogo de filtros con preajustes y ajustes por parámetro, una previsualización en vivo que elige el renderizador más económico que puede hacer el trabajo, y una ruta de exportación de FFmpeg que reproduce el aspecto. La lección principal de antemano:
Cada filtro termina implementado tres veces — como una configuración de GPU shader, como una matriz de color Skia 4×5, y como una cadena de filtro FFmpeg — todos impulsados por un mapa de parámetros compartido. Mantener la consistencia visual de esas tres reimplementaciones es la verdadera ingeniería.
La arquitectura: un mapa de parámetros, tres renderizadores
┌────────────────────────────┐
│ 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)
Las tres rutas existen porque cada una tiene ventajas en algún lugar:
- GPU shaders (flutter_gpu_video_filters) ofrecen efectos de verdadera calidad de shader — distorsiones, desenfoques, mapeo de tonos — pero el paquete renderiza en su propia superficie y funciona mejor con archivos de video locales.
- Skia ColorFilter.matrix — una matriz de color 4×5 envuelta alrededor del widget de video con ColorFiltered — es casi gratuito, funciona en cualquier widget de video (incluyendo transmisiones de red) y cubre las dos docenas de filtros más utilizados: brillo, contraste, sepia, escala de grises, tono, duotonos…
- FFmpeg es el único renderizador cuya salida el usuario realmente conserva. La exportación re-deriva cada aspecto como una cadena de filtro-gráfico (eq=, hue=, colorchannelmixer=…).
Un pequeño router de estrategia decide por filtro qué motor de previsualización usar:
// 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 → envuelve el reproductor en ColorFiltered(colorFilter: getColorFilter(id, params))
- customPainter → superpone una capa de CustomPainter en el video (viñeta, pixelación, semitono — cosas que una matriz de color no puede expresar)
- exportOnly → muestra el video original más una insignia que le dice al usuario que el efecto aparecerá en la exportación
Ese router es la decisión más rentable del sistema: proporciona una previsualización en tiempo real genuinamente gratuita para unos 24 filtros en cada dispositivo, y reserva la maquinaria pesada para los filtros que la necesitan.
1. El catálogo de filtros, preajustes y ajustes
Cada filtro en el catálogo es un pequeño elemento declarativo — un id, un nombre a mostrar, una categoría y (cuando la ruta GPU lo soporta) una fábrica para la configuración del 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();
Los preajustes son simplemente paquetes de parámetros con nombre que apuntan a un filtro — las apariencias rápidas "P1–P5" en la 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}),
];
Los ajustes son descriptores de parámetros tipados con rangos, valores predeterminados e incluso una sugerencia de gradiente para que cada deslizador pueda renderizar una pista significativa (negro→blanco para el brillo, un arcoíris para el tono):
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)];
Los valores actuales del deslizador residen en un objeto de estado inmutable, inicializado con valores predeterminados:
factory FilterAdjustmentState.fromDefaults(String filterId, List<FilterParameter> parameters) {
return FilterAdjustmentState(
filterId: filterId
,
parameterValues: Map.fromEntries(parameters.map((p) => MapEntry(p.id, p.defaultValue))),
);
}
Y un detalle de UX que importa más de lo que parece: las actualizaciones del deslizador escriben al estado inmediatamente (para que el pulgar siga al dedo), pero el re-renderizado de la previsualización se retrasa 300 ms. Sin el retraso, arrastrar un deslizador reconstruye el subárbol de video filtrado docenas de veces por segundo y el arrastre se traba; con él, la previsualización se ajusta al valor final en el momento en que el dedo se ralentiza.
Lo que se persiste en el clip es deliberadamente mínimo:
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. Previsualización en vivo, ruta por ruta
El caballo de batalla: una matriz de color 4×5
La mayor parte del catálogo es matemática de color, y las matrices de color de Skia realizan cálculos de color de forma gratuita. El gestor de filtros convierte un id de filtro + parámetros en un ColorFilter.matrix:
static ColorFilter getColorFilter(String filterId, [Map<String, double> parameters = const {}]) {
final matrix = _getColorMatrixWithParams(filterId, parameters);
return ColorFilter.matrix(matrix);
}
Las matrices de filtros parametrizados se construyen dinámicamente. Dos ejemplos — y observe el ×255, porque los desplazamientos de la matriz viven en el espacio de color 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];
La intensidad del filtro — el deslizador global "cuánto de este aspecto" — se implementa como una interpolación directa entre la matriz de efecto y la matriz identidad:
final interpolatedMatrix = List<double>.generate(20, (i) {
return identityMatrix[i] + (effectMatrix[i] - identityMatrix[i]) * intensity;
});
Esa única línea de código proporciona a cada filtro de color un control de fuerza del 0–100% gratuito, sin trabajo adicional de shader o pipeline.
La ruta GPU real
Para los clips locales, el editor intercambia una superficie filtrada por GPU real de flutter_gpu_video_filters. Debido a que el widget tiene una clave, alterar el clip o el filtro provoca que toda la superficie sea destruida y recreada (el controlador del paquete no admite cambios de configuración en caliente en pleno funcionamiento):
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);
},
);
La configuración del shader se ensambla por filtro a partir de las configuraciones tipadas del paquete, alimentadas por el mismo mapa de parámetros en el que escriben los deslizadores:
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);
Dos lecciones de ciclo de vida aprendidas a base de horas de depuración:
- Pausar el reproductor subyacente antes de que se inicie la superficie de la GPU. La previsualización de la GPU reproduce el video en sí; si olvidas la pausa, obtendrás dos decodificadores reproduciendo el mismo clip — incluyendo audio duplicado y ligeramente desfasado.
- Eliminar correctamente al cambiar de filtro:
disconnect()la configuración antigua, crear nuevos parámetros de previsualización,connect()la nueva ydispose()el controlador (además de cancelar la suscripción al stream de tamaño) al salir de la pantalla.
La sorpresa de escritorio
En escritorio, el backend de video renderiza en una textura externa que omite la composición de capas de Skia — así que envolver el reproductor en ColorFiltered silenciosamente no hace nada; no hay una capa de Skia para transformar. La solución es forzar la rasterización de la capa subyacente (ej. un BackdropFilter interpuesto sobre un SizedBox.expand) para que la matriz de color tenga píxeles reales sobre los que operar. Si tus filtros "funcionan en Android pero no en escritorio", es casi seguro que esta es la razón.
3. Exportación: reconstruyendo el aspecto en FFmpeg
La previsualización es prestada; la exportación es propia. En el momento de la exportación, el modelo de Filters guardado se resuelve (un filtro elegido manualmente tiene prioridad sobre un preajuste; los ids de preajuste se mapean de nuevo a su filtro + parámetros) y se traduce a una cadena de filtro de 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 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)
}
Intensidad en la exportación: el truco de dividir/mezclar
Recuerde que la previsualización implementa la intensidad interpolando la matriz de color hacia la identidad. FFmpeg no tiene una "interpolación de matriz" — pero tiene composición de stream. Así que una exportación de intensidad parcial divide el video, filtra una rama y la mezcla de nuevo sobre el original con la opacidad guardada:
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(' ');
}
No es matemáticamente idéntico a la interpolación de matriz, pero perceptualmente se acerca — y funciona para cualquier cadena de filtro, no solo para matrices de color.
El paso de filtro es una recodificación aislada en una cadena de exportación más larga (ajuste de lienzo → filtro → efectos → superposiciones → mezcla de audio → concatenación), con -c:a copy manteniendo el audio intacto hasta la etapa de mezcla dedicada, y la duración de la fuente se sondea en cada etapa para detectar desincronizaciones tempranas.
4. La tabla de paridad: donde tres renderizadores concuerdan — y donde no
Esta es la parte de la que nadie escribe. Un valor almacenado, tres interpretaciones:
| Parámetro | Rango del deslizador | GPU shader | Matriz Skia | Exportación FFmpeg |
|---|---|---|---|---|
| brillo | −1 … 1 | nativo −1…1 | offset = v × 255 | eq=brightness=v |
| contraste | 0.5 … 2 | nativo | v diagonal, offset recentrado | eq=contrast=v |
| saturación | 0 … 2 | nativo | mezcla ponderada por luminancia Rec.709 | eq=saturation=v |
| exposición | −1 … 1 | ganancia lineal | ganancia lineal | simulado mediante curva gamma |
| tono | −180° … 180° | nativo | matriz de rotación completa cos/sen | hue=h=v |
| sepia | intensidad | shader | matriz .393/.769/.189… | colorchannelmixer — mismos coeficientes, paridad exacta |
| intensidad | 0 … 1 | multiplicador de preajuste | matriz lerp → identidad | split + blend=all_opacity |
Lecciones de paridad difíciles de obtener:
- Elige un filtro como tu verdad fundamental. Sepia logra una paridad exacta porque los coeficientes 3×3 idénticos aparecen tanto en la matriz de Skia como en el colorchannelmixer de FFmpeg. Construye ese primero y úsalo para validar tu pipeline de principio a fin.
- Cuidado con las discrepancias de unidades. El offset de brillo de Skia está en el espacio 0–255 mientras que el GPU shader funciona en −1…1 — el mismo valor de deslizador es drásticamente más agresivo en una ruta a menos que se normalice deliberadamente.
- Algunas asignaciones son aproximaciones, y eso es una decisión. El
eqde FFmpeg no tiene control de exposición, por lo que la exportación aproxima la exposición con una curva gamma inversa. La previsualización de ganancia lineal frente a la exportación de curva gamma divergen en las sombras. ¿Aceptable? Quizás — pero decídelo conscientemente y documéntalo. - Vigila los parámetros que se degradan silenciosamente. En una versión temprana, el deslizador Kelvin de balance de blancos controlaba la previsualización de la GPU de maravilla — mientras que la exportación emitía una temperatura de color hard-coded. El usuario arrastraba un deslizador que no hacía nada en su video final. Audita cada parámetro en las tres rutas.
- Los filtros solo de previsualización deben indicarlo. Los efectos de distorsión y estilización (swirl, bulge, toon, kuwahara…) existen solo como GPU shaders; su traducción de exportación devuelve una cadena vacía y la pipeline pasa el video original sin cambios. La UI tiene que revelar esto honestamente — una discrepancia silenciosa entre la previsualización y la exportación es la forma más rápida de perder la confianza de un usuario.
5. Notas de rendimiento de producción
- No apliques filtros GPU a lo que una matriz puede renderizar. El router de estrategia envía unos 24 filtros de color a través de ColorFiltered — eficazmente gratuito, funciona en transmisiones de red, funciona en todos los lugares donde Skia compone. La superficie de la GPU está reservada para archivos locales y efectos solo de shader.
- Asigna una clave a la superficie de la GPU; no la modifiques.
ValueKey('${clipIndex}_${filterId}')y la recreación completa es mejor que intentar intercambiar configuraciones de shader en caliente en un controlador en vivo. - Debounce en deslizadores (≈300 ms) — actualiza el estado inmediatamente para un pulgar responsivo, re-renderiza la previsualización filtrada de forma perezosa.
- Omite el renderizado de miniaturas por filtro a menos que lo necesites. Nuestro carrusel de filtros utiliza mosaicos de iconos en lugar de 30 miniaturas de video filtradas; generar y almacenar en caché previsualizaciones filtradas reales por filtro y por clip es un costo engañosamente grande en dispositivos de gama baja para una ganancia marginal de UX.
- Persiste de forma dispersa. La intensidad solo se escribe cuando < 1.0; null significa "fuerza máxima". Pequeñas decisiones como esta mantienen el JSON por clip ligero cuando los proyectos tienen docenas de clips.
Lecciones aprendidas
- No existe una única "implementación de filtro". Acepta que la previsualización y la exportación son motores diferentes; diseña un mapa de parámetros canónico y trata cada renderizador como una proyección de este.
- Encaminar por capacidad. Una estrategia de tres niveles (matriz de color → custom painter → solo GPU/exportación) proporciona a la mayoría de los filtros una previsualización en tiempo real gratuita y reserva la ruta costosa para los efectos que lo merecen.
- La intensidad es la característica premium más barata que jamás lanzarás — una interpolación de matriz en la previsualización, una división/mezcla en FFmpeg.
- La paridad es una superficie de prueba. Renderiza un fotograma a través de cada ruta con los mismos parámetros y compara. Las discrepancias que encontrarás (unidades, gamma vs. ganancia, constantes codificadas) son exactamente las que los usuarios habrían encontrado por ti.
- Sé honesto en la UI. Si un filtro solo existe en la exportación — o solo en la previsualización — etiquétalo. La confianza sobrevive a las características faltantes; no sobrevive a las sorpresas.
El resultado: un sistema de filtros con previsualización en vivo en todo, desde un teléfono Android económico hasta una compilación de escritorio, ajustes a nivel de deslizador con retroalimentación instantánea, preajustes con nombre y exportaciones que se ven como lo que el usuario vio — construido a partir de un mapa de parámetros y tres renderizadores cuidadosamente reconciliados.

