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