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);