From 3bc66e078881f81b3fc39d516e263438c7e749a3 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Tue, 30 Jun 2026 13:11:07 -0500 Subject: [PATCH] feat: max memory settings for the replay buffer If the maximum memory limit of the video replay buffer is exceede before the time limit, then the oldest frames will be discarded. When this occurs, the old packets in the audio replay buffer are discarded to match the start time of the oldest video frame. This setting only applies to the memory size of the video replay buffer. The size of the audio is not significant in comparison. closes #125 --- .gitignore | 1 + src/audio/audio_replay_buffer.zig | 47 ++++++++++----- src/audio/audio_timeline.zig | 13 +---- src/store/audio_session.zig | 2 +- src/store/capture_store.zig | 97 +++++++++++++++++++++++++++++-- src/store/store.zig | 3 +- src/store/user_settings.zig | 11 ++++ src/store/user_settings_store.zig | 5 ++ src/store/video_session.zig | 34 +++++++++-- src/ui/draw_left_column.zig | 39 ++++++++++++- src/video/video_replay_buffer.zig | 71 +++++++++++++++++++--- 11 files changed, 275 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index d967639..0bb9730 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ zig-pkg /*.h264 /*.bmp /*.mp4 +/*.mov diff --git a/src/audio/audio_replay_buffer.zig b/src/audio/audio_replay_buffer.zig index dc0809c..2c39ef9 100644 --- a/src/audio/audio_replay_buffer.zig +++ b/src/audio/audio_replay_buffer.zig @@ -64,7 +64,7 @@ pub fn add_data(self: *Self, data: Arc(AudioCaptureData)) !void { var ready_packets = self.timeline.take_ready_packets(); defer deinitPacketList(&ready_packets); self.append_packets(&ready_packets); - self.trim_packets(); + self.trim_packets(.{}); } /// Flush any remaining packets in the timeline. @@ -74,12 +74,12 @@ pub fn finalize(self: *Self) !void { var ready_packets = self.timeline.take_ready_packets(); defer deinitPacketList(&ready_packets); self.append_packets(&ready_packets); - self.trim_packets(); + self.trim_packets(.{}); } pub fn set_replay_seconds(self: *Self, replay_seconds: u32) void { self.replay_seconds = replay_seconds; - self.trim_packets(); + self.trim_packets(.{}); } pub fn packet_iterator(self: *Self) LinkedListIterator(EncodedAudioPacketNode) { @@ -99,19 +99,36 @@ fn append_packets(self: *Self, packets: *std.DoublyLinkedList) void { } } -/// Remove packets that are older than the configured replay duration. -fn trim_packets(self: *Self) void { - const retention_samples = self.replay_retention_samples(); - const oldest_sample = self.timeline.encoded_until_sample - retention_samples; +fn remove_first_packet(self: *Self) void { + if (self.packets.popFirst()) |first| { + const packet_node: *EncodedAudioPacketNode = @fieldParentPtr("node", first); + self.size -= @intCast(packet_node.data.*.size); + self.len -= 1; + packet_node.deinit(); + } +} + +/// Remove packets that are older than the configured replay duration, or older +/// than `oldest_time_ns` when provided. +pub fn trim_packets(self: *Self, args: struct { + oldest_time_ns: ?i128 = null, +}) void { + const oldest_sample = if (args.oldest_time_ns) |oldest_time_ns| blk: { + break :blk self.timeline.timestamp_to_sample_floor(oldest_time_ns); + } else blk: { + const retention_samples = self.replay_retention_samples(); + break :blk self.timeline.encoded_until_sample - retention_samples; + }; + + if (oldest_sample == null) { + return; + } while (self.packets.first) |first| { const packet_node: *EncodedAudioPacketNode = @fieldParentPtr("node", first); const packet_end = packet_node.data.*.pts + packet_node.data.*.duration; - if (packet_end <= oldest_sample) { - _ = self.packets.popFirst(); - self.len -= 1; - self.size -= @intCast(packet_node.data.*.size); - packet_node.deinit(); + if (packet_end <= oldest_sample.?) { + self.remove_first_packet(); } else { break; } @@ -122,8 +139,8 @@ fn replay_retention_samples(self: *Self) i64 { return self.replay_seconds * SAMPLE_RATE; } -const TestUtil = struct { - fn create_audio_capture_data( +pub const TestUtil = struct { + pub fn create_audio_capture_data( allocator: Allocator, timestamp_ns: i128, samples_per_channel: usize, @@ -159,7 +176,7 @@ test "AudioReplayBuffer - add_data - encodes audio before export and exposes pac try replay_buffer.finalize(); try std.testing.expect(replay_buffer.has_packets()); - const packet_window = replay_buffer.timeline.get_sample_window( + const packet_window = replay_buffer.timeline.get_unclamped_sample_window( std.time.ns_per_s, std.time.ns_per_s + std.time.ns_per_s / 10, ) orelse return error.ExpectedSampleWindow; diff --git a/src/audio/audio_timeline.zig b/src/audio/audio_timeline.zig index ed44205..231d747 100644 --- a/src/audio/audio_timeline.zig +++ b/src/audio/audio_timeline.zig @@ -138,7 +138,7 @@ pub const AudioTimeline = struct { device.value_ptr.* = .{}; } - var start_sample = self.timestamp_to_sample_floor(audio_capture_data.start_ns()); + var start_sample = self.timestamp_to_sample_floor(audio_capture_data.start_ns()).?; // Chunks don't always arrive at exactly the timestamps they are // expected to. Small jitter here can cause static once multiple devices @@ -193,14 +193,6 @@ pub const AudioTimeline = struct { }; } - pub fn get_sample_window(self: *Self, start_time_ns: i128, end_time_ns: i128) ?SampleWindow { - if (self.timeline_origin_ns == null) return null; - return .{ - .start_sample = self.timestamp_to_sample_floor(start_time_ns), - .end_sample = self.timestamp_to_sample_ceil(end_time_ns), - }; - } - /// Export alignment sometimes needs a window that begins before the first /// captured audio sample. Keep that offset negative so muxing preserves the /// initial gap instead of shifting audio earlier to time zero. This can occur @@ -292,7 +284,8 @@ pub const AudioTimeline = struct { } /// Floor is used for starts so a chunk never begins after its true time. - fn timestamp_to_sample_floor(self: *Self, timestamp_ns: i128) i64 { + pub fn timestamp_to_sample_floor(self: *Self, timestamp_ns: i128) ?i64 { + if (self.timeline_origin_ns == null) return null; return @max(self.timestamp_to_sample_floor_unclamped(timestamp_ns), 0); } diff --git a/src/store/audio_session.zig b/src/store/audio_session.zig index b278c6c..37ae4b5 100644 --- a/src/store/audio_session.zig +++ b/src/store/audio_session.zig @@ -142,7 +142,7 @@ pub const AudioSession = struct { try replay_buffer.add_data(data.clone()); self.store.dispatch(.{ .capture = .{ - .update_replay_buffer_size = .{ .audio_bytes = replay_buffer.size }, + .update_replay_buffer_metrics = .{ .audio_bytes = replay_buffer.size }, }, }); } diff --git a/src/store/capture_store.zig b/src/store/capture_store.zig index b38dc03..2bd5dc5 100644 --- a/src/store/capture_store.zig +++ b/src/store/capture_store.zig @@ -16,6 +16,7 @@ const Muxer = @import("../video/muxer.zig").Muxer; const Mutex = @import("../mutex.zig").Mutex; const VideoCaptureSelection = @import("../capture/video/video_capture.zig").VideoCaptureSelection; const VideoReplayBuffer = @import("../video/video_replay_buffer.zig").VideoReplayBuffer; +const AudioReplayBuffer = @import("../audio/audio_replay_buffer.zig"); const exporter = @import("../exporter.zig"); const AudioCaptureData = @import("../capture/audio/audio_capture_data.zig"); const Arc = @import("../arc.zig").Arc; @@ -75,7 +76,7 @@ pub const CaptureStore = struct { update_audio_device_level: UpdateAudioDeviceLevelPayload, start_audio_capture_thread, - update_replay_buffer_size: union(enum) { + update_replay_buffer_metrics: union(enum) { audio_bytes: u64, video: struct { bytes: u64, start_time: ?std.Io.Timestamp }, }, @@ -87,6 +88,12 @@ pub const CaptureStore = struct { stop_replay_buffer_success, stop_replay_buffer_fail, + /// When the video replay buffer state changes, sync the audio replay buffer with it. + /// This is really only necessary in the case that the replay length is infinite, but + /// the user relies on max bytes to trim the replay buffer. Without this, the audio + /// replay buffer would grow infinitely. + sync_replay_buffers, + save_replay, save_replay_success, save_replay_fail, @@ -137,6 +144,7 @@ pub const CaptureStore = struct { .stop_video_capture = .{effect_stop_video_capture}, .select_video_source = .{effect_select_video_source}, .select_video_source_prepared = .{effect_select_video_source_prepared}, + .sync_replay_buffers = .{effect_sync_replay_buffers}, .save_replay = .{effect_save_replay}, }; @@ -363,7 +371,7 @@ pub const CaptureStore = struct { break; } }, - .update_replay_buffer_size => |payload| { + .update_replay_buffer_metrics => |payload| { switch (payload) { .audio_bytes => |audio_bytes| { state.capture.replay_buffer_metrics.audio_bytes = audio_bytes; @@ -392,7 +400,11 @@ pub const CaptureStore = struct { pub fn effect_sync_replay_buffer_with_user_settings(store: *Store, replay_seconds: u32) void { store.capture_store.audio_session.set_replay_buffer_seconds(replay_seconds); - store.capture_store.video_session.set_replay_buffer_seconds(replay_seconds); + store.capture_store.video_session.set_replay_buffer_values(.{ .replay_seconds = replay_seconds }); + } + + pub fn effect_sync_replay_buffer_max_bytes(store: *Store, replay_max_bytes: u64) void { + store.capture_store.video_session.set_replay_buffer_values(.{ .replay_max_bytes = replay_max_bytes }); } // ---------------------------------------------------------------------------- @@ -557,6 +569,7 @@ pub const CaptureStore = struct { .fps = state.user_settings.user_settings.capture_fps, .bit_rate = state.user_settings.user_settings.capture_bit_rate, .replay_seconds = state.user_settings.user_settings.replay_seconds, + .replay_max_bytes = state.user_settings.user_settings.replay_max_bytes, }; }; @@ -568,6 +581,7 @@ pub const CaptureStore = struct { state_local.fps, state_local.bit_rate, state_local.replay_seconds, + state_local.replay_max_bytes, ); store.dispatch(.{ .capture = .start_replay_buffer_success }); } @@ -672,12 +686,33 @@ pub const CaptureStore = struct { store.dispatch(.{ .capture = .stop_recording_to_disk_success }); } + /// See sync_replay_buffers message type for details. + fn effect_sync_replay_buffers(store: *Store, _: anytype) !void { + const self = &store.capture_store; + const video_start_ns = blk: { + var replay_buffer_locked = self.video_session.video_replay_buffer.lock(); + defer replay_buffer_locked.unlock(); + const replay_buffer = replay_buffer_locked.unwrap() orelse return; + const start_time = replay_buffer.get_start_time() orelse return; + break :blk start_time.nanoseconds; + }; + const audio_bytes = blk: { + var replay_buffer_locked = self.audio_session.audio_replay_buffer.lock(); + defer replay_buffer_locked.unlock(); + const replay_buffer = replay_buffer_locked.unwrap() orelse return; + replay_buffer.trim_packets(.{ .oldest_time_ns = video_start_ns }); + break :blk replay_buffer.size; + }; + store.dispatch(.{ .capture = .{ .update_replay_buffer_metrics = .{ .audio_bytes = audio_bytes } } }); + } + fn effect_save_replay(store: *Store, _: anytype) !void { const self = &store.capture_store; errdefer store.dispatch(.{ .capture = .save_replay_fail }); var fps: u32 = 0; var replay_seconds: u32 = 0; + var replay_max_bytes: u64 = 0; var video_output_directory: ?String = null; defer { if (video_output_directory) |*_video_output_directory| _video_output_directory.deinit(); @@ -695,6 +730,7 @@ pub const CaptureStore = struct { const settings = state.user_settings.user_settings; fps = settings.capture_fps; replay_seconds = settings.replay_seconds; + replay_max_bytes = settings.replay_max_bytes; // video_output_directory should never be null at this point. If so, there is // something seriously wrong. assert(settings.video_output_directory != null); @@ -712,6 +748,7 @@ pub const CaptureStore = struct { const video_replay_buffer: ?*VideoReplayBuffer = (try self.video_session.take_and_swap_replay_buffer( replay_seconds, + replay_max_bytes, )); defer if (video_replay_buffer) |_video_replay_buffer| _video_replay_buffer.deinit(); @@ -980,12 +1017,12 @@ test "CaptureStore - update_replay_buffer_size" { const size = 1024 * 1024 * 10; // 10MB - store.dispatch(.{ .capture = .{ .update_replay_buffer_size = .{ .audio_bytes = size } } }); + store.dispatch(.{ .capture = .{ .update_replay_buffer_metrics = .{ .audio_bytes = size } } }); store.run(.{ .once = true, .wait_for_effects = true }); const start_time = std.Io.Timestamp.now(std.testing.io, .awake); store.dispatch(.{ .capture = .{ - .update_replay_buffer_size = .{ + .update_replay_buffer_metrics = .{ .video = .{ .bytes = size, .start_time = start_time, @@ -1001,6 +1038,56 @@ test "CaptureStore - update_replay_buffer_size" { try std.testing.expectEqual(20, state.capture.replay_buffer_metrics.size_in_mb(.total)); } +test "CaptureStore - sync_replay_buffers - should remove audio frames when the video replay buffer is trimmed" { + const TestStore = @import("./store.zig").TestStore; + const AudioReplayBufferTestUtil = @import("../audio/audio_replay_buffer.zig").TestUtil; + const allocator = std.testing.allocator; + const test_store = try TestStore.init(allocator); + defer test_store.deinit(); + const store = test_store.store; + const state = &store.state.private.value; + + const video_start_ns = (2 * std.time.ns_per_s); + + var audio_replay_buffer = try AudioReplayBuffer.init(allocator, 10); + store.capture_store.audio_session.audio_replay_buffer.set(audio_replay_buffer); + + for (0..4) |second| { + const chunk = try AudioReplayBufferTestUtil.create_audio_capture_data( + allocator, + (@as(i128, @intCast(second)) * std.time.ns_per_s), + 2048, + 0.1, + ); + + try audio_replay_buffer.add_data(chunk); + } + try audio_replay_buffer.finalize(); + const audio_bytes_before = audio_replay_buffer.size; + try std.testing.expect(audio_bytes_before > 0); + + var video_replay_buffer = try VideoReplayBuffer.init(allocator, 10, 0, &.{}); + store.capture_store.video_session.video_replay_buffer.set(video_replay_buffer); + + try video_replay_buffer.add_frame(&.{1}, video_start_ns, true); + try video_replay_buffer.add_frame(&.{2}, video_start_ns + std.time.ns_per_s, false); + + store.dispatch(.{ .capture = .sync_replay_buffers }); + store.run(.{ .once = true, .wait_for_effects = true }); + store.run(.{ .once = true, .wait_for_effects = true }); + + try std.testing.expect(audio_replay_buffer.size < audio_bytes_before); + try std.testing.expectEqual(audio_replay_buffer.size, state.capture.replay_buffer_metrics.audio_bytes); + + // Ensure that there are no audio packets that occurred before the oldest video frame. + const oldest_audio_sample = audio_replay_buffer.timeline.timestamp_to_sample_floor(video_start_ns) orelse return error.ExpectedOldestAudioSample; + var iter = audio_replay_buffer.packet_iterator(); + while (iter.next()) |packet| { + const packet_end = packet.data.*.pts + packet.data.*.duration; + try std.testing.expect(packet_end > oldest_audio_sample); + } +} + // ---------------------------------------------------------------------------- // TODO: Still need to write tests for the rest of the message types. // ---------------------------------------------------------------------------- diff --git a/src/store/store.zig b/src/store/store.zig index e8fff4a..440f14d 100644 --- a/src/store/store.zig +++ b/src/store/store.zig @@ -288,7 +288,8 @@ pub const Store = struct { effect_fn(store, effect_payload); }, } - log.debug("[execute_registered_effects] effect: {s}", .{@typeName(@TypeOf(effect_fn))}); + // This is super chatty - we probably don't want this on anymore. + // log.debug("[execute_registered_effects] effect: {s}", .{@typeName(@TypeOf(effect_fn))}); } else { @compileError(@typeName(@TypeOf(effect_fn)) ++ " has no return type"); } diff --git a/src/store/user_settings.zig b/src/store/user_settings.zig index 458104b..fc87207 100644 --- a/src/store/user_settings.zig +++ b/src/store/user_settings.zig @@ -6,6 +6,7 @@ const util = @import("../util.zig"); const log = std.log.scoped(.user_settings); const SETTINGS_JSON = "settings.json"; +pub const DEFAULT_REPLAY_MAX_BYTES: u64 = 1024 * 1024 * 1024; // 1GB /// NOTE: This MUST remain JSON serializable. pub const UserSettings = struct { @@ -22,6 +23,8 @@ pub const UserSettings = struct { /// In bits per second (bps). capture_bit_rate: u64 = 10_000_000, replay_seconds: u32 = 30, + /// Max bytes in the replay buffer before it discards frames. 0 = unlimited + replay_max_bytes: u64 = DEFAULT_REPLAY_MAX_BYTES, start_replay_buffer_on_startup: bool = false, restore_capture_source_on_startup: bool = true, // Doesn't have a default value because an allocator is @@ -228,6 +231,7 @@ test "UserSettings - load" { \\ "capture_fps": 144, \\ "capture_bit_rate": 25000000, \\ "replay_seconds": 45, + \\ "replay_max_bytes": 536870912, \\ "start_replay_buffer_on_startup": true, \\ "restore_capture_source_on_startup": false, \\ "video_output_directory": "/tmp/spacecap-output", @@ -248,6 +252,7 @@ test "UserSettings - load" { try std.testing.expectEqual(144, settings.capture_fps); try std.testing.expectEqual(25_000_000, settings.capture_bit_rate); try std.testing.expectEqual(45, settings.replay_seconds); + try std.testing.expectEqual(536_870_912, settings.replay_max_bytes); try std.testing.expect(settings.start_replay_buffer_on_startup); try std.testing.expect(!settings.restore_capture_source_on_startup); try std.testing.expectEqualStrings("/tmp/spacecap-output", settings.video_output_directory.?.bytes); @@ -264,6 +269,7 @@ test "UserSettings - save" { .capture_fps = 30, .capture_bit_rate = 8_000_000, .replay_seconds = 12, + .replay_max_bytes = 256 * 1024 * 1024, .start_replay_buffer_on_startup = true, .restore_capture_source_on_startup = false, .video_output_directory = try String.from(allocator, "/tmp/spacecap-recordings"), @@ -279,6 +285,7 @@ test "UserSettings - save" { try std.testing.expectEqual(30, loaded.capture_fps); try std.testing.expectEqual(8_000_000, loaded.capture_bit_rate); try std.testing.expectEqual(12, loaded.replay_seconds); + try std.testing.expectEqual(256 * 1024 * 1024, loaded.replay_max_bytes); try std.testing.expect(loaded.start_replay_buffer_on_startup); try std.testing.expect(!loaded.restore_capture_source_on_startup); try std.testing.expectEqualStrings("/tmp/spacecap-recordings", loaded.video_output_directory.?.bytes); @@ -292,6 +299,7 @@ test "UserSettings - clone" { .capture_fps = 60, .capture_bit_rate = 10_000_000, .replay_seconds = 30, + .replay_max_bytes = 128 * 1024 * 1024, .video_output_directory = try String.from(allocator, "/tmp/original"), }; defer original.deinit(allocator); @@ -307,15 +315,18 @@ test "UserSettings - clone" { try cloned.set_video_output_directory(try String.from(allocator, "/tmp/cloned")); cloned.capture_fps = 120; + cloned.replay_max_bytes = 512 * 1024 * 1024; try cloned.update_audio_device_settings(allocator, "device-1", false, 2.0); try cloned.update_audio_device_settings(allocator, "device-2", true, 1.0); try std.testing.expectEqual(60, original.capture_fps); + try std.testing.expectEqual(128 * 1024 * 1024, original.replay_max_bytes); try std.testing.expectEqualStrings("/tmp/original", original.video_output_directory.?.bytes); try TestUtil.expect_audio_device_settings(original, "device-1", true, 0.75); try std.testing.expect(original.audio_devices.map.get("device-2") == null); try std.testing.expectEqual(120, cloned.capture_fps); + try std.testing.expectEqual(512 * 1024 * 1024, cloned.replay_max_bytes); try std.testing.expectEqualStrings("/tmp/cloned", cloned.video_output_directory.?.bytes); try TestUtil.expect_audio_device_settings(cloned, "device-1", false, 2.0); try TestUtil.expect_audio_device_settings(cloned, "device-2", true, 1.0); diff --git a/src/store/user_settings_store.zig b/src/store/user_settings_store.zig index e0a67f7..3954cd6 100644 --- a/src/store/user_settings_store.zig +++ b/src/store/user_settings_store.zig @@ -14,6 +14,7 @@ pub const Message = union(enum) { set_capture_fps: u32, set_capture_bit_rate: u64, set_replay_seconds: u32, + set_replay_max_bytes: u64, set_restore_capture_source_on_startup: bool, set_start_replay_buffer_on_startup: bool, set_video_output_directory: *ActionPayload(struct { @@ -51,6 +52,7 @@ pub const Message = union(enum) { .set_capture_fps = .{ effect_sync_settings_to_file, CaptureStore.effect_update_video_capture_fps }, .set_capture_bit_rate = .{effect_sync_settings_to_file}, .set_replay_seconds = .{ effect_sync_settings_to_file, CaptureStore.effect_sync_replay_buffer_with_user_settings }, + .set_replay_max_bytes = .{ effect_sync_settings_to_file, CaptureStore.effect_sync_replay_buffer_max_bytes }, .set_restore_capture_source_on_startup = .{effect_sync_settings_to_file}, .set_start_replay_buffer_on_startup = .{effect_sync_settings_to_file}, .select_output_directory = .{effect_select_output_directory}, @@ -94,6 +96,9 @@ pub fn update(allocator: Allocator, msg: Store.Message, state: *Store.State) !vo .set_replay_seconds => |payload| { state.user_settings.user_settings.replay_seconds = payload; }, + .set_replay_max_bytes => |payload| { + state.user_settings.user_settings.replay_max_bytes = payload; + }, .set_restore_capture_source_on_startup => |payload| { state.user_settings.user_settings.restore_capture_source_on_startup = payload; }, diff --git a/src/store/video_session.zig b/src/store/video_session.zig index cfa3e4c..c0d0bdc 100644 --- a/src/store/video_session.zig +++ b/src/store/video_session.zig @@ -6,7 +6,6 @@ const VideoCapture = @import("../capture/video/video_capture.zig").VideoCapture; const Mutex = @import("../mutex.zig").Mutex; const VideoReplayBuffer = @import("../video/video_replay_buffer.zig").VideoReplayBuffer; const Vulkan = @import("../vulkan/vulkan.zig").Vulkan; -const Util = @import("../util.zig"); const BufferedChan = @import("../channel.zig").BufferedChan; const ChanError = @import("../channel.zig").ChanError; const Store = @import("../store/store.zig").Store; @@ -64,6 +63,8 @@ pub const VideoSession = struct { // Init/deinit of record_data_queue must also be behind this lock. video_record_mutex: std.Io.Mutex = .init, video_replay_buffer: Mutex(?*VideoReplayBuffer), + /// Increments every frame (even when not recording/replay buffer is not going). + frame_count: u64 = 0, // When recording to disk this channel will not be null. There is // a separate thread that pulls data off this queue and writes to disk. // We do this so that we don't slow the main capture thread by disk IO. @@ -155,7 +156,13 @@ pub const VideoSession = struct { try self.video_capture.stop(); } - pub fn start_replay_buffer(self: *Self, fps: u32, capture_bit_rate: u64, replay_seconds: u32) !void { + pub fn start_replay_buffer( + self: *Self, + fps: u32, + capture_bit_rate: u64, + replay_seconds: u32, + replay_max_bytes: u64, + ) !void { self.video_record_mutex.lockUncancelable(self.io); defer self.video_record_mutex.unlock(self.io); @@ -171,6 +178,7 @@ pub const VideoSession = struct { video_locked.set(try VideoReplayBuffer.init( self.allocator, replay_seconds, + replay_max_bytes, self.vulkan.video_encoder.?.bit_stream_header.items, )); } @@ -311,6 +319,7 @@ pub const VideoSession = struct { vulkan_image_buffer.as_ptr().in_use.store(false, .release); vulkan_image_buffer.deinit(); } + self.frame_count += 1; var image_slc = [_]vk.Image{vulkan_image_buffer.as_ptr().image}; var image_view_slc = [_]vk.ImageView{vulkan_image_buffer.as_ptr().image_view}; @@ -397,9 +406,13 @@ pub const VideoSession = struct { } if (video_replay_buffer) |_video_replay_buffer| { + // Only sync roughly every half second. + if (self.frame_count % @max(1, video_encoder.fps / 2) == 0) { + self.store.dispatch(.{ .capture = .sync_replay_buffers }); + } self.store.dispatch(.{ .capture = .{ - .update_replay_buffer_size = .{ + .update_replay_buffer_metrics = .{ .video = .{ .bytes = _video_replay_buffer.size, .start_time = _video_replay_buffer.get_start_time(), @@ -446,7 +459,7 @@ pub const VideoSession = struct { } } - pub fn take_and_swap_replay_buffer(self: *Self, replay_seconds: u32) !?*VideoReplayBuffer { + pub fn take_and_swap_replay_buffer(self: *Self, replay_seconds: u32, replay_max_bytes: u64) !?*VideoReplayBuffer { self.video_record_mutex.lockUncancelable(self.io); defer self.video_record_mutex.unlock(self.io); @@ -465,17 +478,26 @@ pub const VideoSession = struct { video_replay_buffer_locked.set(try .init( self.allocator, replay_seconds, + replay_max_bytes, video_encoder.bit_stream_header.items, )); return video_replay_buffer; } - pub fn set_replay_buffer_seconds(self: *Self, replay_seconds: u32) void { + pub fn set_replay_buffer_values(self: *Self, args: struct { + replay_seconds: ?u32 = null, + replay_max_bytes: ?u64 = null, + }) void { var replay_buffer_locked = self.video_replay_buffer.lock(); defer replay_buffer_locked.unlock(); if (replay_buffer_locked.unwrap()) |replay_buffer| { - replay_buffer.set_replay_seconds(replay_seconds); + if (args.replay_seconds) |replay_seconds| { + replay_buffer.set_replay_seconds(replay_seconds); + } + if (args.replay_max_bytes) |replay_max_bytes| { + replay_buffer.set_max_bytes(replay_max_bytes); + } } } }; diff --git a/src/ui/draw_left_column.zig b/src/ui/draw_left_column.zig index b366356..4fae958 100644 --- a/src/ui/draw_left_column.zig +++ b/src/ui/draw_left_column.zig @@ -13,6 +13,7 @@ const CAPTURE_BIT_RATE_KBPS_MIN: i32 = 100; const CAPTURE_BIT_RATE_KBPS_MAX: i32 = 1_000_000; const REPLAY_SECONDS_MIN: i32 = 1; const REPLAY_SECONDS_MAX: i32 = 60 * 60 * 24; +const BYTES_PER_MB: u64 = 1024 * 1024; const VIDEO_OUTPUT_DIRECTORY_MAX_BYTES = std.fs.max_path_bytes; const VIDEO_OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH: f32 = 34; @@ -23,6 +24,7 @@ const VIDEO_OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH: f32 = 34; var capture_fps_local: ?i32 = null; var capture_bit_rate_local: ?i32 = null; var replay_seconds_local: ?i32 = null; +var replay_max_memory_mb_local: ?i32 = null; var fg_fps_local: ?i32 = null; var bg_fps_local: ?i32 = null; var video_output_directory_local: ?[VIDEO_OUTPUT_DIRECTORY_MAX_BYTES:0]u8 = null; @@ -143,6 +145,10 @@ fn draw_capture_settings(allocator: std.mem.Allocator, store: *Store, state: *St const current_capture_fps: i32 = @intCast(settings.capture_fps); const current_capture_bit_rate: i32 = @intCast(settings.capture_bit_rate / CAPTURE_BIT_RATE_BPS_PER_KBPS); const current_replay_seconds: i32 = @intCast(settings.replay_seconds); + const current_replay_max_memory_mb: i32 = @intCast(if (settings.replay_max_bytes == 0) + 0 + else + std.math.divCeil(u64, settings.replay_max_bytes, BYTES_PER_MB) catch |err| @panic(@errorName(err))); var restore_capture_source_on_startup = settings.restore_capture_source_on_startup; var start_replay_buffer_on_startup = settings.start_replay_buffer_on_startup; @@ -210,7 +216,9 @@ fn draw_capture_settings(allocator: std.mem.Allocator, store: *Store, state: *St // Replay buffer length { - c.ImGui_Text("Replay buffer length"); + c.ImGui_PushTextWrapPos(0); + c.ImGui_Text("Replay buffer length (s)"); + c.ImGui_PopTextWrapPos(); c.ImGui_SameLine(); imgui_util.help_marker("Length of video and audio stored in memory (seconds)"); imgui_util.set_next_item_width_fill(); @@ -243,6 +251,35 @@ fn draw_capture_settings(allocator: std.mem.Allocator, store: *Store, state: *St c.ImGui_PopTextWrapPos(); } + // Replay buffer memory limit + { + c.ImGui_PushTextWrapPos(0); + c.ImGui_Text("Replay video memory limit (MB)"); + c.ImGui_PopTextWrapPos(); + c.ImGui_SameLine(); + imgui_util.help_marker("Maximum size of the replay buffer (Megabytes). 0 disables the memory limit."); + imgui_util.set_next_item_width_fill(); + var replay_max_memory_mb = replay_max_memory_mb_local orelse current_replay_max_memory_mb; + if (c.ImGui_InputIntEx( + "##replay_buffer_max_memory", + &replay_max_memory_mb, + 5, + 25, + c.ImGuiInputTextFlags_None, + )) { + replay_max_memory_mb = std.math.clamp(replay_max_memory_mb, 0, std.math.maxInt(i32)); + replay_max_memory_mb_local = replay_max_memory_mb; + } + if (c.ImGui_IsItemDeactivatedAfterEdit() and replay_max_memory_mb != current_replay_max_memory_mb) { + store.dispatch(.{ .user_settings = .{ + .set_replay_max_bytes = @as(u64, @intCast(replay_max_memory_mb)) * BYTES_PER_MB, + } }); + replay_max_memory_mb_local = null; + } else if (!c.ImGui_IsItemActive()) { + replay_max_memory_mb_local = null; + } + } + c.ImGui_PushTextWrapPos(0); c.ImGui_Text("Restore capture source on startup"); c.ImGui_PopTextWrapPos(); diff --git a/src/video/video_replay_buffer.zig b/src/video/video_replay_buffer.zig index eeca8b1..533c4d5 100644 --- a/src/video/video_replay_buffer.zig +++ b/src/video/video_replay_buffer.zig @@ -30,12 +30,14 @@ pub const VideoReplayBuffer = struct { len: u32 = 0, header_frame: std.ArrayList(u8), replay_seconds: u32, + replay_max_bytes: u64, /// replay_seconds - total time in seconds to retain /// Caller owns memory pub fn init( allocator: std.mem.Allocator, replay_seconds: u32, + replay_max_bytes: u64, header_frame_data: []const u8, ) !*Self { const frames = std.DoublyLinkedList{}; @@ -52,6 +54,7 @@ pub const VideoReplayBuffer = struct { .size = 0, .header_frame = header_frame, .replay_seconds = replay_seconds, + .replay_max_bytes = replay_max_bytes, }; return self; @@ -70,10 +73,11 @@ pub const VideoReplayBuffer = struct { // Copies the data into the replay buffer. pub fn add_frame(self: *Self, data: []const u8, frame_time_ns: i128, is_idr: bool) !void { var data_list = try std.ArrayList(u8).initCapacity(self.allocator, data.len); + errdefer data_list.deinit(self.allocator); try data_list.appendSlice(self.allocator, data); var node = try self.allocator.create(VideoReplayBufferNode); - errdefer self.allocator.destroy(self); + errdefer self.allocator.destroy(node); node.* = .{ .data = .{ @@ -85,14 +89,19 @@ pub const VideoReplayBuffer = struct { }; self.frames.append(&node.node); - self.trim_expired_frames(); self.len += 1; self.size += data.len; + self.trim_frames(); } pub fn set_replay_seconds(self: *Self, replay_seconds: u32) void { self.replay_seconds = replay_seconds; - self.trim_expired_frames(); + self.trim_frames(); + } + + pub fn set_max_bytes(self: *Self, max_bytes: u64) void { + self.replay_max_bytes = max_bytes; + self.trim_frames(); } pub fn get_seconds(self: *const Self) u32 { @@ -122,12 +131,14 @@ pub const VideoReplayBuffer = struct { if (self.frames.popFirst()) |first| { const node: *VideoReplayBufferNode = @alignCast(@fieldParentPtr("node", first)); self.size -= node.data.data.items.len; - node.deinit(); self.len -= 1; + node.deinit(); } } - fn trim_expired_frames(self: *Self) void { + /// Trim the expired frames. Also discard frames if the size of the + /// replay buffer is larger than the max bytes (ignore if max bytes is 0). + fn trim_frames(self: *Self) void { const last = self.frames.last orelse return; const last_node: *VideoReplayBufferNode = @alignCast(@fieldParentPtr("node", last)); const oldest_ns = last_node.data.timestamp_ns - (@as(i128, @intCast(self.replay_seconds)) * std.time.ns_per_s); @@ -143,6 +154,14 @@ pub const VideoReplayBuffer = struct { break; } } + + if (self.replay_max_bytes == 0) { + return; + } + + while (self.size > self.replay_max_bytes and self.frames.first != null) { + self.remove_first_frame(); + } } /// Pop and return the first node. Caller owns the memory. @@ -185,7 +204,7 @@ pub const VideoReplayBuffer = struct { }; test "VideoReplayBuffer - addFrame - should add a frame with 3 bytes" { - var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 30, &.{}); + var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 30, 0, &.{}); defer replay_buffer.deinit(); try replay_buffer.add_frame(&[_]u8{ 1, 2, 3 }, 0, false); try std.testing.expectEqual(@as(u64, 3), replay_buffer.size); @@ -196,7 +215,7 @@ test "VideoReplayBuffer - addFrame - should trim frames outside replay window" { const replay_seconds = 2; const max_seconds_retained = replay_seconds; const max_frames = max_seconds_retained + 1; - var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, replay_seconds, &.{}); + var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, replay_seconds, 0, &.{}); defer replay_buffer.deinit(); for (0..10) |i| { @@ -212,8 +231,42 @@ test "VideoReplayBuffer - addFrame - should trim frames outside replay window" { try std.testing.expectEqual(max_seconds_retained, replay_buffer.get_seconds()); } +test "VideoReplayBuffer - addFrame - should trim frames over max bytes" { + var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 30, 5, &.{}); + defer replay_buffer.deinit(); + + try replay_buffer.add_frame(&[_]u8{ 1, 2, 3 }, 0, false); + try replay_buffer.add_frame(&[_]u8{ 4, 5 }, 1, false); + try std.testing.expectEqual(5, replay_buffer.size); + try std.testing.expectEqual(2, replay_buffer.len); + + try replay_buffer.add_frame(&[_]u8{ 6, 7 }, 2, false); + try std.testing.expectEqual(4, replay_buffer.size); + try std.testing.expectEqual(2, replay_buffer.len); + + const first = replay_buffer.frames.first orelse return error.ExpectedFirstFrame; + const first_frame: *VideoReplayBufferNode = @alignCast(@fieldParentPtr("node", first)); + try std.testing.expectEqual(1, first_frame.data.timestamp_ns); +} + +test "VideoReplayBuffer - setMaxBytes - should trim existing frames" { + var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 30, 0, &.{}); + defer replay_buffer.deinit(); + + try replay_buffer.add_frame(&[_]u8{ 1, 2, 3 }, 0, false); + try replay_buffer.add_frame(&[_]u8{ 4, 5, 6 }, 1, false); + + replay_buffer.set_max_bytes(3); + try std.testing.expectEqual(3, replay_buffer.size); + try std.testing.expectEqual(1, replay_buffer.len); + + const first = replay_buffer.frames.first orelse return error.ExpectedFirstFrame; + const first_frame: *VideoReplayBufferNode = @alignCast(@fieldParentPtr("node", first)); + try std.testing.expectEqual(1, first_frame.data.timestamp_ns); +} + test "VideoReplayBuffer - getReplayWindow - returns null when empty and first/last timestamps when populated" { - var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 10, &.{}); + var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 10, 0, &.{}); defer replay_buffer.deinit(); try std.testing.expect(replay_buffer.get_replay_window() == null); @@ -228,7 +281,7 @@ test "VideoReplayBuffer - getReplayWindow - returns null when empty and first/la } test "VideoReplayBuffer - ensureFirstFrameIsIdr - removes leading non-idr frames" { - var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 10, &.{}); + var replay_buffer = try VideoReplayBuffer.init(std.testing.allocator, 10, 0, &.{}); defer replay_buffer.deinit(); try replay_buffer.add_frame(&[_]u8{0x01}, 10, false);