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.
Working in this project (real GPU code)
Simulated in the web preview only
Requires native Flutter / iOS / Android work (not built here)
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.
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}'));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/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/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.
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.ProcessInfo.thermalState, Android PowerManager.getThermalHeadroom) steps the tier down before frames drop.| Project | Purpose | License | Commercial use | Mobile | Maintenance |
|---|---|---|---|---|---|
| MediaPipe Tasks Vision | Face landmarks (478 pts), blendshapes, head pose, selfie segmentation | Apache-2.0 | Yes | iOS + Android + Web | Actively maintained by Google |
| TensorFlow Lite / LiteRT | Runtime for the MediaPipe models, GPU delegate | Apache-2.0 | Yes | iOS + Android | Active |
| GPUImage3 (optional, iOS) | Metal filter chain helpers | BSD-3-Clause | Yes | iOS | Low activity — reference only |
| libyuv | Fast pixel-format conversion (NV12 ⇄ RGBA ⇄ I420) | BSD-3-Clause | Yes | iOS + Android | Active (Chromium) |
| flutter_webrtc / libwebrtc | Existing streaming pipeline, external video source injection | MIT / BSD-3-Clause | Yes | iOS + Android | Active |
| zod | Manifest validation in Studio + services | MIT | Yes | n/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.
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.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.