Studio

Pulse Effects — architecture & native spec

One manifest format, one render graph, three runtimes (web Studio, iOS, Android). No DeepAR, Banuba, Snap Camera Kit or TikTok SDK anywhere in the stack.

Honest status matrix

Working in this project (real GPU code)

  • · EffectManifest v1 schema + zod validation + validator UI
  • · Effect catalogue, categories, search, favourites, recents
  • · Composable effect stack (layers: background → behind-person → person → face → foreground)
  • · WebGL2 grade: exposure, brightness, contrast, saturation, warmth, tint, lift/gain, vignette, grain, glow, sharpen
  • · Beauty: edge-preserving skin smoothing, blemish softening, face brightness, eye enhancement
  • · Chroma key: key colour, tolerance, edge softness, spill suppression
  • · Background compositing: blur, gradient, solid colour, edge feather/contract
  • · Particle engine: 13 sprite types, background/foreground layering, blend modes
  • · Face-anchored sprite placement (anchor, scale, offset, rotation, animation)
  • · Adaptive quality tiers with GPU cost budget + auto-downgrade on sustained low FPS

Simulated in the web preview only

  • · Person segmentation matte — an analytic ellipse, not an ML mask
  • · Face landmarks — anchors derived from that ellipse, not from a tracker
  • · Sprite artwork — emoji stand-ins instead of production PNG/sequence assets
  • · Background image/video plates — gradients stand in for the final plates
  • · Remote catalogue — served from a local module rather than an effects API

Requires native Flutter / iOS / Android work (not built here)

  • · MediaPipe Face Landmarker + Image Segmenter on-device inference
  • · Camera capture → GPU texture with zero-copy pixel buffers
  • · Metal (iOS) / OpenGL ES + Vulkan (Android) render passes
  • · Injecting processed frames into the existing WebRTC video track
  • · Thermal/battery-aware quality scheduling on real hardware
  • · Effect pack download, checksum validation, install/update/delete cache

Render graph

frame pipeline

CAMERA (native capture, NV12/BGRA)
  ↓ zero-copy → GPU texture (CVPixelBuffer / HardwareBuffer)
TRACKING TAP (downscaled 256px copy, async, own thread)
  ├── MediaPipe Face Landmarker → landmarks, head pose, blendshapes
  └── MediaPipe Image Segmenter → person matte (R8 texture)
  ↓ (results smoothed with one-euro filter, never blocks render)
EFFECT STACK (single GPU command buffer, layer ordered)
  1 background      : blur / image / video / gradient / colour / animated
  2 background_fx   : particles behind subject (matte-masked)
  3 person          : chroma key, beauty, colour grade / LUT
  4 face            : landmark-anchored meshes + sprites
  5 foreground      : particles, flares, frames, overlays
  ↓
PROCESSED TEXTURE
  ├── local preview (Flutter Texture widget)
  ├── recorder (stories, posts, profile video)
  └── WebRTC external video source → existing live track → viewers

Effects never own the pipeline: each manifest only contributes uniforms, assets and anchors to the shared graph, so any number can run simultaneously.

Dart API

package:pulse_effects/pulse_effects.dart

final engine = await PulseEffects.instance.initialize(
  const EngineConfig(
    quality: QualityTier.auto,      // low | medium | high | ultra | auto
    targetFps: 30,
    trackFaces: 2,
    segmentation: SegmentationMode.auto,
  ),
);

// Catalogue + install (remote pack hosted by us)
final catalogue = await engine.catalogue.fetch(page: 0);
final effect    = await engine.catalogue.install('cyber_mask_01');

// Composable stack — nothing is exclusive except slots that must be
final stack = engine.stack;
await stack.add('bg_nightclub');
await stack.add('bty_studio');
await stack.add('msk_sunglasses');
await stack.add('fx_hearts');
await stack.add('flt_cinematic');
stack.setParam('bty_studio', 'skinSmoothing', 0.45);

// Output taps
engine.previewTextureId;                 // Flutter Texture widget
final track = await engine.videoTrack;   // MediaStreamTrack for flutter_webrtc
await engine.startRecording(path);

engine.stats.listen((s) => debugPrint('${s.fps}fps ${s.gpuMs}ms ${s.thermal}'));

Flutter platform channels

channel contract

MethodChannel  'pulse/effects'          — control plane
  initialize(Map config) -> {textureId:int, caps:Map}
  installEffect(String manifestJson, String bundlePath) -> bool
  addEffect(String id) / removeEffect(String id) / clearStack()
  setParam(String id, String key, double value)
  setQuality(String tier)
  setMirror(bool)
  startRecording(String path) / stopRecording() -> String
  attachToWebRtc(String trackId) -> bool
  dispose()

EventChannel   'pulse/effects/stats'    — fps, gpuMs, droppedFrames, thermalState
EventChannel   'pulse/effects/tracking' — faceCount, landmarks (opt-in, throttled)

All heavy payloads (assets) move over the filesystem, never the channel.

iOS interface (Swift / Metal)

ios/Classes/PulseEffectsEngine.swift

protocol PulseEffectPass {
  func encode(_ enc: MTLComputeCommandEncoder,
              input: MTLTexture, output: MTLTexture,
              frame: PulseFrameContext)
}

final class PulseEffectsEngine: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
  private let device = MTLCreateSystemDefaultDevice()!
  private lazy var queue = device.makeCommandQueue()!
  private var textureCache: CVMetalTextureCache!
  private var passes: [PulseEffectPass] = []          // built from manifests
  private let tracker = FaceLandmarkerRunner()        // MediaPipe Tasks, .liveStream
  private let segmenter = ImageSegmenterRunner()

  func captureOutput(_ o: AVCaptureOutput, didOutput sb: CMSampleBuffer, from c: AVCaptureConnection) {
    guard let pb = CMSampleBufferGetImageBuffer(sb) else { return }
    tracker.detectAsync(pb, timestamp: ts)            // non-blocking
    segmenter.segmentAsync(pb, timestamp: ts)

    let input = makeTexture(from: pb)                 // zero-copy via CVMetalTextureCache
    let cb = queue.makeCommandBuffer()!
    var src = input
    for pass in passes { pass.encode(enc, input: src, output: pong, frame: ctx); swap(&src, &pong) }
    cb.addCompletedHandler { _ in
      self.flutterTexture.publish(src)                // FlutterTexture
      self.webrtcSource.push(pixelBuffer: self.outputPB, timestamp: ts)  // RTCVideoSource
    }
    cb.commit()
  }
}

Frame format: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange in, kCVPixelFormatType_32BGRA out (IOSurface-backed, shared with WebRTC).

Android interface (Kotlin / OpenGL ES)

android/src/main/kotlin/PulseEffectsEngine.kt

class PulseEffectsEngine(context: Context, private val textureRegistry: TextureRegistry) {
  private val glThread = HandlerThread("pulse-gl").apply { start() }
  private val eglCore = EglCore()                       // shared context with WebRTC
  private val passes = mutableListOf<EffectPass>()      // built from manifests
  private val faceLandmarker = FaceLandmarker.createFromOptions(context, opts)
  private val segmenter = ImageSegmenter.createFromOptions(context, segOpts)

  fun onFrame(image: ImageProxy) {                      // CameraX, OUTPUT_IMAGE_FORMAT_YUV_420_888
    val oesTex = surfaceTexture.attach()                // camera → OES texture, no CPU copy
    faceLandmarker.detectAsync(mpImage, ts)
    segmenter.segmentAsync(mpImage, ts)

    var src = oesTex
    passes.forEach { src = it.render(src, ctx) }        // FBO ping-pong

    flutterSurface.publish(src)
    videoSource.capturerObserver.onFrameCaptured(       // org.webrtc.VideoFrame
      VideoFrame(TextureBufferImpl(w, h, RGB, src, matrix, glHandler, yuvConverter, null), 0, tsNs)
    )
  }
}

Threading: capture thread → GL thread (single EGL context shared with WebRTC's EglBase) → tracking runs on its own inference thread; results are consumed by the newest frame and smoothed, so tracking latency never stalls rendering.

WebRTC integration points

host-side wiring

iOS:      RTCVideoSource + RTCVideoCapturer → capturer(_:didCapture: RTCVideoFrame(buffer:
          RTCCVPixelBuffer(pixelBuffer: processedPB)))
Android:  PeerConnectionFactory.createVideoSource(false) → capturerObserver.onFrameCaptured(
          VideoFrame(TextureBufferImpl(processedTexId, ...)))
Flutter:  final track = await PulseEffects.instance.videoTrack;
          await peerConnection.addTrack(track, localStream);

Because the processed texture IS the track source, viewers see the effects.
Nothing here replaces the existing signalling, SFU or room logic — the only
change on the streaming side is swapping the camera source for the engine source.

Performance & adaptive quality

  • One command buffer per frame; ping-pong FBOs; no CPU readback in the hot path.
  • Tracking runs at 15–20 Hz on a 256px copy and is interpolated to render rate.
  • Each manifest carries a GPU cost; the scheduler drops the most expensive effects when the tier budget is exceeded (visible live in the Studio stack panel).
  • Thermal state (iOS ProcessInfo.thermalState, Android PowerManager.getThermalHeadroom) steps the tier down before frames drop.
  • Segmentation runs at half resolution below the High tier; particles are capped per tier.

Dependency review (no paid AR SDK)

ProjectPurposeLicenseCommercial useMobileMaintenance
MediaPipe Tasks VisionFace landmarks (478 pts), blendshapes, head pose, selfie segmentationApache-2.0YesiOS + Android + WebActively maintained by Google
TensorFlow Lite / LiteRTRuntime for the MediaPipe models, GPU delegateApache-2.0YesiOS + AndroidActive
GPUImage3 (optional, iOS)Metal filter chain helpersBSD-3-ClauseYesiOSLow activity — reference only
libyuvFast pixel-format conversion (NV12 ⇄ RGBA ⇄ I420)BSD-3-ClauseYesiOS + AndroidActive (Chromium)
flutter_webrtc / libwebrtcExisting streaming pipeline, external video source injectionMIT / BSD-3-ClauseYesiOS + AndroidActive
zodManifest validation in Studio + servicesMITYesn/a (tooling)Active

No GPL/AGPL dependencies. All permissive (Apache-2.0, BSD-3-Clause, MIT). Models ship with the app and run on-device — raw camera frames never leave the handset.

Remote effect library (API shape)

effects API

GET  /api/effects?cursor=&category=&q=      → { items: EffectManifest[], cursor }
GET  /api/effects/:id                       → EffectManifest
GET  /api/effects/:id/bundle                → .pfx zip (assets + manifest.json + sha256)
POST /api/effects            (admin)        → create draft
PUT  /api/effects/:id        (admin)        → new version
POST /api/effects/:id/publish|disable
DELETE /api/effects/:id

Device cache: install → verify sha256 → unzip to /effects/<id>/<version>/
             → validate manifest against schema v1 → register
             update = download new version, atomic swap, delete old
Creator effects: assets + template-constrained manifests only. No scripts,
no shaders from users — every render path is one of our vetted passes.

Phase status

Phase 1
Architecture, manifest format, catalogue, Studio UI, preview frameworkDone in this project
Phase 2
Filters, LUTs, colour grade, overlays, background modesDone (shader-side); LUT .cube loader pending
Phase 3
Face-landmark anchored masksManifest + anchoring done; real tracker is native
Phase 4
Person segmentation + virtual backgroundsCompositing done; ML matte is native
Phase 5
Particles, animation, combined stacksDone
Phase 6
Native Flutter/iOS/Android runtimeSpecified below — not implemented
Phase 7
WebRTC track integrationSpecified below — not implemented

Designed-for-later capabilities

The manifest carries an experimental bag and the tracking block already enumerates hand and body modes, so AI background generation, avatars, face transformation, gesture/voice triggers, virtual clothing, hair colour, makeup and tattoos can be added as new render passes and manifest blocks without a format break. Unknown keys are ignored by older runtimes.

Schema versioning: bump schemaVersion only for breaking changes; runtimes refuse manifests above the version they support.