/* * 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 } }