/*
* VideoKit
* Copyright © 2026 Yusuf Olokoba. All Rights Reserved.
*/
#nullable enable
namespace VideoKit {
using AOT;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using UnityEngine;
using Internal;
using Status = Internal.VideoKit.Status;
///
/// Media recorder capable of recording video and/or audio frames to a media file.
/// All recorder methods are thread safe, and as such can be called from any thread.
///
public class MediaRecorder {
#region --Enumerations--
///
/// Recording format.
///
public enum Format : int {
///
/// MP4 video with H.264 AVC video codec and AAC audio codec.
/// This format supports recording both video and audio frames.
/// This format is not supported on WebGL.
///
MP4 = 0,
///
/// MP4 video with H.265 HEVC video codec and AAC audio codec.
/// This format has better compression than `MP4`.
/// This format supports recording both video and audio frames.
/// This format is not supported on WebGL.
///
HEVC = 1,
///
/// WEBM video with VP8 or VP9 video codec.
/// This format support recording both video and audio frames.
/// This is only supported on Android and WebGL.
///
WEBM = 2,
///
/// Animated GIF image.
/// This format only supports recording video frames.
///
GIF = 3,
///
/// JPEG image sequence.
/// This format only supports recording video frames.
/// This format is not supported on WebGL.
///
JPEG = 4,
///
/// Waveform audio.
/// This format only supports recording audio frames.
///
WAV = 5,
///
/// EXPERIMENTAL.
/// MP4 video with AV1 video codec and AAC audio codec.
/// This format supports recording both video and audio frames.
/// This is currently supported on Android 14+.
///
AV1 = 6,
///
/// EXPERIMENTAL.
/// Apple ProRes video.
/// This format supports recording both video and audio frames.
/// This is currently supported on iOS and macOS.
///
ProRes4444 = 7,
}
#endregion
#region --Client API--
///
/// Recorder format.
///
public virtual Format format => handle.GetMediaRecorderFormat(out var format).Throw() == Status.Ok ? format : default;
///
/// Recorder video width.
///
public virtual int width => handle.GetMediaRecorderWidth(out var width).Throw() == Status.Ok ? width : default;
///
/// Recorder video height.
///
public virtual int height => handle.GetMediaRecorderHeight(out var height).Throw() == Status.Ok ? height : default;
///
/// Recorder audio sample rate.
///
public virtual int sampleRate => handle.GetMediaRecorderSampleRate(out var sampleRate).Throw() == Status.Ok ? sampleRate : default;
///
/// Recorder audio channel count.
///
public virtual int channelCount => handle.GetMediaRecorderChannelCount(out var channelCount).Throw() == Status.Ok ? channelCount : default;
///
/// Whether the recorder supports appending pixel buffers.
///
public virtual bool canAppendPixelBuffer => handle.CanAppendPixelBuffer(out var result).Throw() == Status.Ok && result;
///
/// Whether the recorder supports appendind audio buffers.
///
public virtual bool canAppendAudioBuffer => handle.CanAppendAudioBuffer(out var result).Throw() == Status.Ok && result;
///
/// Append a video frame to the recorder.
///
/// Input image to append. The image MUST have a valid timestamp for formats that require one.
public virtual void Append(PixelBuffer pixelBuffer) => handle.AppendPixelBuffer(pixelBuffer).Throw();
///
/// Append an audio frame to the recorder.
///
/// Input audio buffer to append. This audio buffer MUST have a valid timestamp for formats that require one.
public virtual void Append(AudioBuffer audioBuffer) => handle.AppendSampleBuffer(audioBuffer).Throw();
///
/// Finish writing.
///
/// Recorded media asset.
public virtual Task FinishWriting() {
var tcs = new TaskCompletionSource();
var handle = GCHandle.Alloc(tcs, GCHandleType.Normal);
try {
this.handle.FinishWriting(OnFinishWriting, (IntPtr)handle).Throw();
} catch (Exception ex) {
handle.Free();
tcs.SetException(ex);
}
return tcs.Task;
}
///
/// Create a media recorder.
/// NOTE: This requires an active VideoKit plan.
///
/// Recorder format.
/// Video width.
/// Video height.
/// Video frame rate.
/// Audio sample rate.
/// Audio channel count.
/// Video bit rate in bits per second.
/// Keyframe interval in seconds.
/// Image compression quality in range [0, 1].
/// Audio bit rate in bits per second.
/// Subdirectory name to save recordings. This will be created if it does not exist.
/// Created recorder.
public static async Task Create(
Format format,
int width = 0,
int height = 0,
float frameRate = 0f,
int sampleRate = 0,
int channelCount = 0,
int videoBitRate = 20_000_000,
int keyframeInterval = 2,
float compressionQuality = 0.8f,
int audioBitRate = 64_000,
string? prefix = null
) {
// Check session
await VideoKitClient.Instance!.CheckSession();
// Create recorder
IntPtr recorder = IntPtr.Zero;
switch (format) {
case Format.MP4: return new MediaRecorder(VideoKit.CreateMP4Recorder(
CreatePath(extension:@".mp4", prefix:prefix),
width,
height,
frameRate,
sampleRate,
channelCount,
videoBitRate,
keyframeInterval,
audioBitRate,
out recorder
).Throw() == Status.Ok ? recorder : default);
case Format.HEVC: return new MediaRecorder(VideoKit.CreateHEVCRecorder(
CreatePath(extension: @".mp4", prefix: prefix),
width,
height,
frameRate,
sampleRate,
channelCount,
videoBitRate,
keyframeInterval,
audioBitRate,
out recorder
).Throw() == Status.Ok ? recorder : default);
case Format.GIF: return new MediaRecorder(VideoKit.CreateGIFRecorder(
CreatePath(extension: @".gif", prefix: prefix),
width,
height,
1f / frameRate,
out recorder
).Throw() == Status.Ok ? recorder : default);
case Format.WAV: return new MediaRecorder(VideoKit.CreateWAVRecorder(
CreatePath(extension: @".wav", prefix: prefix),
sampleRate,
channelCount,
out recorder
).Throw() == Status.Ok ? recorder : default);
case Format.WEBM: return new MediaRecorder(VideoKit.CreateWEBMRecorder(
CreatePath(extension: @".webm", prefix: prefix),
width,
height,
frameRate,
sampleRate,
channelCount,
videoBitRate,
keyframeInterval,
audioBitRate,
out recorder
).Throw() == Status.Ok ? recorder : default);
case Format.JPEG: return new MediaRecorder(VideoKit.CreateJPEGRecorder(
CreatePath(prefix: prefix),
width,
height,
compressionQuality,
out recorder
).Throw() == Status.Ok ? recorder : default);
case Format.AV1: return new MediaRecorder(VideoKit.CreateAV1Recorder(
CreatePath(extension: @".mp4", prefix: prefix),
width,
height,
frameRate,
sampleRate,
channelCount,
videoBitRate,
keyframeInterval,
audioBitRate,
out recorder
).Throw() == Status.Ok ? recorder : default);
case Format.ProRes4444: return new MediaRecorder(VideoKit.CreateProRes4444Recorder(
CreatePath(extension: @".mov", prefix: prefix),
width,
height,
sampleRate,
channelCount,
audioBitRate,
out recorder
).Throw() == Status.Ok ? recorder : default);
default: throw new InvalidOperationException($"Cannot create media recorder because format is not supported: {format}");
}
}
///
/// Check whether the current device supports recording to this format.
///
/// Recording format.
/// Whether the current device supports recording to this format.
public static bool IsFormatSupported(Format format) => VideoKit.IsMediaRecorderFormatSupported(format) == Status.Ok;
#endregion
#region --Operations--
private readonly IntPtr handle;
private static string directory = string.Empty;
protected MediaRecorder (IntPtr handle) => this.handle = handle;
public static implicit operator IntPtr (MediaRecorder recorder) => recorder.handle;
public static implicit operator Action (MediaRecorder recorder) => recorder.Append;
public static implicit operator Action (MediaRecorder recorder) => recorder.Append;
protected static string CreatePath(string? extension = null, string? prefix = null) {
// Create parent directory
var parentDirectory = !string.IsNullOrEmpty(prefix) ? Path.Combine(directory, prefix) : directory;
Directory.CreateDirectory(parentDirectory);
// Get recording path
var timestamp = DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss_fff");
var name = $"recording_{timestamp}{extension ?? string.Empty}";
var path = Path.Combine(parentDirectory, name);
// Return
return path;
}
#endregion
#region --Callbacks--
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
private static void OnInitialize() => directory = Application.isEditor ?
Directory.GetCurrentDirectory() :
Application.persistentDataPath;
[MonoPInvokeCallback(typeof(VideoKit.MediaAssetHandler))]
private static unsafe void OnFinishWriting(IntPtr context, IntPtr asset) {
// Check
if (!VideoKit.IsAppDomainLoaded)
return;
// Get tcs
TaskCompletionSource? tcs;
try {
var handle = (GCHandle)context;
tcs = handle.Target as TaskCompletionSource;
handle.Free();
} catch (Exception ex) {
Debug.LogException(ex);
return;
}
// Invoke
if (asset != IntPtr.Zero)
tcs?.SetResult(new MediaAsset(asset));
else
tcs?.SetException(new Exception(@"Recorder failed to finish writing"));
}
#endregion
}
}