miniav_recorder
High-level multi-source A/V recorder for Dart. Combines screen, camera, microphone and loopback sources into one or more MP4/MKV/M4A/MP3 outputs (or live chunked streams) with a shared master clock.
Built on miniav + miniav_tools + miniav_tools_ffmpeg.
Quick start
import 'package:miniav_recorder/miniav_recorder.dart';
final rec = (RecorderBuilder()
..addScreen(codec: VideoCodec.h264, scale: ScreenScalePolicy.h264Friendly)
..addMic(deviceId: mic.deviceId)
..addFileOutput('output.mkv'))
.build();
await rec.start();
await Future.delayed(const Duration(seconds: 10));
await rec.stop();
Enumerate devices
Use RecorderDevices to list available capture devices before building a recorder. Every deviceId returned can be passed directly to the matching builder method.
final displays = await RecorderDevices.displays(); // for addScreen(displayId:)
final windows = await RecorderDevices.windows(); // for addScreen(windowId:)
final cameras = await RecorderDevices.cameras(); // for addCamera(deviceId:)
final mics = await RecorderDevices.microphones(); // for addMic(deviceId:)
final loops = await RecorderDevices.loopbacks(); // for addLoopback(deviceId:)
print(displays.map((d) => '${d.name}: ${d.deviceId}').join('\n'));
All lists carry a MiniAVDeviceInfo with deviceId, name, and isDefault.
Sources
Screen / display
addScreen accepts the display ID string returned by RecorderDevices.displays() (or MiniScreen.enumerateDisplays()) directly, or null to capture the platform's default display.
// Use a specific display by ID
builder.addScreen(
displayId: display.deviceId, // from RecorderDevices.displays()
codec: VideoCodec.h264,
hwAccel: HwAccelPreference.preferred, // default
scale: ScreenScalePolicy.h264Friendly, // optional downscale
);
// Platform default display
builder.addScreen();
// Specific application window
builder.addScreen(windowId: win.deviceId);
Camera
builder.addCamera(
deviceId: camera.deviceId, // from RecorderDevices.cameras()
codec: VideoCodec.h264,
);
Microphone / Loopback
builder.addMic(deviceId: mic.deviceId, codec: AudioCodec.aac);
builder.addLoopback(deviceId: loopback.deviceId, codec: AudioCodec.aac);
Sinks
// Write to a container file — container is auto-selected (see below).
builder.addFileOutput('rec.mkv');
// Override the container explicitly.
builder.addFileOutput('audio.m4a', container: Container.m4a);
// Receive raw encoded packets for live streaming / network forwarding.
builder.addStreamOutput((Object chunk) {
if (chunk is TrackChunk) {
// chunk.bytes, chunk.kind, chunk.ptsUs, chunk.isKeyframe, …
}
});
Auto-container selection
When no container: override is supplied, the recorder infers the most natural container:
| Track mix | Auto-selected container |
|---|---|
| Video + audio | .mkv |
| Video only | .mp4 |
| Audio-only (AAC) | .m4a |
| Audio-only (MP3) | .mp3 |
| Audio-only (Opus) | .ogg |
| Audio-only (mixed/other) | .mkv |
Audio-only recorders
A recorder with no video sources is fully supported. Useful for recording mic or loopback audio to a standalone audio file:
final micRec = (RecorderBuilder()
..addMic(deviceId: mic.deviceId, codec: AudioCodec.aac)
..addFileOutput('mic.m4a')) // auto-picks Container.m4a
.build();
await micRec.start();
await Future.delayed(const Duration(seconds: 30));
await micRec.stop();
Synchronised multi-recorder (RecorderGroup)
Record to multiple outputs simultaneously with clock-synchronised start. The prepare phase (encoder/muxer init, GPU bringup) runs concurrently on all recorders, then all master clocks are started in a tight sequential loop to minimise clock skew.
final avRec = (RecorderBuilder()
..addScreen() // null = platform default display
..addLoopback(deviceId: loop.deviceId)
..addFileOutput('av.mp4'))
.build();
final micRec = (RecorderBuilder()
..addMic(deviceId: mic.deviceId, codec: AudioCodec.aac)
..addFileOutput('mic.m4a')) // auto-picks Container.m4a
.build();
final group = RecorderGroup([avRec, micRec]);
await group.start();
await Future.delayed(const Duration(seconds: 10));
await group.stop();
RecorderGroup.recorders exposes each individual Recorder so you can query state or add per-recorder error handling.
Zero-copy GPU path (Windows)
RecorderBuilder.preferZeroCopy defaults to true.
When recording a screen source on Windows with a compatible hardware encoder (NVENC, AMD VCE, Intel QSV, Media Foundation), the recorder:
- Initialises a shared Dawn D3D12 context via minigpu.
- Creates a matching
ID3D11Deviceon the same GPU adapter. - Requests GPU output (
MiniAVOutputPreference.gpu) from the DXGI capture so each frame arrives as a D3D11 NT shared handle — no pixel data crosses PCIe. - Passes the shared device handle to the FFmpeg backend, which opens
FfmpegD3d11HwEncoderwithexistingD3d11Device. The encoder reads the texture directly without any CPU copy.
Falls back silently to the CPU-upload path on any failure (unsupported GPU, non-Windows, no HW encoder).
Process-global GPU singleton & hot restart
The shared Minigpu + Dawn-allocated ID3D11Device are lifetime-of-the-isolate singletons. Recorder.start() / stop() never tear them down — re-initialising Dawn within a single process can pick a different backend on the second run (D3D12 vs D3D11) and break the cross-API shared-texture path with errors like “The D3D11 device of the texture and the D3D11 device of Device "MGPU.MainDevice" must be same”.
For Flutter hot restart, the MiniAV C library's MiniAV_Dispose() is called automatically via MiniAV.dispose(). It acquires an exclusive write lock, waits for every in-flight native callback to finish, then disables further dispatch — so native worker threads can never invoke a closed NativeCallable. The next Recorder.start() re-enables callbacks automatically.
Wire both teardowns into your app's hot-restart hook so the Dawn/D3D11 device is also released cleanly:
import 'package:flutter/widgets.dart';
import 'package:miniav/miniav.dart';
import 'package:miniav_recorder/miniav_recorder.dart';
class _MyAppState extends State<MyApp> {
@override
void reassemble() {
super.reassemble();
// Quiesce all native callbacks, then release the shared GPU.
MiniAV.dispose(); // disables C-level callbacks atomically
Recorder.disposeSharedGpu(); // tears down Dawn / D3D11 device
}
}
Static helpers:
| API | Purpose |
|---|---|
Recorder.ensureSharedGpu() |
Idempotently bring up the shared Minigpu + D3D11 device. Called automatically by start() when needed. |
Recorder.sharedGpu |
The live Minigpu instance after ensureSharedGpu(), or null when GPU is unsupported. Use to run custom compute passes (e.g. gpu.copyBuffer, a custom WGSL effect, or a GpuScreenProcessor for live preview) on the same Dawn device without a second gpu.init(). |
Recorder.disposeSharedGpu() |
Explicitly destroy the shared GPU singleton. Safe to call multiple times. After this, the next start() re-initialises. |
Logging
miniav_recorder exposes a unified log API that routes messages from the Dart recorder runtime, the MiniAV C library, and FFmpeg through a single callback — all accessible from a single import 'package:miniav_recorder/miniav_recorder.dart'.
Log level
// Only emit warnings and errors (default: RecorderLogLevel.info).
Recorder.setLogLevel(RecorderLogLevel.warning);
| Level | What is shown |
|---|---|
verbose |
All debug output from recorder, MiniAV, and FFmpeg |
info |
Informational messages + warnings + errors (default) |
warning |
Warnings and errors only |
error |
Errors only |
quiet |
Silence all logs |
Custom callback
Recorder.setLogCallback((source, level, message) {
print('[${source.name}] ${level.name}: $message');
});
The callback receives:
| Parameter | Type | Values |
|---|---|---|
source |
RecorderLogSource |
recorder · miniav · ffmpeg |
level |
RecorderLogLevel |
verbose · info · warning · error |
message |
String |
The log line (no trailing newline) |
source tells you which subsystem emitted the message so you can filter or route them independently.
To remove the callback and revert to the default stderr output:
Recorder.setLogCallback(null);
Combined example
// Verbose FFmpeg output only, everything else at warning+.
Recorder.setLogLevel(RecorderLogLevel.verbose);
Recorder.setLogCallback((source, level, message) {
if (source == RecorderLogSource.ffmpeg || level.index >= RecorderLogLevel.warning.index) {
myLogger.log('[${source.name}] $message');
}
});
Note: Dawn / minigpu logs are written directly to native
stderrby the Dawn library and cannot be intercepted by this callback.
GPU downscaling
Screen sources can be downscaled on the GPU before encoding using ScreenScalePolicy.
| Policy | Behaviour |
|---|---|
ScreenScalePolicy.none |
No scaling (default). |
ScreenScalePolicy.h264Friendly |
Auto-downscale so max(width, height) ≤ 4096, preserving aspect ratio. Avoids the automatic H.264 → HEVC codec promotion on ultrawide / 4K+ displays. |
ScreenScalePolicy.fixedSize(w, h) |
Encode at an explicit pixel size (rounded to even). |
ScreenScalePolicy.scaleFactor(f) |
Uniform fractional scale, e.g. 0.5 = half each axis (rounded to even). |
How it works
When zero-copy is active and a non-none policy is set, a GpuScreenProcessor is created for the track. Per frame:
MiniAVBuffer (D3D11 NT handle)
→ gpu.importVideoFrame() VideoTexture (BGRA, srcW×srcH)
→ VideoTexture.toRGBA() Buffer (RGBA u32[], srcW×srcH)
→ WGSL bilinear dispatch Buffer (RGBA u32[], dstW×dstH)
→ effects chain Buffer (RGBA u32[], outW×outH)
→ SharedOutputTexture.copyFromBuffer
SharedOutputTexture (RGBA, outW×outH)
→ D3D11TextureFrameSource → FfmpegD3d11HwEncoder
All GPU memory — no pixel data reaches the CPU.
Example: ultrawide display → H.264
builder.addScreen(
displayId: display.deviceId,
codec: VideoCodec.h264, // stays h264 after downscale
scale: ScreenScalePolicy.h264Friendly, // 5120×1440 → 4096×1152
);
Example: custom scale
// Half resolution
builder.addScreen(scale: ScreenScalePolicy.scaleFactor(0.5));
// Fixed 1920×1080
builder.addScreen(scale: ScreenScalePolicy.fixedSize(1920, 1080));
GPU effects pipeline
Screen sources support an ordered chain of GPU post-processing effects applied after any ScreenScalePolicy downscaling. All effects run as WGSL compute shaders — no pixel data reaches the CPU.
Effects are passed to addScreen() as a List<ScreenEffect>. They are applied left-to-right; each receives the output dimensions of the preceding step.
builder.addScreen(
displayId: display.deviceId,
scale: ScreenScalePolicy.h264Friendly,
effects: [
ScreenEffect.crop(0, 0, 1920, 1040), // 1. strip taskbar
ScreenEffect.vignette(strength: 0.4), // 2. colour grade
],
);
Built-in effects
| Factory | Changes dimensions? | Description |
|---|---|---|
ScreenEffect.vignette({strength}) |
No | Radial vignette + warm colour grade. strength 0–1 (default 1.0). |
ScreenEffect.crop(x, y, width, height) |
Yes | Crop to a sub-rectangle. Encoder is opened at width×height. |
ScreenEffect.flip({horizontal, vertical}) |
No | Mirror horizontally and/or vertically. Uses a separate GPU buffer (avoids in-place race conditions). |
ScreenEffect.rotate(ScreenRotation) |
Yes for 90°/270° | Clockwise rotation. 90°/270° swap width ↔ height. |
ScreenEffect.scale(width, height) |
Yes | Bilinear resize to an arbitrary target size. Useful after a crop to upscale a region back to a standard resolution. |
ScreenEffect.wgsl(source, {extraParams}) |
No | Custom in-place WGSL compute shader. |
Rotation values
ScreenEffect.rotate(ScreenRotation.r90) // 90° clockwise — output: H×W
ScreenEffect.rotate(ScreenRotation.r180) // 180° — output: W×H (unchanged)
ScreenEffect.rotate(ScreenRotation.r270) // 270° clockwise — output: H×W
Dimension-changing effects
When an effect changes the frame dimensions (crop, rotate 90°/270°, scale), the encoder and shared output texture are automatically opened at the correct final size — no configuration needed beyond listing the effects.
// 2560×1440 display → crop taskbar → portrait rotate → upscale
builder.addScreen(
effects: [
ScreenEffect.crop(0, 0, 2560, 1400), // → 2560×1400
ScreenEffect.rotate(ScreenRotation.r90), // → 1400×2560
ScreenEffect.scale(700, 1280), // → 700×1280
],
);
Composing effects
Some useful patterns:
// Correct a front-facing webcam (mirrored by default):
effects: [ScreenEffect.flip(horizontal: true)]
// Record a portion of the screen at full HD:
effects: [
ScreenEffect.crop(640, 360, 640, 360), // crop a 640×360 region from the centre
ScreenEffect.scale(1920, 1080), // upscale to 1080p for the encoder
]
// Fix a sideways phone screen share:
effects: [ScreenEffect.rotate(ScreenRotation.r270)]
Custom WGSL effects
Provide your own compute shader source via ScreenEffect.wgsl(). The shader runs in-place on the current buffer (same dimensions in and out):
ScreenEffect.wgsl(
'''
struct Params { width: u32, height: u32, gamma: f32, _pad: f32 };
@group(0) @binding(0) var<storage, read_write> pixels : array<u32>;
@group(0) @binding(1) var<storage, read_write> params : Params;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid : vec3<u32>) {
if (gid.x >= params.width || gid.y >= params.height) { return; }
let idx = gid.y * params.width + gid.x;
// … transform pixels[idx] in place …
}
''',
extraParams: [2.2], // passed as f32 from byte 8 in the Params struct
)
WGSL shader contract
| Binding | Type | Meaning |
|---|---|---|
@binding(0) |
array<u32> (read_write) |
Packed RGBA8 pixels, row-major. Modify in-place. |
@binding(1) |
user-defined struct (read_write) | width: u32 at offset 0, height: u32 at offset 4, then the extraParams values as consecutive f32 fields from byte 8. |
Workgroups are dispatched at 8×8 threads. Always guard: if (gid.x >= params.width || gid.y >= params.height) { return; }.
Effects + scale policy together
Downscaling from ScreenScalePolicy runs first (still on the full-resolution D3D11 texture), then the effects chain runs on the smaller buffer — maximising efficiency.
builder.addScreen(
scale: ScreenScalePolicy.h264Friendly, // 1. GPU bilinear downscale
effects: [
ScreenEffect.crop(0, 0, 3840, 2000), // 2. strip taskbar
ScreenEffect.vignette(strength: 0.4), // 3. colour grade
],
);
Requirements
- Zero-copy GPU path must be active (
preferZeroCopy = true, Windows D3D12). - If the GPU context is unavailable the effects chain is skipped and a warning is printed; recording continues normally at the downscaled (or original) size.
DVR clip buffer
A ClipBuffer keeps a rolling in-memory ring of encoded packets covering the last maxWindow of recording. Call saveClip at any point to materialise any sub-window to a file without pausing or splitting the active recording.
// Keep up to 3 minutes in RAM.
final clip = builder.addClipBuffer(maxWindow: Duration(minutes: 3));
final rec = builder.build();
await rec.start();
// … record continuously …
// Save clips of different lengths from the same buffer — non-destructive.
await clip.saveClip('moment_5s.mp4', duration: Duration(seconds: 5));
await clip.saveClip('moment_30s.mp4', duration: Duration(seconds: 30));
await clip.saveClip('replay_3min.mp4'); // omit duration → full maxWindow
API
| Member | Description |
|---|---|
addClipBuffer({required maxWindow, maxPackets}) |
Register a ClipBuffer on the builder. Returns the buffer. |
ClipBuffer.maxWindow |
Buffered duration (longest possible clip). |
ClipBuffer.maxPackets |
Optional hard cap on ring size (bounds memory). |
ClipBuffer.length |
Number of packets currently buffered. |
ClipBuffer.oldestPtsUs / newestPtsUs |
Timestamp span of buffered data. |
ClipBuffer.saveClip(path, {duration, container}) |
Write the last duration (default: maxWindow) to path. |
ClipBuffer.clear() |
Discard all buffered packets. |
Notes
- The clip always starts on a video keyframe to ensure the file is playable.
- Timestamps are remapped to start from 0 in the output file.
saveClipcan be called multiple times concurrently with different durations from the sameClipBuffer.- Memory scales with
maxWindow × average_bitrate. At 8 Mbps video + 128 Kbps audio, 3 minutes ≈ ~180 MB. - Add a
maxPacketscap if memory is a concern:
// Cap at ~10 000 packets regardless of duration.
builder.addClipBuffer(
maxWindow: Duration(minutes: 3),
maxPackets: 10000,
);
Warmup
On first run, the recorder may need to download FFmpeg shared libraries before encoding can start. Call MiniAVTools.warmup() early in your app — in main() or initState — to trigger heavy initialisation up front and get byte-level download progress.
import 'package:miniav_recorder/miniav_recorder.dart';
// Flutter — show a progress bar while FFmpeg downloads:
@override
void initState() {
super.initState();
MiniAVTools.warmup().listen(
(p) {
if (p.fraction != null) {
setState(() => _downloadProgress = p.fraction!);
}
if (p.isDone && p.error != null) {
debugPrint('Warmup error [${p.backendName}]: ${p.error}');
}
},
onDone: () => setState(() => _ready = true),
);
}
// Command-line / background — block until all backends are ready:
await MiniAVTools.warmup().drain<void>();
If the libraries are already cached on disk, warmup() completes immediately with no events. It is safe to call multiple times.
Warmup is optional: createEncoder, createMuxer, etc. trigger auto-download on demand. The benefit of calling warmup() early is that you control when the download happens and can give the user feedback instead of silently stalling.
WarmupProgress fields
| Field | Type | Meaning |
|---|---|---|
backendName |
String |
Backend that emitted the event ("ffmpeg", etc.). |
task |
String |
Human-readable task description (e.g. "Downloading FFmpeg"). |
isDone |
bool |
true on the final event for this task — success or failure. |
bytesReceived |
int? |
Bytes received so far; null for non-download events. |
totalBytes |
int? |
Total expected bytes; null when server omits Content-Length. |
fraction |
double? |
Computed bytesReceived / totalBytes in [0.0, 1.0], or null. |
error |
Object? |
Non-null when the task failed. The stream never errors itself. |
Builder options
final builder = RecorderBuilder()
..defaultVideoBitrate = 6_000_000 // bits/s
..defaultAudioBitrate = 128_000 // bits/s
..defaultFrameRate = 30
..backendPreference = BackendPreference.auto
..preferZeroCopy = true; // default — safe to leave on
Codec selection
The recorder calls FfmpegBackend.bestCodecForResolution automatically. When hardware encoding is preferred and the source (or downscaled) dimensions exceed 4096 px in any dimension, it promotes h264 → hevc to stay within hardware encoder limits. Using ScreenScalePolicy.h264Friendly avoids this promotion.
Examples
See examples/recorder/:
| Script | Description |
|---|---|
screen_mic.dart |
Screen + microphone → MKV (zero-copy + h264Friendly by default). |
camera_mic.dart |
Webcam + microphone → MP4. |
screen_mic_loopback_chunked.dart |
Screen + loopback → chunked stream packets. |
Libraries
- miniav_recorder
- miniav_recorder — high-level synchronized A/V recorder.