/*
* VideoKit
* Copyright © 2025 Yusuf Olokoba. All Rights Reserved.
*/
#nullable enable
namespace VideoKit {
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Serialization;
using Unity.Collections;
using Clocks;
using Sources;
using UI;
using MediaFormat = MediaRecorder.Format;
///
/// VideoKit recorder for recording videos.
///
[Tooltip(@"VideoKit recorder for recording videos.")]
[HelpURL(@"https://videokit.ai/reference/videokitrecorder")]
[DisallowMultipleComponent]
public sealed partial class VideoKitRecorder : MonoBehaviour {
#region --Enumerations--
///
/// Video recording mode.
///
public enum VideoMode : int {
///
/// Don't record video.
///
None = 0,
///
/// Record video frames from one or more game cameras.
///
Camera = 1,
///
/// Record video frames from the screen.
///
Screen = 2,
///
/// Record video frames from a texture.
///
Texture = 3,
///
/// Record video frames from a camera device.
///
CameraDevice = 4,
}
///
/// Audio recording mode.
///
public enum AudioMode : int {
///
/// Don't record audio.
///
None = 0b0000,
///
/// Record audio from an audio device.
///
AudioDevice = 0b0010,
///
/// Record audio from an audio listener.
///
AudioListener = 0b0001,
///
/// Record audio from an audio source.
///
AudioSource = 0b0100,
}
///
/// Video recording resolution presets.
///
public enum Resolution : int {
///
/// QVGA resolution.
///
_240xAuto = 11,
///
/// QVGA resolution.
///
_320xAuto = 5,
///
/// Portrait SD resolution.
///
[InspectorName(@"480p Portrait")]
_480xAuto = 6,
///
/// SD resolution.
///
[InspectorName(@"480p Landscape")]
_640xAuto = 0,
///
/// Potrait HD resolution.
///
[InspectorName(@"720p Portrait")]
_720xAuto = 7,
///
/// HD resolution.
///
[InspectorName(@"720p Landscape")]
_1280xAuto = 1,
///
/// Portrait Full HD resolution.
///
[InspectorName(@"1080p Portrait")]
_1080xAuto = 12,
///
/// Full HD resolution.
///
[InspectorName(@"1080p Landscape")]
_1920xAuto = 2,
///
/// Portrait 2K WQHD resolution.
///
[InspectorName(@"2K Portrait")]
_1440xAuto = 13,
///
/// 2K WQHD resolution.
///
[InspectorName(@"2K Landscape")]
_2560xAuto = 3,
///
/// Portrait 4K UHD resolution.
///
[InspectorName(@"4K Portrait")]
_2160xAuto = 14,
///
/// 4K UHD resolution.
///
[InspectorName(@"4K Landscape")]
_3840xAuto = 4,
///
/// Screen resolution.
///
Screen = 9,
///
/// Half of the screen resolution.
///
HalfScreen = 10,
///
/// Custom resolution.
///
Custom = 8,
}
///
/// Recorder status.
///
public enum Status : int {
///
/// No recording session is in progress.
///
Idle = 0,
///
/// Recording session is in progress.
///
Recording = 1,
///
/// Recording session is in progress but is paused.
///
Paused = 2,
}
///
/// Video watermark mode.
///
public enum WatermarkMode : int {
///
/// No watermark.
///
None = 0,
///
/// Place watermark in the bottom-left of the frame.
///
BottomLeft = 1,
///
/// Place watermark in the bottom-right of the frame.
///
BottomRight = 2,
///
/// Place watermark in the upper-left of the frame.
///
UpperLeft = 3,
///
/// Place watermark in the upper-right of the frame.
///
UpperRight = 4,
///
/// Place watermark in a user-defined rectangle.
/// Set the rect with the `watermarkRect` property.
///
Custom = 5,
}
///
/// Recording action.
///
[Flags]
public enum RecordingAction : int {
///
/// Nothing.
///
None = 0,
///
/// Save the media asset to the camera roll.
///
CameraRoll = 1 << 1,
///
/// Prompt the user to share the media asset with the native sharing UI.
///
Share = 1 << 2,
///
/// Playback the video with the platform default media player.
///
Playback = 1 << 3,
///
/// Define a custom callback to receive the media asset.
/// NOTE: This is mutually exclusive with all other recording actions.
///
Custom = 1 << 5,
}
#endregion
#region --Types--
///
/// Resolved recording configuration.
///
public struct Configuration {
///
/// Recording format.
///
public MediaFormat foamt;
///
/// Video width.
///
public int width;
///
/// Video height.
///
public int height;
///
/// Video frame rate.
///
public float frameRate;
///
/// Audio sample rate.
///
public int sampleRate;
///
/// Audio channel count.
///
public int channelCount;
///
/// Video bitrate in bits per second.
///
public int videoBitRate;
///
/// Video keyframe interval in seconds.
///
public int keyframeInterval;
///
/// Audio bitrate in bits per second.
///
public int audioBitRate;
///
/// Path prefix.
///
public string recordingPathPrefix;
}
#endregion
#region --Inspector--
[Header(@"Format")]
///
/// Recording format.
///
[Tooltip(@"Recording format.")]
public MediaFormat format = MediaFormat.MP4;
///
/// Prepare the hardware encoders on awake.
/// This prevents a noticeable stutter that occurs on the very first recording.
///
[Tooltip(@"Prepare the hardware encoders on awake. This prevents a noticeable stutter that occurs on the very first recording.")]
public bool prepareOnAwake = false;
[Header(@"Video")]
///
/// Video recording mode.
///
[Tooltip(@"Video recording mode.")]
public VideoMode videoMode = VideoMode.Camera;
///
/// Video recording resolution.
/// NOTE: This is not supported with `VideoMode.CameraDevice`.
///
[Tooltip(@"Video recording resolution.")]
public Resolution resolution = Resolution._1280xAuto;
///
/// Video recording custom resolution.
/// NOTE: This is only used when `resolution` is set to `Resolution.Custom`.
///
[Tooltip(@"Video recording custom resolution.")]
public Vector2Int customResolution = new Vector2Int(1280, 720);
///
/// Game cameras to record.
///
[Tooltip(@"Game cameras to record.")]
public Camera[] cameras = new Camera[0];
///
/// Recording texture for recording video frames from a texture.
///
[Tooltip(@"Recording texture for recording video frames from a texture.")]
public Texture? texture;
///
/// Camera view for recording video frames from a camera device.
///
[Tooltip(@"Camera view for recording video frames from a camera device.")]
public VideoKitCameraView? cameraView;
///
/// Frame rate for animated GIF images.
/// This only applies when recording GIF images.
///
[Tooltip(@"Frame rate for animated GIF images."), Range(5f, 30f), FormerlySerializedAs(@"frameRate")]
public float _frameRate = 10f;
///
/// Number of successive camera frames to skip while recording.
///
[Tooltip(@"Number of successive camera frames to skip while recording."), Range(0, 5)]
public int frameSkip = 0;
[Header(@"Watermark")]
///
/// Recording watermark mode for adding a watermark to videos.
/// NOTE: Watermarking is not supported with the `VideoMode.CameraDevice` video mode.
///
[Tooltip(@"Recording watermark mode for adding a watermark to videos.")]
public WatermarkMode watermarkMode = WatermarkMode.None;
///
/// Recording watermark.
///
[SerializeField, FormerlySerializedAs(@"watermark"), Tooltip(@"Recording watermark.")]
private Texture? _watermark;
///
/// Watermark display rect when `watermarkMode` is set to `WatermarkMode.Custom`.
///
[SerializeField, FormerlySerializedAs(@"watermarkRect"), Tooltip(@"Watermark display rect when `watermarkMode` is set to `WatermarkMode.Custom`")]
private Rect _watermarkRect;
[Header(@"Audio")]
///
/// Audio recording mode.
///
[Tooltip(@"Audio recording mode.")]
public AudioMode audioMode = AudioMode.None;
///
/// Audio manager for recording audio from an audio device.
///
[Tooltip(@"Audio manager for recording audio from an audio device.")]
public VideoKitAudioManager? audioManager;
///
/// Whether the recorder can configure the audio manager for recording.
/// Unless you intend to override the audio manager configuration, leave this `true`.
///
[Tooltip(@"Whether the recorder can configure the audio manager for recording.")]
public bool configureAudioManager = true;
///
/// Audio listener for recording audio from an audio listener.
///
[Tooltip(@"Audio listener for recording audio from an audio listener.")]
public AudioListener? audioListener;
///
/// Audio source for recording audio from an audio source.
///
[Tooltip(@"Audio source for recording audio from an audio source.")]
public AudioSource? audioSource;
[Header(@"Recording")]
///
/// Recording action.
///
[Tooltip(@"Recording action.")]
public RecordingAction recordingAction = 0;
///
/// Event raised when a recording session is completed.
///
[Tooltip(@"Event raised when a recording session is completed.")]
public UnityEvent? OnRecordingCompleted;
#endregion
#region --Client API--
///
/// Recording path prefix when saving recordings to the app's documents.
///
[HideInInspector]
public string mediaPathPrefix = @"recordings";
///
/// Video bit rate in bits per second.
///
[HideInInspector]
public int videoBitRate = 20_000_000;
///
/// Video keyframe interval in seconds.
///
[HideInInspector]
public int keyframeInterval = 2;
///
/// Audio bit rate in bits per second.
///
[HideInInspector]
public int audioBitRate = 64_000;
///
/// Recorder factory when using a custom recorder.
/// Note that this variable takes precedence over the `format` when creating a recorder.
///
public Func>? recorderFactory;
///
/// Resolved recording configuration.
///
public Configuration configuration {
get {
var width = resolution switch {
var _ when videoMode == 0 => 0,
var _ when videoMode == VideoMode.CameraDevice => cameraView!.texture!.width,
Resolution._240xAuto => 240,
Resolution._320xAuto => 320,
Resolution._480xAuto => 480,
Resolution._640xAuto => 640,
Resolution._720xAuto => 720,
Resolution._1080xAuto => 1080,
Resolution._1280xAuto => 1280,
Resolution._1920xAuto => 1920,
Resolution._1440xAuto => 1440,
Resolution._2560xAuto => 2560,
Resolution._3840xAuto => 3840,
Resolution.Screen => Screen.width >> 1 << 1,
Resolution.HalfScreen => Screen.width >> 2 << 1,
Resolution.Custom => customResolution.x,
_ => 1280,
};
var aspect = videoMode switch {
VideoMode.Camera => (float)Screen.width / Screen.height,
VideoMode.Screen => (float)Screen.width / Screen.height,
VideoMode.Texture => (float)texture!.width / texture!.height,
_ => 0f,
};
var height = resolution switch {
var _ when videoMode == 0 => 0,
var _ when videoMode == VideoMode.CameraDevice => cameraView!.texture!.height,
Resolution.Custom => customResolution.y,
Resolution.Screen => Screen.height >> 1 << 1,
Resolution.HalfScreen => Screen.height >> 2 << 1,
_ => Mathf.RoundToInt(width / aspect) >> 1 << 1,
};
var frameRate = videoMode switch {
var _ when format == MediaFormat.GIF => _frameRate,
VideoMode.CameraDevice => cameraView!.device!.frameRate,
_ => 30,
};
var sampleRate = audioMode switch {
AudioMode.AudioDevice => audioManager?.device?.sampleRate ?? 0,
AudioMode.AudioListener => AudioSettings.outputSampleRate,
AudioMode.AudioSource => AudioSettings.outputSampleRate,
_ => 0,
};
var channelCount = audioMode switch {
AudioMode.AudioDevice => audioManager?.device?.channelCount ?? 0,
AudioMode.AudioListener => (int)AudioSettings.speakerMode,
AudioMode.AudioSource => (int)AudioSettings.speakerMode,
_ => 0,
};
return new Configuration {
width = width,
height = height,
frameRate = frameRate,
sampleRate = sampleRate,
channelCount = channelCount,
videoBitRate = videoBitRate,
keyframeInterval = keyframeInterval,
audioBitRate = audioBitRate,
recordingPathPrefix = mediaPathPrefix,
};
}
}
///
/// Recording watermark.
///
public Texture? watermark {
get => GetTextureSource(videoInput)?.watermark ?? _watermark;
set {
_watermark = value;
var textureSource = GetTextureSource(videoInput);
if (textureSource != null)
textureSource.watermark = value;
}
}
///
/// Watermark normalized display rect when `watermarkMode` is set to `WatermarkMode.Custom`.
///
public Rect watermarkRect {
get {
var textureSource = GetTextureSource(videoInput);
if (textureSource == null)
return _watermarkRect;
var config = configuration;
var width = recorder?.width ?? config.width;
var height = recorder?.height ?? config.height;
var rect = textureSource!.watermarkRect;
return new(
rect.x / width,
rect.y / height,
rect.width / width,
rect.height / height
);
}
set {
_watermarkRect = value;
var config = configuration;
var width = recorder?.width ?? config.width;
var height = recorder?.height ?? config.height;
var textureSource = GetTextureSource(videoInput);
if (textureSource != null)
textureSource.watermarkRect = new(
Mathf.RoundToInt(value.x * width),
Mathf.RoundToInt(value.y * height),
Mathf.RoundToInt(value.width * width),
Mathf.RoundToInt(value.height * height)
);
}
}
///
/// Recorder status.
///
public Status status => clock?.paused switch {
true => Status.Paused,
false => Status.Recording,
null => Status.Idle,
};
///
/// Start recording.
///
public async void StartRecording () => await StartRecordingAsync();
///
/// Start recording.
///
public async Task StartRecordingAsync () {
// Check active
if (!isActiveAndEnabled)
throw new InvalidOperationException(@"VideoKitRecorder cannot start recording because component is disabled");
// Check status
if (status != Status.Idle)
throw new InvalidOperationException(@"VideoKitRecorder cannot start recording because a recording session is already in progress");
// Check camera device mode
if (videoMode == VideoMode.CameraDevice) {
// Check camera view
if (cameraView == null)
throw new InvalidOperationException(@"VideoKitRecorder cannot start recording because the video mode is set to `VideoMode.CameraDevice` but `cameraView` is null");
// Check camera preview is running
if (cameraView.texture == null)
throw new InvalidOperationException(@"VideoKitRecorder cannot start recording because the video mode is set to `VideoMode.CameraDevice` but the camera preview is not running");
}
// Check audio mode
if (audioMode.HasFlag(AudioMode.AudioListener) && Application.platform == RuntimePlatform.WebGLPlayer) {
Debug.LogWarning(@"VideoKitRecorder cannot record audio from AudioListener because WebGL does not support `OnAudioFilterRead`");
audioMode &= ~AudioMode.AudioListener;
}
// Check audio device
if (audioMode.HasFlag(AudioMode.AudioDevice)) {
// Check audio manager
if (audioManager == null)
throw new InvalidOperationException(@"VideoKitRecorder cannot start recording because the audio mode includes `AudioMode.AudioDevice` but `audioManager` is null");
// Configure audio manager
if (configureAudioManager) {
// Set format
if (audioMode.HasFlag(AudioMode.AudioListener)) {
audioManager.sampleRate = VideoKitAudioManager.SampleRate.MatchUnity;
audioManager.channelCount = VideoKitAudioManager.ChannelCount.MatchUnity;
}
// Start running
await audioManager.StartRunningAsync();
}
}
// Check format
if (format == MediaFormat.MP4 && Application.platform == RuntimePlatform.WebGLPlayer) {
format = MediaFormat.WEBM;
Debug.LogWarning(@"VideoKitRecorder will use WEBM format on WebGL because MP4 is not supported");
}
// Create recorder
var config = configuration;
if (recorderFactory != null)
recorder = await recorderFactory(config);
else
recorder = await MediaRecorder.Create(
format,
width: config.width,
height: config.height,
frameRate: config.frameRate,
sampleRate: config.sampleRate,
channelCount: config.channelCount,
videoBitRate: config.videoBitRate,
keyframeInterval: config.keyframeInterval,
compressionQuality: 0.8f,
audioBitRate: config.audioBitRate,
prefix: config.recordingPathPrefix
);
// Create inputs
clock = new RealtimeClock();
videoInput = recorder.canAppendPixelBuffer ? CreateVideoInput(recorder.width, recorder.height, recorder.Append) : null;
audioInput = recorder.canAppendAudioBuffer ? CreateAudioInput(recorder.Append) : null;
// Apply watermark
var textureSource = GetTextureSource(videoInput);
if (textureSource != null) {
textureSource.watermark = watermark;
textureSource.watermarkRect = CreateWatermarkRect(recorder.width, recorder.height);
}
}
///
/// Pause recording.
///
[Obsolete(@"Deprecated in VideoKit 0.0.20 and will be removed soon after.", false)]
public void PauseRecording () {
// Check
if (status != Status.Recording) {
Debug.LogError(@"Cannot pause recording because no recording session is in progress");
return;
}
// Stop audio manager
if (configureAudioManager && audioManager != null)
audioManager.StopRunning();
// Dispose inputs
videoInput?.Dispose();
audioInput?.Dispose();
videoInput = null;
audioInput = null;
// Pause clock
clock!.paused = true;
}
///
/// Resume recording.
///
[Obsolete(@"Deprecated in VideoKit 0.0.20 and will be removed soon after.", false)]
public void ResumeRecording () {
// Check status
if (status != Status.Paused) {
Debug.LogError(@"Cannot resume recording because the recording session is not paused");
return;
}
// Check recorder
if (recorder == null) {
Debug.LogError(@"Cannot resume recording because the recording session is invalid");
return;
}
// Check active
if (!isActiveAndEnabled) {
Debug.LogError(@"Cannot resume recording because component is disabled");
return;
}
// Check audio manager
if (configureAudioManager && audioManager != null)
audioManager.StartRunning();
// Unpause clock
clock!.paused = false;
// Create inputs
videoInput = recorder.canAppendPixelBuffer ? CreateVideoInput(recorder.width, recorder.height, recorder.Append) : null;
audioInput = recorder.canAppendAudioBuffer ? CreateAudioInput(recorder.Append) : null;
// Apply watermark
var textureSource = GetTextureSource(videoInput);
if (textureSource != null) {
textureSource.watermark = watermark;
textureSource.watermarkRect = CreateWatermarkRect(recorder.width, recorder.height);
}
}
///
/// Stop recording.
///
public async void StopRecording () => await StopRecordingAsync();
///
/// Stop recording.
///
public async Task StopRecordingAsync () {
// Check
if (status == Status.Idle) {
Debug.LogWarning(@"Cannot stop recording because no recording session is in progress");
return;
}
// Stop audio manager
if (configureAudioManager && audioManager != null)
audioManager.StopRunning();
// Stop inputs
audioInput?.Dispose();
videoInput?.Dispose();
videoInput = null;
audioInput = null;
clock = null;
// Stop recording
var asset = await recorder!.FinishWriting();
// Check that this is not result of disabling // CHECK // Delete asset?
if (!isActiveAndEnabled)
return;
// Post action
if (recordingAction.HasFlag(RecordingAction.Custom))
OnRecordingCompleted?.Invoke(asset);
if (recordingAction.HasFlag(RecordingAction.CameraRoll))
await asset.SaveToCameraRoll();
if (recordingAction.HasFlag(RecordingAction.Share))
await asset.Share();
#if UNITY_ANDROID || UNITY_IOS || UNITY_VISIONOS
if (recordingAction.HasFlag(RecordingAction.Playback) && asset.type == MediaAsset.MediaType.Video)
Handheld.PlayFullScreenMovie($"file://{asset.path}");
#endif
}
///
/// Capture a screenshot with the current video settings.
///
/// Screenshot image asset.
public async Task CaptureScreenshot () {
var config = configuration;
var recorder = await MediaRecorder.Create(
MediaFormat.JPEG,
config.width,
config.height,
compressionQuality: 0.8f,
prefix: config.recordingPathPrefix
);
{
var tcs = new TaskCompletionSource();
using var source = CreateVideoInput(recorder.width, recorder.height, pixelBuffer => {
if (tcs.Task.IsCompleted)
return;
recorder.Append(pixelBuffer);
tcs.SetResult(true);
});
var textureSource = GetTextureSource(source);
if (textureSource != null) {
textureSource.watermark = watermark;
textureSource.watermarkRect = CreateWatermarkRect(recorder.width, recorder.height);
}
await tcs.Task;
}
var sequenceAsset = await recorder.FinishWriting();
var imageAsset = sequenceAsset.assets[0];
return imageAsset;
}
#endregion
#region --Operations--
private MediaRecorder? recorder;
private RealtimeClock? clock;
private IDisposable? videoInput;
private IDisposable? audioInput;
private void Reset () {
cameras = Camera.allCameras;
cameraView = FindFirstObjectByType();
audioManager = FindFirstObjectByType();
audioListener = FindFirstObjectByType();
}
private async void Awake () {
if (prepareOnAwake)
await PrepareEncoder();
}
private void OnDestroy () {
if (status != Status.Idle)
StopRecording();
}
private IDisposable? CreateVideoInput (int width, int height, Action handler) => videoMode switch {
VideoMode.Screen => new ScreenSource(width, height, handler, clock) { frameSkip = frameSkip },
VideoMode.Camera => new CameraSource(width, height, cameras, handler, clock) { frameSkip = frameSkip },
VideoMode.Texture => new TextureSource(width, height, handler, clock) { texture = texture, frameSkip = frameSkip },
VideoMode.CameraDevice => new CameraViewSource(cameraView!, handler, clock) { frameSkip = frameSkip },
_ => null,
};
private IDisposable? CreateAudioInput (Action handler) => audioMode switch {
AudioMode.AudioDevice => new AudioManagerSource(audioManager!, handler, clock),
AudioMode.AudioListener => new AudioComponentSource(audioListener!, handler, clock),
AudioMode.AudioSource => new AudioComponentSource(audioSource!, handler, clock),
_ => null,
};
#endregion
#region --Utility--
private RectInt CreateWatermarkRect (int width, int height) {
// Check none
if (watermarkMode == WatermarkMode.None)
return default;
// Check custom
if (watermarkMode == WatermarkMode.Custom)
return new(
Mathf.RoundToInt(watermarkRect.x * width),
Mathf.RoundToInt(watermarkRect.y * height),
Mathf.RoundToInt(watermarkRect.width * width),
Mathf.RoundToInt(watermarkRect.height * height)
);
// Construct rect
var imageSize = new Vector2(width, height);
var offset = 0.1f;
var size = 0.3f;
var normalizedRect = new Dictionary {
[WatermarkMode.BottomLeft] = new(offset, offset, size, size),
[WatermarkMode.BottomRight] = new(1f - size - offset, offset, size, size),
[WatermarkMode.UpperLeft] = new(offset, 1f - size - offset, size, size),
[WatermarkMode.UpperRight] = new(1f - size - offset, 1f - size - offset, size, size),
}[watermarkMode];
var frameRect = new RectInt(
Vector2Int.RoundToInt(Vector2.Scale(normalizedRect.position, imageSize)),
Vector2Int.RoundToInt(Vector2.Scale(normalizedRect.size, imageSize))
);
// Return
return frameRect;
}
private static async Task PrepareEncoder () {
try {
// Create recorder
var clock = new FixedClock(30);
var recorder = await MediaRecorder.Create(
MediaFormat.MP4,
width: 1280,
height: 720,
frameRate: 30
);
// Commit empty frames
using var pixelData = new NativeArray(
recorder.width * recorder.height * 4,
Allocator.Persistent,
NativeArrayOptions.ClearMemory
);
var format = PixelBuffer.Format.RGBA8888;
for (var i = 0; i < 3; ++i) {
using var pixelBuffer = new PixelBuffer(
recorder.width,
recorder.height,
format,
pixelData,
timestamp:
clock.timestamp
);
recorder.Append(pixelBuffer);
}
// Finish and delete
var asset = await recorder.FinishWriting();
File.Delete(asset.path);
} catch { }
}
private static TextureSource? GetTextureSource (IDisposable? videoInput) => videoInput switch {
CameraSource cameraSource => cameraSource.textureSource,
ScreenSource screenSource => screenSource.textureSource,
TextureSource textureSource => textureSource,
_ => null,
};
#endregion
}
}