Diskuter dit projekt
MicrocosmWorksInnovere og Arkitektere Digitale Kosmos
OmKontakt
MicrocosmWorksInnoverer og arkitekterer digitale kosmos

Leverer IT-løsninger, der betyder noget. Vi brænder for teknologi, sikkerhed og at hjælpe virksomheder med at vokse gennem pålidelig, innovativ IT-infrastruktur.

hello@microcosmworks.com
+91 7011868196
New Delhi, India

Løsninger

BygAI ProduktudviklingSaaS ProduktudviklingSkræddersyet Softwareudvikling
ModerniserSoftwaremoderniseringAI-moderniseringCloud App-modernisering
SkalerBackend & Distribuerede SystemerCloud YdelsesingeniørPålideligheds- og YdelsesingeniørAI-infrastruktur
UdvidProduktudviklingsteams
Alle løsningerAI AgentudviklingAI VideoplatformSundhed & Fitness Apps

Tjenester

Digital RådgivningCloud InfrastrukturSaaS UdviklingAI UdviklingVideo Teknologi
ERP UdviklingZoho TilpasningOdoo UdviklingSalesforce-integrationTilpasset CRM Udvikling
QuickBooks-integrationIoT LøsningerBlockchain Udvikling
Cybersikkerhed RådgivningIT-support - L3

AI Væksthub

AI HubStartup-innovationVirksomhedsaccelerator

Ressourcer

IndsigterIndustri GuiderBrugssag BlueprintsArkitektur MønstreCase Studier

Virksomhed

Om OsKontaktDiskuter dit projektVores Arbejde

© 2026 MicrocosmWorks. Alle rettigheder forbeholdes.

PrivatlivspolitikServicevilkår
Tilbage til indsigter
AI Development

Sådan byggede vi Instagram-lignende GPU-videofiltre i Flutter

Lær, hvordan vi byggede produktionsklare GPU-videofiltre i Flutter ved hjælp af GPU shaders, Skia farvematricer og FFmpeg. Opdag arkitekturen bag forhåndsvisninger i realtid, justerbare filterforudindstillinger og eksportparitet, der holder de renderede videoer konsistente på tværs af hvert trin.

Saurav Kumar Gupta's image Saurav Kumar Gupta
•
October 3, 2026
•
Opdateret October 4, 2026
Modern 3D illustration of MCP, APIs, and RAG working together to power enterprise AI integrations.webp

Én skyder, tre renderere: sådan byggede vi Instagram-lignende videofiltre med GPU shaders til forhåndsvisning, Skia farvematricer til alle andre steder, og FFmpeg til eksport – og fik dem alle til at fortælle den samme visuelle historie.

Filtre ser nemme ud. Videofiltre er det ikke.

At anvende en sepia-tone på et billede i Flutter er en enkelt linje – pak det ind i en ColorFiltered widget, og du er færdig. At anvende det på en video er en helt anden sag:

  • Forhåndsvisningen skal køre med 30–60 fps oven på en afspillende video, på enhver enhed brugeren ejer.
  • Brugeren skal kunne trække i skydere (lysstyrke, kontrast, farvetone, temperatur...) og se resultatet live.
  • Og den grusomme del: når de trykker på Eksport, skal den indlejrede MP4 se ud, som den gjorde i forhåndsvisningen – men din forhåndsvisnings-renderer (Flutter/GPU) og din eksport-renderer (FFmpeg) er helt forskellige motorer, der aldrig har hørt om hinanden.

Dette indlæg handler om, hvordan vi byggede den pipeline i en Flutter videoeditor: et filterkatalog med forudindstillinger og justeringer per parameter, en live forhåndsvisning, der vælger den billigste renderer, der kan udføre opgaven, og en FFmpeg eksportsti, der gengiver udseendet. Den vigtigste lektion med det samme:

Hvert filter ender med at blive implementeret tre gange — som en GPU shader-konfiguration, som en Skia 4×5 farvematrix og som en FFmpeg filterstreng — alle styret af ét fælles parameterkort. At opretholde den visuelle konsistens af disse tre reimplementationer er den sande ingeniørarbejde.

Arkitekturen: ét parameterkort, tre renderere

                       ┌────────────────────────────┐
                      │   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)

De tre veje eksisterer, fordi hver især vinder et sted:

  1. GPU shaders (flutter_gpu_video_filters) giver ægte shader-kvalitetseffekter — forvrængninger, sløringer, tone-mapping — men pakken renderes til sin egen overflade og fungerer bedst med lokale videofiler.
  2. Skia ColorFilter.matrix — en 4×5 color matrix pakket omkring video-widget'en med ColorFiltered — er næsten gratis, fungerer på enhver video-widget (inklusive netværksstreams), og dækker de mest brugte to dusin filtre: brightness, contrast, sepia, grayscale, hue, duotones…
  3. FFmpeg er den eneste renderer, hvis output brugeren faktisk beholder. Eksporten genskaber hvert look som en filtergraf-streng (eq=, hue=, colorchannelmixer=...).

En lille strategi-router bestemmer per-filter, hvilken forhåndsvisnings-motor der skal bruges:

// 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 → pak afspilleren ind i ColorFiltered(colorFilter: getColorFilter(id, params))
  • customPainter → læg et CustomPainter overlay oven på videoen (vignette, pixelering, rasterisering — ting en color matrix ikke kan udtrykke)
  • exportOnly → vis den originale video plus et badge, der fortæller brugeren, at effekten vises i eksporten

Denne router er den enkeltstående mest omkostningseffektive beslutning i systemet: den giver ca. 24 filtre ægte gratis forhåndsvisning i realtid på enhver enhed og reserverer det tunge maskineri til de filtre, der har brug for det.

1. Filterkataloget, forudindstillinger og justeringer

Hvert filter i kataloget er et lille deklarativt element — et id, et visningsnavn, en kategori, og (når GPU-stien understøtter det) en fabrik til shader-konfigurationen:

const GpuVideoFilterItem({
  required this.id,
  required this.name,
  required this.icon,
  required this.category,
  this.createConfiguration, // () => GPUFilterConfiguration, when GPU-capable
});

GPUFilterConfiguration? getConfiguration() => createConfiguration?.call();

Forudindstillinger er blot navngivne parameterbundter, der peger på et filter — de "P1–P5" hurtige udseender i UI'en:

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}),
];

Justeringer er typiserede parameterbeskrivelser med intervaller, standardværdier og endda et gradient-hint, så hver skyder kan gengive et meningsfuldt spor (sort→hvid for lysstyrke, en regnbue for farvetone):

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)];

De nuværende skyder-værdier lever i et uforanderligt tilstandsobjekt, initialiseret med standardværdier:

factory FilterAdjustmentState.fromDefaults(String filterId, List<FilterParameter> parameters) {
  return FilterAdjustmentState(
    filterId: filterId,
    parameterValues: Map.fromEntries(parameters.map((p) => MapEntry(p.id, p.defaultValue))),
  );
}

Og en UX-detalje, der betyder mere, end det ser ud til: skyderopdateringer skriver straks til tilstanden (så skyderhåndtaget følger fingeren), men forhåndsvisnings-re-render er debounced med 300 ms. Uden debounce genopbygges det filtrerede videotræ snesevis af gange i sekundet, når en skyder trækkes, hvilket får trækket til at hakke; med det springer forhåndsvisningen til den endelige værdi i det øjeblik fingeren sænker farten.

Hvad der persisteres på klippet er bevidst minimalt:

class Filters {
  final String? adjust;     // manuelt valgt filter-id, f.eks. 'sepia'
  final String? presets;    // preset-id, f.eks. 'P1'
  final double? intensity;  // 0.0–1.0; null betyder 1.0 (kun gemt når < 1.0)
  final Map<String, double>? parameters; // skyder-værdier per filter
}

2. Live forhåndsvisning, sti for sti

Arbejdshesten: en 4×5 farvematrix

Det meste af kataloget er farvematematik, og Skia farvematricer udfører farvematematik gratis. Filter-manageren omdanner et filter-id + parametre til en ColorFilter.matrix:

static ColorFilter getColorFilter(String filterId, [Map<String, double> parameters = const {}]) {
  final matrix = _getColorMatrixWithParams(filterId, parameters);
  return ColorFilter.matrix(matrix);
}

Matricerne for parametriserede filtre konstrueres dynamisk. To eksempler — og bemærk ×255, fordi matrixforskydninger lever i 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];

Filter intensity — den globale "hvor meget af dette look" skyder — er implementeret som en direkte interpolation mellem effektmatricen og identitetsmatricen:

final interpolatedMatrix = List<double>.generate(20, (i) {
  return identityMatrix[i] + (effectMatrix[i] - identityMatrix[i]) * intensity;
});

Den ene linje giver hvert farvefilter en gratis 0–100% styrkekontrol uden ekstra shader- eller pipeline-arbejde.

Den rigtige GPU-sti

For lokale klip udskifter editoren en ægte GPU-filtreret overflade fra flutter_gpu_video_filters. Fordi widget'en er key'et, medfører ændring af klippet eller filteret, at hele overfladen ødelægges og genoprettes (pakkens controller bryder sig ikke om hot-configuration-skift undervejs):

return GPUVideoSurfacePreview(
  key: ValueKey(filterKey), // '${clipIndex}_${filterId}' — tving fuld genoprettelse
  configuration: _gpuFilterConfiguration!,
  onViewCreated: (controller, sizeStream) async {
    _gpuPreviewController = controller;
    if (_mainController != null && _isPlaying) {
      _mainController!.pause(); // undgå dobbelt afspilning (og fordoblet lyd!)
    }
    await controller.setVideoSource(FileInputSource(File(clipInfo.assetPath!)));
    if (mounted) setState(() => _isGpuPreviewInitialized = true);
  },
);

Shader-konfigurationen samles per filter fra pakkens typiserede configs, fodret af det samme parameterkort, som skyderne skriver til:

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);

To livscyklus-lektioner betalt med fejlfindings-timer:

  • Sæt den underliggende afspiller på pause, før GPU-overfladen starter. GPU-forhåndsvisningen afspiller videoen selv; glem pausen, og du får to dekodere, der afspiller det samme klip — inklusive dobbelt, let forskudt lyd.
  • Ryd op korrekt ved filterskift: disconnect() den gamle konfiguration, opret nye forhåndsvisningsparametre, connect() den nye, og dispose() controlleren (samt annuller size-stream abonnementet), når skærmen forlades.

Desktop-udfordringen

På desktop renderes video-backend'en til en ekstern tekstur, der omgår Skia's layer compositing — så at pakke afspilleren ind i ColorFiltered gør stille og roligt ingenting; der er intet Skia-lag at transformere. Løsningen er at tvinge rasterisering af det underliggende lag (f.eks. en indskudt BackdropFilter over en SizedBox.expand), så farvematricen har faktiske pixels at arbejde på. Hvis dine filtre "fungerer på Android, men ikke på desktop", er dette næsten helt sikkert årsagen.

3. Eksport: genskabelse af udseendet i FFmpeg

Forhåndsvisningen er lejet; eksporten er ejet. Ved eksporttidspunktet løses den gemte Filters-model (et manuelt valgt filter har forrang over en forudindstilling; forudindstillings-id'er mappes tilbage til deres filter + parametre) og oversættes til en FFmpeg filterstreng:

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 har ingen eksponering — falsk den med 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 '' (kun forhåndsvisning)
}

Intensitet ved eksport: split/blend-tricket

Husk, at forhåndsvisningen implementerer intensitet ved at lerpe color matrix'en mod identitet. FFmpeg har ingen "matrix lerp" — men det har stream compositing. Så en delvis intensitets-eksport deler videoen, filtrerer den ene gren og blander den tilbage over originalen med den gemte opacitet:

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(' ');
}

Det er ikke matematisk identisk med matrix lerp'en, men perceptuelt lander det tæt på — og det fungerer for enhver filterstreng, ikke kun color matrices.

Filter-pass'et er en isoleret re-encode i en længere eksportkæde (canvas-fit → filter → effects → overlays → audio mix → concat), med -c:a copy der holder lyden uberørt indtil den dedikerede mixing stage, og kildens varighed sonderes ved hvert trin for at fange drift tidligt.

4. Paritetstabellen: hvor tre renderere er enige — og hvor de ikke er

Dette er den del, ingen skriver om. Én gemt værdi, tre fortolkninger:

ParameterSkyderområdeGPU shaderSkia matrixFFmpeg eksport
brightness−1 … 1native −1…1forskydning = v × 255eq=brightness=v
contrast0.5 … 2nativediagonal v, forskydning re-centrereteq=contrast=v
saturation0 … 2nativeRec.709 luma-vægtet blandingeq=saturation=v
exposure−1 … 1linear gainlinear gainfalsk via gammakurve
hue−180° … 180°nativefuld cos/sin rotationsmatrixhue=h=v
sepiaintensityshadermatrix .393/.769/.189…colorchannelmixer — samme koefficienter, eksakt paritet
intensity0 … 1preset multiplikatorlerp matrix → identitysplit + blend=all_opacity

Hårdt vundne paritets-lektioner:

  • Vælg ét filter som din sande reference. Sepia opnår eksakt paritet, fordi de identiske 3×3 koefficienter optræder i både Skia matrixen og FFmpeg's colorchannelmixer. Byg den først og brug den til at validere din pipeline ende til ende.
  • Vær opmærksom på enheds-uoverensstemmelser. Skia's brightness offset er i 0–255 rummet, mens GPU shaderen arbejder i −1…1 — den samme skyder-værdi er dramatisk mere aggressiv på den ene sti, medmindre du normaliserer bevidst.
  • Nogle mappninger er tilnærmelser, og det er en beslutning. FFmpeg's eq har ingen exposure control, så eksporten tilnærmer exposure med en omvendt gammakurve. Linear-gain preview vs. gammakurve-eksport afviger i skyggerne. Acceptabelt? Måske — men beslut det bevidst, og skriv det ned.
  • Hold øje med parametre, der nedbrydes lydløst. I en tidlig version styrede white-balance Kelvin slideren GPU-preview'et smukt — mens eksporten udsendte en hardkodet color temperature. Brugeren trak i en slider, der intet gjorde ved deres final video. Audit hver parameter på tværs af alle tre paths.
  • Forhåndsvisnings-kun-filtre skal angive dette. Distortion og stylize effects (swirl, bulge, toon, kuwahara...) eksisterer kun som GPU shaders; deres eksport-oversættelse returnerer en tom streng, og pipelinen lader den originale video passere igennem. UI'en skal ærligt oplyse om dette — et lydløst mismatch mellem preview og export er den hurtigste måde at miste en brugers trust på.

5. Præstationsbemærkninger fra produktionen

  • Filtrer ikke med GPU, hvad en matrix kan gengive. Strategi-routeren sender ~24 color filters gennem ColorFiltered — effektivt gratis, fungerer på network streams, fungerer overalt hvor Skia composites. GPU surface er reserveret til lokale filer og shader-only effects.
  • Key GPU-overfladen; muter den ikke. ValueKey('${clipIndex}_${filterId}') og fuld recreation er bedre end at forsøge at hot-swappe shader configurations på en live controller.
  • Debounce skydere (≈300 ms) — opdater state øjeblikkeligt for en responsive thumb, re-render den filtered preview dovent.
  • Spring rendering af thumbnail billeder per filter over, medmindre du har brug for det. Vores filter carousel bruger icon tiles i stedet for 30 filtered video thumbnails; generering og caching af real filtered previews per filter per clip er en deceptively large cost på low-end devices for marginal UX gain.
  • Persister sparsomt. Intensitet skrives kun, når < 1.0; null betyder "full strength." Tiny choices like this keep per-clip JSON lean when projects have dozens of clips.

Lærdomme

  1. Der er ikke en enkelt "filter implementation." Accepter, at preview og export er different engines; design one canonical parameter map and treat every renderer as a projection of it.
  2. Route efter kapacitet. En tre-lags strategy (color matrix → custom painter → GPU/export-only) giver most filters free real-time preview og saves the expensive path for effects that earn it.
  3. Intensitet er den billigste premium-funktion, du nogensinde vil sende ud — en matrix lerp i preview, en split/blend i FFmpeg.
  4. Paritet er en testflade. Render a frame through each path with the same parameters and compare. The mismatches you'll find (units, gamma vs. gain, hard-coded constants) er exactly the ones users would have found for you.
  5. Vær ærlig i UI'en. If a filter only exists at export — or only in preview — label it. Trust survives missing features; it doesn't survive surprises.

Resultatet: et filtersystem med live forhåndsvisning på alt fra en budget Android-telefon til en desktop-version, justeringer på skyder-niveau med øjeblikkelig feedback, navngivne forudindstillinger og eksporter, der ligner det, brugeren så — bygget fra ét parameterkort og tre omhyggeligt afstemte renderere.

Media ProcessingVideo FiltersGPU RenderingFlutter DevelopmentReal-Time Rendering
Saurav Kumar Gupta's image

Om forfatteren

Saurav Kumar Gupta

AI & Cloud Solutions Expert at MicrocosmWorks

Building innovative AI-powered solutions and helping businesses transform through cutting-edge technology.

Vil du lære mere?

Kontakt os for at diskutere, hvordan vi kan hjælpe med at implementere disse løsninger for din virksomhed.

Kom i Kontakt

Ofte stillede spørgsmål

Flutter can deliver smooth real-time video filters by selecting the most efficient rendering method for each effect, such as GPU shaders for advanced filters, Skia color matrices for color adjustments, and FFmpeg for final video export.

Live previews and exported videos use different rendering engines. A shared parameter system ensures GPU previews, Skia color filters, and FFmpeg exports produce a visually consistent result across all stages.

GPU shaders process visual effects directly on the graphics hardware, enabling real-time adjustments like brightness, contrast, hue, blur, and color grading while maintaining smooth 30–60 FPS playback.

A unified parameter map drives every renderer, translating the same filter settings into GPU shader configurations, Skia color matrices, and FFmpeg filter graphs to maintain visual consistency between preview and export.

A layered architecture with a shared filter parameter model, intelligent renderer selection, and FFmpeg-based export provides scalable, high-performance video editing while ensuring consistent filter behavior across devices and platforms.

Comments (0)

Share your thoughts and join the conversation

Leave a Comment

Your email will not be published

No comments yet

Be the first to share your thoughts!