miniav_recorder 0.1.2
miniav_recorder: ^0.1.2 copied to clipboard
High-level multi-source A/V recorder built on miniav + miniav_tools. Sync screen/camera/mic/loopback into MP4/MKV files or chunked streams.
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).
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 GpuDownscaler 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)
→ SharedOutputTexture.copyFromBuffer
SharedOutputTexture (RGBA, dstW×dstH)
→ 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 downscaling. All effects run as WGSL compute shaders on the GPU — no CPU copies, no PCIe round-trips.
Effects are passed to addScreen() as a List<ScreenEffect>:
builder.addScreen(
displayId: display.deviceId,
scale: ScreenScalePolicy.h264Friendly,
effects: [
ScreenEffect.vignette(strength: 0.5),
],
);
Built-in effects #
| Factory | Description |
|---|---|
ScreenEffect.vignette({strength}) |
Radial vignette + warm colour grade. strength 0–1 (default 0.4). |
Custom WGSL effects #
Provide your own compute shader source via ScreenEffect.wgsl():
ScreenEffect.wgsl(
'''
struct Params { width: u32, height: u32, gamma: 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 fields 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 against out-of-bounds: if (gid.x >= params.width || gid.y >= params.height) { return; }.
Effects + scale together #
builder.addScreen(
displayId: display.deviceId,
scale: ScreenScalePolicy.h264Friendly, // 1. downscale on GPU
effects: [
ScreenEffect.vignette(strength: 0.4), // 2. vignette on smaller buffer
],
);
Downscaling runs first (on the full-resolution buffer), then effects run on the already-smaller destination buffer — maximising efficiency.
Requirements #
- Zero-copy GPU path must be active (
preferZeroCopy = true, Windows D3D12). - If the GPU context is unavailable at runtime, a warning is printed and effects are skipped (encoding continues normally without them).
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. |
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).
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 GpuDownscaler 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)
→ SharedOutputTexture.copyFromBuffer
SharedOutputTexture (RGBA, dstW×dstH)
→ 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(
displayId: display.deviceId,
scale: ScreenScalePolicy.scaleFactor(0.5),
);
// Fixed 1920×1080
builder.addScreen(
displayId: display.deviceId,
scale: ScreenScalePolicy.fixedSize(1920, 1080),
);
GPU effects pipeline #
Screen sources support an ordered chain of GPU post-processing effects applied after downscaling. All effects run as WGSL compute shaders on the GPU — no CPU copies, no PCIe round-trips.
Effects are passed to addScreen() as a List<ScreenEffect>:
builder.addScreen(
displayId: display.deviceId,
scale: ScreenScalePolicy.h264Friendly,
effects: [
ScreenEffect.vignette(strength: 0.5),
],
);
Built-in effects #
| Factory | Description |
|---|---|
ScreenEffect.vignette({strength}) |
Radial vignette + warm colour grade. strength 0–1 (default 0.4). |
Custom WGSL effects #
Provide your own compute shader source via ScreenEffect.wgsl():
ScreenEffect.wgsl(
'''
struct Params { width: u32, height: u32, gamma: 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 fields 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 against out-of-bounds: if (gid.x >= params.width || gid.y >= params.height) { return; }.
Effects + scale together #
builder.addScreen(
displayId: display.deviceId,
scale: ScreenScalePolicy.h264Friendly, // 1. downscale on GPU
effects: [
ScreenEffect.vignette(strength: 0.4), // 2. vignette on smaller buffer
],
);
Downscaling runs first (on the full-resolution buffer), then effects run on the already-smaller destination buffer — maximising efficiency.
Requirements #
- Zero-copy GPU path must be active (
preferZeroCopy = true, Windows D3D12). - If the GPU context is unavailable at runtime, a warning is printed and effects are skipped (encoding continues normally without them).
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. |