From b240fe0eb8be3e8a63719f13b7ef98d931a81264 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Tue, 30 Jun 2026 21:25:55 -0500 Subject: [PATCH] feat: screenshots - Refactor ffmpeg structure to keep it less coupled from other parts of the codebase. - Refactor some messages to not use ActionPayload. closes #7 --- .gitignore | 2 + README.md | 4 +- build.zig | 92 +++----- build/app_image.zig | 26 ++ build/ffmpeg_build.sh | 5 +- build/ffmpeg_build.zig | 70 +++--- flake.nix | 1 + src/args.zig | 3 +- src/audio/audio_replay_buffer.zig | 20 +- src/audio/audio_timeline.zig | 17 +- .../pipewire/vulkan_image_buffer_chan.zig | 1 + src/capture/video/video_capture.zig | 1 + src/exporter.zig | 132 ++++++----- src/{audio => ffmpeg}/audio_encoder.zig | 165 ++++++------- src/{ffmpeg.zig => ffmpeg/c.zig} | 6 +- src/ffmpeg/main.zig | 11 + src/{video => ffmpeg}/muxer.zig | 148 ++++++------ src/ffmpeg/png.zig | 53 +++++ src/global_shortcuts/global_shortcuts.zig | 2 + src/ipc/ipc.zig | 2 + src/ipc/linux/linux_ipc_server.zig | 3 + src/main.zig | 4 + src/store/README.md | 72 ++++++ src/store/action_payload.zig | 9 + src/store/audio_session.zig | 28 ++- src/store/capture_store.zig | 223 +++++++++++++----- src/store/global_shortcuts_store.zig | 17 +- src/store/store.zig | 8 +- src/store/user_settings.zig | 87 +++++-- src/store/user_settings_store.zig | 93 ++++---- src/store/video_session.zig | 84 ++++++- src/string.zig | 19 +- src/test.zig | 23 +- src/ui/draw_bottom_panel.zig | 34 +-- src/ui/draw_left_column.zig | 95 ++++++-- src/ui/tray.zig | 12 + src/util.zig | 148 ++++++++++-- src/video/vulkan_video_encoder.zig | 84 ++++--- src/vulkan/vulkan.zig | 146 +++++++++++- src/vulkan/vulkan_image_buffer.zig | 69 +++++- src/vulkan/vulkan_image_ring_buffer.zig | 15 +- 41 files changed, 1442 insertions(+), 592 deletions(-) create mode 100644 build/app_image.zig rename src/{audio => ffmpeg}/audio_encoder.zig (69%) rename src/{ffmpeg.zig => ffmpeg/c.zig} (74%) create mode 100644 src/ffmpeg/main.zig rename src/{video => ffmpeg}/muxer.zig (66%) create mode 100644 src/ffmpeg/png.zig diff --git a/.gitignore b/.gitignore index 0bb9730..dd0dc1b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ zig-pkg /*.bmp /*.mp4 /*.mov +/*.png +/*.jpg diff --git a/README.md b/README.md index 720d0ab..4a9660c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

Spacecap

-A hardware accelerated screen recording tool for Linux. _Still in development -(see features/roadmap below)_. +A hardware accelerated screen recording tool for Linux. _Still in early +development (see features/roadmap below)_. - Written in [Zig](https://ziglang.org/) (0.16.0). - Video encoding with Vulkan Video ([vulkan-zig](https://github.com/Snektron/vulkan-zig)). diff --git a/build.zig b/build.zig index 2396fe1..995f127 100644 --- a/build.zig +++ b/build.zig @@ -1,10 +1,14 @@ const std = @import("std"); const ffmpeg_build_util = @import("build/ffmpeg_build.zig"); const version = @import("build/version.zig"); +const build_linux_app_image = @import("./build/app_image.zig").build_linux_app_image; const EXE_NAME = "spacecap"; const PackageVersion = version.PackageVersion; +// TODO: There are still some issues with the Zig backend. Use LLMV for now... +const USE_LLVM = true; + fn compile_shader( allocator: std.mem.Allocator, b: *std.Build, @@ -76,12 +80,6 @@ fn add_shared_dependencies( .optimize = optimize, }).module("clap"); exe.root_module.addImport("clap", clap); - - const ffmpeg_build = switch (target.result.os.tag) { - .windows => ffmpeg_build_util.build_windows(b), - else => ffmpeg_build_util.build_linux(b), - }; - ffmpeg_build_util.link_libs(exe, ffmpeg_build); } fn add_linux_dependencies( @@ -112,8 +110,32 @@ fn add_linux_dependencies( // Vulkan is linked directly, because it is required that the // system has the libs installed. exe.root_module.linkSystemLibrary("vulkan", .{}); + + ffmpeg_build_util.build_linux(b, exe); +} + +fn add_windows_dependencies( + allocator: std.mem.Allocator, + b: *std.Build, + exe: *std.Build.Step.Compile, + _: std.Build.ResolvedTarget, + _: std.builtin.OptimizeMode, +) !void { + _ = allocator; + ffmpeg_build_util.build_windows(b, exe); + const vulkan_sdk_path_windows = b.graph.environ_map.get("VULKAN_SDK_PATH_WINDOWS").?; + exe.root_module.addLibraryPath(.{ .cwd_relative = vulkan_sdk_path_windows }); + + exe.root_module.linkSystemLibrary("vulkan-1", .{}); + + // All windows machines should be able to link to this by default + exe.root_module.linkSystemLibrary("gdi32", .{}); } +/// NOTE: This is not used anymore. We are statically linking everything we can +/// and it is not necessary. Keeping it around in case we need it for Windows +/// things. +/// /// Install a dynamic library in the /lib directory /// e.g. zig-out/linux/lib/SDL3.so /// @@ -183,29 +205,11 @@ fn build_windows( .name = EXE_NAME, .root_module = module, .version = package_version.semantic_version, - .use_llvm = true, + .use_llvm = USE_LLVM, }); - // TODO: seems like rpath is not working - exe.root_module.addRPath(b.path("./lib")); try add_shared_dependencies(allocator, b, exe, target, optimize); - - const vulkan_sdk_path_windows = b.graph.environ_map.get("VULKAN_SDK_PATH_WINDOWS").?; - exe.root_module.addLibraryPath(.{ .cwd_relative = vulkan_sdk_path_windows }); - - try install_and_link_system_library(.{ - .allocator = allocator, - .b = b, - .exe = exe, - .source_dir = vulkan_sdk_path_windows, - .lib_name = "vulkan-1", - .target = .windows, - }); - - // All windows machines should be able to link to this by default - exe.root_module.linkSystemLibrary("gdi32", .{}); - // Required for ffmpeg. - exe.root_module.linkSystemLibrary("bcrypt", .{}); + try add_windows_dependencies(allocator, b, exe, target, optimize); const install_step = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = .{ .custom = "windows" } }, @@ -234,9 +238,7 @@ fn build_linux( .name = EXE_NAME, .root_module = module, .version = package_version.semantic_version, - // TODO: There are currently some pointer alignment issues - // with pipewire using the Zig backend. Just stick to LLVM for now... - .use_llvm = true, + .use_llvm = USE_LLVM, }); if (!nix) { @@ -270,30 +272,7 @@ fn build_linux( return &install_step.step; } -fn build_linux_app_image( - b: *std.Build, - allocator: std.mem.Allocator, - linux_install_step: *std.Build.Step, -) *std.Build.Step { - const appimage_step = b.step("appimage", "Build Linux AppImage"); - - const buffer = b.build_root.handle.readFileAlloc( - b.graph.io, - "./build/build_app_image.sh", - allocator, - .limited(1024 * 1024), - ) catch unreachable; - defer allocator.free(buffer); - - const cmd = b.addSystemCommand(&.{ "bash", "-lc", buffer }); - - cmd.step.dependOn(linux_install_step); - appimage_step.dependOn(&cmd.step); - - return appimage_step; -} - -fn build_unit_tests_default( +fn build_unit_tests( allocator: std.mem.Allocator, b: *std.Build, target: std.Build.ResolvedTarget, @@ -301,7 +280,7 @@ fn build_unit_tests_default( options: *std.Build.Step.Options, ) !void { const unit_test_files = [_][]const u8{ - "./src/test.zig", + "./src/main.zig", }; const test_step = b.step("test", "Run unit tests"); @@ -317,8 +296,7 @@ fn build_unit_tests_default( const exe = b.addTest(.{ .root_module = module, .test_runner = .{ .path = b.path("./src/test_runner.zig"), .mode = .simple }, - // Keep test linking behavior aligned with Linux executable builds. - .use_llvm = true, + .use_llvm = USE_LLVM, }); try add_shared_dependencies(allocator, b, exe, target, optimize); @@ -407,7 +385,7 @@ pub fn build(b: *std.Build) !void { b.getInstallStep().dependOn(appimage_step); } - try build_unit_tests_default( + try build_unit_tests( allocator, b, linux_target, diff --git a/build/app_image.zig b/build/app_image.zig new file mode 100644 index 0000000..f73a367 --- /dev/null +++ b/build/app_image.zig @@ -0,0 +1,26 @@ +const std = @import("std"); + +pub fn build_linux_app_image( + b: *std.Build, + allocator: std.mem.Allocator, + linux_install_step: *std.Build.Step, +) *std.Build.Step { + const appimage_step = b.step("appimage", "Build Linux AppImage"); + + const buffer = b.build_root.handle.readFileAlloc( + b.graph.io, + "./build/build_app_image.sh", + allocator, + .limited(1024 * 1024), + ) catch |err| { + @panic(@errorName(err)); + }; + defer allocator.free(buffer); + + const cmd = b.addSystemCommand(&.{ "bash", "-lc", buffer }); + + cmd.step.dependOn(linux_install_step); + appimage_step.dependOn(&cmd.step); + + return appimage_step; +} diff --git a/build/ffmpeg_build.sh b/build/ffmpeg_build.sh index 3c735ec..06fcdd4 100755 --- a/build/ffmpeg_build.sh +++ b/build/ffmpeg_build.sh @@ -55,7 +55,10 @@ common_configure_flags=( target_configure_flags=() case "$target" in linux) - target_configure_flags=() + target_configure_flags=( + --enable-encoder=png + --enable-zlib + ) ;; windows) target_configure_flags=( diff --git a/build/ffmpeg_build.zig b/build/ffmpeg_build.zig index 9e089c0..3a88a32 100644 --- a/build/ffmpeg_build.zig +++ b/build/ffmpeg_build.zig @@ -1,19 +1,42 @@ const std = @import("std"); -pub const FfmpegBuild = struct { - step: *std.Build.Step, - /// Directory will contain the generated ffmpeg headers. - include_dir: std.Build.LazyPath, - /// Directory will contain the static libraries. - lib_dir: std.Build.LazyPath, -}; +pub fn build_linux( + b: *std.Build, + exe: *std.Build.Step.Compile, +) void { + const ffmpeg_build = build_for_target( + b, + exe, + "linux", + "ffmpeg-build", + "ffmpeg-install", + ); + link_libs(exe, ffmpeg_build.include_dir, ffmpeg_build.lib_dir); + exe.root_module.linkSystemLibrary("zlib", .{}); +} + +pub fn build_windows( + b: *std.Build, + exe: *std.Build.Step.Compile, +) void { + const ffmpeg_build = build_for_target( + b, + exe, + "windows", + "ffmpeg-build-windows", + "ffmpeg-install-windows", + ); + link_libs(exe, ffmpeg_build.include_dir, ffmpeg_build.lib_dir); + exe.root_module.linkSystemLibrary("bcrypt", .{}); +} fn build_for_target( b: *std.Build, + exe: *std.Build.Step.Compile, target: []const u8, build_dir_name: []const u8, install_dir_name: []const u8, -) FfmpegBuild { +) struct { include_dir: std.Build.LazyPath, lib_dir: std.Build.LazyPath } { const ffmpeg = b.dependency("ffmpeg", .{}); const build_ffmpeg_step = b.addSystemCommand(&.{"bash"}); build_ffmpeg_step.addFileArg(b.path("build/ffmpeg_build.sh")); @@ -23,36 +46,21 @@ fn build_for_target( build_ffmpeg_step.addArg(ffmpeg.path("").getPath(b)); build_ffmpeg_step.expectExitCode(0); build_ffmpeg_step.setName(build_dir_name); + exe.step.dependOn(&build_ffmpeg_step.step); return .{ - .step = &build_ffmpeg_step.step, .include_dir = ffmpeg_install_prefix.path(b, "include"), .lib_dir = ffmpeg_install_prefix.path(b, "lib"), }; } -pub fn build_linux(b: *std.Build) FfmpegBuild { - return build_for_target( - b, - "linux", - "ffmpeg-build", - "ffmpeg-install", - ); -} - -pub fn build_windows(b: *std.Build) FfmpegBuild { - return build_for_target( - b, - "windows", - "ffmpeg-build-windows", - "ffmpeg-install-windows", - ); -} - -pub fn link_libs(exe: *std.Build.Step.Compile, ffmpeg_build: FfmpegBuild) void { - exe.step.dependOn(ffmpeg_build.step); - exe.root_module.addIncludePath(ffmpeg_build.include_dir); - exe.root_module.addLibraryPath(ffmpeg_build.lib_dir); +fn link_libs( + exe: *std.Build.Step.Compile, + include_dir: std.Build.LazyPath, + lib_dir: std.Build.LazyPath, +) void { + exe.root_module.addIncludePath(include_dir); + exe.root_module.addLibraryPath(lib_dir); exe.root_module.linkSystemLibrary("avformat", .{ .preferred_link_mode = .static }); exe.root_module.linkSystemLibrary("avcodec", .{ .preferred_link_mode = .static }); exe.root_module.linkSystemLibrary("avdevice", .{ .preferred_link_mode = .static }); diff --git a/flake.nix b/flake.nix index 2d4c3d3..f839882 100644 --- a/flake.nix +++ b/flake.nix @@ -130,6 +130,7 @@ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.vulkan-loader pkgs.glib + pkgs.zlib # Required for linux tray icon. pkgs.gtk3 diff --git a/src/args.zig b/src/args.zig index e8ecef8..5ec29b3 100644 --- a/src/args.zig +++ b/src/args.zig @@ -16,7 +16,7 @@ const shared_params = ( const linux_params = ( \\-s, --send Send a command to a running Spacecap instance. - \\ Commands: save-replay, start-replay-buffer, stop-replay-buffer, toggle-replay-buffer, start-recording, stop-recording, toggle-recording + \\ Commands: save-replay, screenshot, start-replay-buffer, stop-replay-buffer, toggle-replay-buffer, start-recording, stop-recording, toggle-recording \\ ); @@ -28,6 +28,7 @@ const windows_params_parsed = clap.parseParamsComptime(shared_params ++ windows_ pub const SendCommand = enum { @"save-replay", + screenshot, @"start-replay-buffer", @"stop-replay-buffer", @"toggle-replay-buffer", diff --git a/src/audio/audio_replay_buffer.zig b/src/audio/audio_replay_buffer.zig index 2c39ef9..bcdf417 100644 --- a/src/audio/audio_replay_buffer.zig +++ b/src/audio/audio_replay_buffer.zig @@ -3,11 +3,11 @@ const Allocator = std.mem.Allocator; const LinkedListIterator = @import("../util.zig").LinkedListIterator; const AudioCaptureData = @import("../capture/audio/audio_capture_data.zig"); const AudioTimeline = @import("./audio_timeline.zig").AudioTimeline; -const EncodedAudioPacketNode = @import("./audio_encoder.zig").EncodedAudioPacketNode; -const deinitPacketList = @import("./audio_encoder.zig").deinit_packet_list; const SAMPLE_RATE = @import("../capture/audio/audio_capture.zig").SAMPLE_RATE; const CHANNELS = @import("../capture/audio/audio_capture.zig").CHANNELS; const Arc = @import("../arc.zig").Arc; +const ffmpeg = @import("../ffmpeg/main.zig"); +const AudioEncoder = ffmpeg.AudioEncoder; const log = std.log.scoped(.AudioReplayBuffer); const Self = @This(); @@ -45,7 +45,7 @@ pub fn init( pub fn deinit(self: *Self) void { defer self.allocator.destroy(self); - deinitPacketList(&self.packets); + AudioEncoder.deinit_packet_list(&self.packets); self.timeline.deinit(); } @@ -62,7 +62,7 @@ pub fn add_data(self: *Self, data: Arc(AudioCaptureData)) !void { try self.timeline.process_ready_timeline(false); var ready_packets = self.timeline.take_ready_packets(); - defer deinitPacketList(&ready_packets); + defer AudioEncoder.deinit_packet_list(&ready_packets); self.append_packets(&ready_packets); self.trim_packets(.{}); } @@ -72,7 +72,7 @@ pub fn finalize(self: *Self) !void { try self.timeline.finalize(); var ready_packets = self.timeline.take_ready_packets(); - defer deinitPacketList(&ready_packets); + defer AudioEncoder.deinit_packet_list(&ready_packets); self.append_packets(&ready_packets); self.trim_packets(.{}); } @@ -82,8 +82,8 @@ pub fn set_replay_seconds(self: *Self, replay_seconds: u32) void { self.trim_packets(.{}); } -pub fn packet_iterator(self: *Self) LinkedListIterator(EncodedAudioPacketNode) { - return LinkedListIterator(EncodedAudioPacketNode).init(&self.packets); +pub fn packet_iterator(self: *Self) LinkedListIterator(AudioEncoder.EncodedAudioPacketNode) { + return LinkedListIterator(AudioEncoder.EncodedAudioPacketNode).init(&self.packets); } pub fn has_packets(self: *Self) bool { @@ -92,7 +92,7 @@ pub fn has_packets(self: *Self) bool { fn append_packets(self: *Self, packets: *std.DoublyLinkedList) void { while (packets.popFirst()) |current| { - const packet_node: *EncodedAudioPacketNode = @fieldParentPtr("node", current); + const packet_node: *AudioEncoder.EncodedAudioPacketNode = @fieldParentPtr("node", current); self.len += 1; self.size += @intCast(packet_node.data.*.size); self.packets.append(current); @@ -101,7 +101,7 @@ fn append_packets(self: *Self, packets: *std.DoublyLinkedList) void { fn remove_first_packet(self: *Self) void { if (self.packets.popFirst()) |first| { - const packet_node: *EncodedAudioPacketNode = @fieldParentPtr("node", first); + const packet_node: *AudioEncoder.EncodedAudioPacketNode = @fieldParentPtr("node", first); self.size -= @intCast(packet_node.data.*.size); self.len -= 1; packet_node.deinit(); @@ -125,7 +125,7 @@ pub fn trim_packets(self: *Self, args: struct { } while (self.packets.first) |first| { - const packet_node: *EncodedAudioPacketNode = @fieldParentPtr("node", first); + const packet_node: *AudioEncoder.EncodedAudioPacketNode = @fieldParentPtr("node", first); const packet_end = packet_node.data.*.pts + packet_node.data.*.duration; if (packet_end <= oldest_sample.?) { self.remove_first_packet(); diff --git a/src/audio/audio_timeline.zig b/src/audio/audio_timeline.zig index 231d747..c194c8a 100644 --- a/src/audio/audio_timeline.zig +++ b/src/audio/audio_timeline.zig @@ -3,9 +3,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const AudioCaptureData = @import("../capture/audio/audio_capture_data.zig"); const AudioMixer = @import("./audio_mixer.zig").AudioMixer; -const AudioEncoder = @import("./audio_encoder.zig").AudioEncoder; -const deinitPacketList = @import("./audio_encoder.zig").deinit_packet_list; -const ffmpeg = @import("../ffmpeg.zig").ffmpeg; +const AudioEncoder = @import("../ffmpeg/main.zig").AudioEncoder; const Arc = @import("../arc.zig").Arc; /// Pending per-device PCM chunk that has not yet been fully mixed into the @@ -53,11 +51,6 @@ pub const SampleWindow = struct { end_sample: i64, }; -pub const CodecContextInfo = struct { - audio_codec_ctx: [*c]ffmpeg.AVCodecContext, - time_base: ffmpeg.AVRational, -}; - /// Mixes and encodes audio data. pub const AudioTimeline = struct { const Self = @This(); @@ -109,7 +102,7 @@ pub const AudioTimeline = struct { } self.device_map.deinit(); - deinitPacketList(&self.ready_packets); + AudioEncoder.deinit_packet_list(&self.ready_packets); self.encoder.deinit(self.allocator); } @@ -175,7 +168,7 @@ pub const AudioTimeline = struct { try self.process_ready_timeline(true); var flush_result = try self.encoder.flush(self.allocator); - errdefer deinitPacketList(&flush_result); + errdefer AudioEncoder.deinit_packet_list(&flush_result); self.append_ready_packets(&flush_result); } @@ -186,7 +179,7 @@ pub const AudioTimeline = struct { return packets; } - pub fn get_codec_context(self: *Self) CodecContextInfo { + pub fn get_codec_context(self: *Self) AudioEncoder.CodecContextInfo { return .{ .audio_codec_ctx = self.encoder.audio_codec_ctx, .time_base = self.encoder.audio_codec_ctx.*.time_base, @@ -231,7 +224,7 @@ pub const AudioTimeline = struct { var packets = try self.encoder.encode_chunk(self.allocator, self.encoded_until_sample, mixed_pcm.items); if (packets) |*owned_packets| { - errdefer deinitPacketList(owned_packets); + errdefer AudioEncoder.deinit_packet_list(owned_packets); self.append_ready_packets(owned_packets); } diff --git a/src/capture/video/linux/pipewire/vulkan_image_buffer_chan.zig b/src/capture/video/linux/pipewire/vulkan_image_buffer_chan.zig index 22c06ad..2ff70b5 100644 --- a/src/capture/video/linux/pipewire/vulkan_image_buffer_chan.zig +++ b/src/capture/video/linux/pipewire/vulkan_image_buffer_chan.zig @@ -31,6 +31,7 @@ pub const VulkanImageBufferChan = struct { /// Increment the buffer ref count, set to in use, then send on the channel. /// On send error, release the buffer and return the error. /// Takes ownership of vulkan_image_buffer. Clone before passing in. + /// NOTE: The Vulkan image must not be pending any fence/semaphore. pub fn send(self: *Self, vulkan_image_buffer: Arc(VulkanImageBuffer)) ChanError!void { defer vulkan_image_buffer.deinit(); errdefer vulkan_image_buffer.as_ptr().in_use.store(false, .release); diff --git a/src/capture/video/video_capture.zig b/src/capture/video/video_capture.zig index 785ff13..3071cf2 100644 --- a/src/capture/video/video_capture.zig +++ b/src/capture/video/video_capture.zig @@ -56,6 +56,7 @@ pub const VideoCapture = struct { return self.vtable.close_all_channels(self.ptr); } + /// NOTE: The Vulkan image must not be pending any fence/semaphore. pub fn wait_for_frame(self: *Self) ChanError!Arc(VulkanImageBuffer) { return self.vtable.wait_for_frame(self.ptr); } diff --git a/src/exporter.zig b/src/exporter.zig index f8440ff..af7c2ff 100644 --- a/src/exporter.zig +++ b/src/exporter.zig @@ -1,14 +1,14 @@ -//// Contains functions to export audio/video. -//// e.g. Export from replay buffers. +//! Contains functions to export audio, video, images, etc. const std = @import("std"); const VideoReplayBuffer = @import("./video/video_replay_buffer.zig").VideoReplayBuffer; const AudioReplayBuffer = @import("./audio/audio_replay_buffer.zig"); -const CodecContextInfo = @import("./audio/audio_timeline.zig").CodecContextInfo; const SampleWindow = @import("./audio/audio_timeline.zig").SampleWindow; -const ffmpeg = @import("./ffmpeg.zig").ffmpeg; -const checkErr = @import("./ffmpeg.zig").check_err; -const Muxer = @import("./video/muxer.zig").Muxer; +const Util = @import("util.zig"); +const ffmpeg = @import("./ffmpeg/main.zig"); +const Png = ffmpeg.Png; +const Muxer = ffmpeg.Muxer; +const CodecContextInfo = ffmpeg.AudioEncoder.CodecContextInfo; const log = std.log.scoped(.exporter); @@ -70,7 +70,7 @@ pub fn export_replay_buffers( if (audio_replay_buffer) |_audio_replay_buffer| { if (audio_sample_window) |sample_window| { - muxer.set_audio_sample_window(sample_window); + muxer.set_audio_sample_window(sample_window.start_sample, sample_window.end_sample); _ = try muxer.write_audio_packets(&_audio_replay_buffer.packets); } } @@ -78,68 +78,78 @@ pub fn export_replay_buffers( try muxer.finish(); } -/// Export only audio to file. -pub fn export_audio(allocator: std.mem.Allocator, sample_rate: u32, channels: u32, samples: []const f32) !void { - if (samples.len == 0) return error.NoAudioSamples; - - var format_context: *ffmpeg.AVFormatContext = undefined; - - const file_name = try std.fmt.allocPrintSentinel(allocator, "audio_{}.wav", .{std.time.nanoTimestamp()}, 0); - defer allocator.free(file_name); - - var ret = ffmpeg.avformat_alloc_output_context2(@ptrCast(&format_context), null, "wav", file_name); - try checkErr(ret); - - defer ffmpeg.avformat_free_context(format_context); +/// Encode raw BGRA image data and save to a file. Currently only supports PNG. +pub fn export_image_to_file( + allocator: std.mem.Allocator, + io: std.Io, + width: u32, + height: u32, + bgra: []const u8, + output_directory: []const u8, +) ![]u8 { + const expected_len: usize = width * height * 4; + if (bgra.len != expected_len) return error.InvalidScreenshotPixelData; + + const rgba = try allocator.alloc(u8, bgra.len); + defer allocator.free(rgba); + + // Convert to RGBA + var i: usize = 0; + while (i < rgba.len) : (i += 4) { + rgba[i] = bgra[i + 2]; + rgba[i + 1] = bgra[i + 1]; + rgba[i + 2] = bgra[i]; + rgba[i + 3] = 255; + } - const out_stream = ffmpeg.avformat_new_stream(format_context, null) orelse return error.FFmpegError; - const stream_idx = out_stream.*.index; + const encoded_data = try Png.encode(allocator, width, height, rgba); + defer allocator.free(encoded_data); - const codecpar = out_stream.*.codecpar; - codecpar.*.codec_id = ffmpeg.AV_CODEC_ID_PCM_F32LE; - codecpar.*.codec_type = ffmpeg.AVMEDIA_TYPE_AUDIO; - codecpar.*.format = ffmpeg.AV_SAMPLE_FMT_FLT; - codecpar.*.sample_rate = @intCast(sample_rate); - ffmpeg.av_channel_layout_default(&codecpar.*.ch_layout, @intCast(channels)); - codecpar.*.bits_per_coded_sample = 32; - codecpar.*.bits_per_raw_sample = 32; - codecpar.*.block_align = @intCast(channels * @sizeOf(f32)); - codecpar.*.bit_rate = @intCast(sample_rate * channels * 32); + try std.Io.Dir.cwd().createDirPath(io, output_directory); - out_stream.*.time_base = ffmpeg.AVRational{ .num = 1, .den = @intCast(sample_rate) }; + const file_name = try Util.format_file_name(allocator, io, .{ + .prefix = "screenshot", + .extension = "png", + }); + defer allocator.free(file_name); - if (format_context.oformat.*.flags & ffmpeg.AVFMT_NOFILE == 0) { - ret = ffmpeg.avio_open(&format_context.pb, file_name, ffmpeg.AVIO_FLAG_WRITE); - try checkErr(ret); - } - defer { - if (format_context.pb != null) { - _ = ffmpeg.avio_closep(&format_context.pb); - } - } + const file_path = try std.fs.path.join(allocator, &.{ output_directory, file_name }); + errdefer allocator.free(file_path); - ret = ffmpeg.avformat_write_header(format_context, null); - try checkErr(ret); + const file = try std.Io.Dir.cwd().createFile(io, file_path, .{ .exclusive = true }); + defer file.close(io); - var pkt = ffmpeg.av_packet_alloc() orelse return error.FFmpegError; - defer ffmpeg.av_packet_free(&pkt); + try file.writeStreamingAll(io, encoded_data); + log.info("[export_image_to_file] wrote {s}", .{file_path}); + return file_path; +} - const bytes = std.mem.sliceAsBytes(samples); - ret = ffmpeg.av_new_packet(pkt, @intCast(bytes.len)); - try checkErr(ret); - @memcpy(pkt.*.data[0..bytes.len], bytes); +test "Exporter - export_image_to_file writes to the output directory" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); - const frames: usize = if (channels > 0) samples.len / @as(usize, @intCast(channels)) else 0; - pkt.*.stream_index = stream_idx; - pkt.*.pts = 0; - pkt.*.dts = 0; - pkt.*.duration = @intCast(frames); - pkt.*.flags = 0; + var tmp_dir_path_buffer: [std.Io.Dir.max_path_bytes]u8 = undefined; + const tmp_dir_path_len = try tmp_dir.dir.realPathFile(io, ".", &tmp_dir_path_buffer); + const output_directory = try std.fs.path.join(allocator, &.{ tmp_dir_path_buffer[0..tmp_dir_path_len], "screenshots" }); + defer allocator.free(output_directory); - ret = ffmpeg.av_interleaved_write_frame(format_context, pkt); - ffmpeg.av_packet_unref(pkt); - try checkErr(ret); + const file_path = try export_image_to_file( + allocator, + io, + 1, + 1, + &.{ 0x11, 0x22, 0x33, 0xff }, + output_directory, + ); + defer allocator.free(file_path); - ret = ffmpeg.av_write_trailer(format_context); - try checkErr(ret); + try std.testing.expectEqualStrings(output_directory, std.fs.path.dirname(file_path).?); + const file = try std.Io.Dir.openFileAbsolute(io, file_path, .{}); + defer file.close(io); + try std.testing.expect((try file.stat(io)).size > 0); } + +// TODO: +test "Exporter - export_replay_buffers writes to the output directory" {} diff --git a/src/audio/audio_encoder.zig b/src/ffmpeg/audio_encoder.zig similarity index 69% rename from src/audio/audio_encoder.zig rename to src/ffmpeg/audio_encoder.zig index 8f7bb75..c580178 100644 --- a/src/audio/audio_encoder.zig +++ b/src/ffmpeg/audio_encoder.zig @@ -1,39 +1,46 @@ +//! The audio encoder only supports AAC as of now. + const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const ffmpeg = @import("../ffmpeg.zig").ffmpeg; -const checkErr = @import("../ffmpeg.zig").check_err; +const c = @import("./c.zig").c; +const check_err = @import("./c.zig").check_err; -// TODO: Make this a user setting. -const AUDIO_BIT_RATE: i64 = 128_000; +pub const AudioEncoder = struct { + const Self = @This(); -pub const EncodedAudioPacketNode = struct { - data: [*c]const ffmpeg.AVPacket, - node: std.DoublyLinkedList.Node = .{}, - allocator: Allocator, + // TODO: Make this a user setting. + const AUDIO_BIT_RATE: i64 = 128_000; - pub fn init(allocator: Allocator, packet: [*c]const ffmpeg.AVPacket) !*@This() { - const self = try allocator.create(@This()); - errdefer allocator.destroy(self); + pub const CodecContextInfo = struct { + audio_codec_ctx: [*c]c.AVCodecContext, + time_base: c.AVRational, + }; - self.* = .{ - .data = packet, - .allocator = allocator, - }; - return self; - } + pub const EncodedAudioPacketNode = struct { + data: [*c]const c.AVPacket, + node: std.DoublyLinkedList.Node = .{}, + allocator: Allocator, - pub fn deinit(self: *@This()) void { - defer self.allocator.destroy(self); - var packet: [*c]ffmpeg.AVPacket = @constCast(self.data); - ffmpeg.av_packet_free(&packet); - } -}; + pub fn init(allocator: Allocator, packet: [*c]const c.AVPacket) !*@This() { + const self = try allocator.create(@This()); + errdefer allocator.destroy(self); -pub const AudioEncoder = struct { - const Self = @This(); + self.* = .{ + .data = packet, + .allocator = allocator, + }; + return self; + } + + pub fn deinit(self: *@This()) void { + defer self.allocator.destroy(self); + var packet: [*c]c.AVPacket = @constCast(self.data); + c.av_packet_free(&packet); + } + }; - audio_codec_ctx: [*c]ffmpeg.AVCodecContext, + audio_codec_ctx: [*c]c.AVCodecContext, channels: u32, // A rolling buffer of raw audio waiting to be encoded. We only encode // once enough sample positions have accumulated. @@ -41,7 +48,7 @@ pub const AudioEncoder = struct { // Absolute sample position of the first sample in `pending_samples`. pending_start_sample: ?i64 = null, is_flushed: bool = false, - frame: *ffmpeg.AVFrame, + frame: *c.AVFrame, pub fn init( allocator: Allocator, @@ -50,56 +57,56 @@ pub const AudioEncoder = struct { ) !Self { assert(channels > 0); assert(sample_rate > 0); - const audio_codec = ffmpeg.avcodec_find_encoder(ffmpeg.AV_CODEC_ID_AAC) orelse return error.MissingAudioEncoder; - var audio_codec_ctx = ffmpeg.avcodec_alloc_context3(audio_codec) orelse return error.FFmpegError; - errdefer ffmpeg.avcodec_free_context(&audio_codec_ctx); + const audio_codec = c.avcodec_find_encoder(c.AV_CODEC_ID_AAC) orelse return error.MissingAudioEncoder; + var audio_codec_ctx = c.avcodec_alloc_context3(audio_codec) orelse return error.FFmpegError; + errdefer c.avcodec_free_context(&audio_codec_ctx); audio_codec_ctx.*.sample_rate = @intCast(sample_rate); - _ = ffmpeg.av_channel_layout_default(&audio_codec_ctx.*.ch_layout, @intCast(channels)); - audio_codec_ctx.*.time_base = ffmpeg.AVRational{ .num = 1, .den = @intCast(sample_rate) }; + _ = c.av_channel_layout_default(&audio_codec_ctx.*.ch_layout, @intCast(channels)); + audio_codec_ctx.*.time_base = c.AVRational{ .num = 1, .den = @intCast(sample_rate) }; audio_codec_ctx.*.bit_rate = AUDIO_BIT_RATE; // Prefer floating-point formats so the replay mixer can hand PCM to the // encoder without an extra sample conversion stage. - var chosen_fmt: ffmpeg.AVSampleFormat = ffmpeg.AV_SAMPLE_FMT_NONE; + var chosen_fmt: c.AVSampleFormat = c.AV_SAMPLE_FMT_NONE; if (audio_codec.*.sample_fmts != null) { var fmt_ptr = audio_codec.*.sample_fmts; - while (fmt_ptr[0] != ffmpeg.AV_SAMPLE_FMT_NONE) : (fmt_ptr += 1) { - if (fmt_ptr[0] == ffmpeg.AV_SAMPLE_FMT_FLTP) { - chosen_fmt = ffmpeg.AV_SAMPLE_FMT_FLTP; + while (fmt_ptr[0] != c.AV_SAMPLE_FMT_NONE) : (fmt_ptr += 1) { + if (fmt_ptr[0] == c.AV_SAMPLE_FMT_FLTP) { + chosen_fmt = c.AV_SAMPLE_FMT_FLTP; break; } - if (fmt_ptr[0] == ffmpeg.AV_SAMPLE_FMT_FLT and chosen_fmt == ffmpeg.AV_SAMPLE_FMT_NONE) { - chosen_fmt = ffmpeg.AV_SAMPLE_FMT_FLT; + if (fmt_ptr[0] == c.AV_SAMPLE_FMT_FLT and chosen_fmt == c.AV_SAMPLE_FMT_NONE) { + chosen_fmt = c.AV_SAMPLE_FMT_FLT; } } } else { - chosen_fmt = ffmpeg.AV_SAMPLE_FMT_FLTP; + chosen_fmt = c.AV_SAMPLE_FMT_FLTP; } - if (chosen_fmt == ffmpeg.AV_SAMPLE_FMT_NONE) { + if (chosen_fmt == c.AV_SAMPLE_FMT_NONE) { return error.UnsupportedAudioSampleFormat; } audio_codec_ctx.*.sample_fmt = chosen_fmt; - audio_codec_ctx.*.profile = ffmpeg.AV_PROFILE_AAC_LOW; + audio_codec_ctx.*.profile = c.AV_PROFILE_AAC_LOW; - _ = ffmpeg.av_opt_set(audio_codec_ctx.*.priv_data, "aac_coder", "fast", 0); - _ = ffmpeg.av_opt_set_int(audio_codec_ctx.*.priv_data, "aac_pns", 0, 0); + _ = c.av_opt_set(audio_codec_ctx.*.priv_data, "aac_coder", "fast", 0); + _ = c.av_opt_set_int(audio_codec_ctx.*.priv_data, "aac_pns", 0, 0); - var ret = ffmpeg.avcodec_open2(audio_codec_ctx, audio_codec, null); - try checkErr(ret); + var ret = c.avcodec_open2(audio_codec_ctx, audio_codec, null); + try check_err(ret); // We can reuse the same frame for the whole session. - var frame = ffmpeg.av_frame_alloc() orelse return error.FFmpegErrorAvFrameAlloc; - errdefer ffmpeg.av_frame_free(@ptrCast(&frame)); + var frame = c.av_frame_alloc() orelse return error.FFmpegErrorAvFrameAlloc; + errdefer c.av_frame_free(@ptrCast(&frame)); frame.*.format = audio_codec_ctx.*.sample_fmt; frame.*.ch_layout = audio_codec_ctx.*.ch_layout; frame.*.sample_rate = audio_codec_ctx.*.sample_rate; assert(audio_codec_ctx.*.frame_size > 0); frame.*.nb_samples = @intCast(audio_codec_ctx.*.frame_size); - ret = ffmpeg.av_frame_get_buffer(frame, 0); - try checkErr(ret); + ret = c.av_frame_get_buffer(frame, 0); + try check_err(ret); return .{ .audio_codec_ctx = audio_codec_ctx, @@ -110,9 +117,9 @@ pub const AudioEncoder = struct { } pub fn deinit(self: *Self, allocator: Allocator) void { - ffmpeg.av_frame_free(@ptrCast(&self.frame)); + c.av_frame_free(@ptrCast(&self.frame)); self.pending_samples.deinit(allocator); - ffmpeg.avcodec_free_context(&self.audio_codec_ctx); + c.avcodec_free_context(&self.audio_codec_ctx); } /// Encode a chunk of contiguous audio that begins at `start_sample`. @@ -202,8 +209,8 @@ pub const AudioEncoder = struct { errdefer deinit_packet_list(&audio_packets); self.is_flushed = true; - const ret = ffmpeg.avcodec_send_frame(self.audio_codec_ctx, null); - try checkErr(ret); + const ret = c.avcodec_send_frame(self.audio_codec_ctx, null); + try check_err(ret); try self.collect_ready_packets(allocator, &audio_packets); return audio_packets; } @@ -212,17 +219,17 @@ pub const AudioEncoder = struct { self: *Self, allocator: Allocator, audio_packets: *std.DoublyLinkedList, - frame: [*c]ffmpeg.AVFrame, + frame: [*c]c.AVFrame, start_sample: i64, codec_samples_per_packet: usize, submitted_samples: usize, ) !void { const source_pcm = self.pending_samples.items[0 .. submitted_samples * self.channels]; - var ret = ffmpeg.av_frame_make_writable(frame); - try checkErr(ret); + var ret = c.av_frame_make_writable(frame); + try check_err(ret); - if (self.audio_codec_ctx.*.sample_fmt == ffmpeg.AV_SAMPLE_FMT_FLTP) { + if (self.audio_codec_ctx.*.sample_fmt == c.AV_SAMPLE_FMT_FLTP) { // Planar float expects one channel per FFmpeg plane. var ch: usize = 0; while (ch < self.channels) : (ch += 1) { @@ -235,7 +242,7 @@ pub const AudioEncoder = struct { dst[i] = source_pcm[i * self.channels + ch]; } } - } else if (self.audio_codec_ctx.*.sample_fmt == ffmpeg.AV_SAMPLE_FMT_FLT) { + } else if (self.audio_codec_ctx.*.sample_fmt == c.AV_SAMPLE_FMT_FLT) { // Interleaved float stores all channels in the first plane. const dst: [*]f32 = @ptrCast(@alignCast(frame[0].data[0])); if (submitted_samples < codec_samples_per_packet) { @@ -249,8 +256,8 @@ pub const AudioEncoder = struct { frame.*.nb_samples = @intCast(submitted_samples); frame.*.pts = start_sample; - ret = ffmpeg.avcodec_send_frame(self.audio_codec_ctx, frame); - try checkErr(ret); + ret = c.avcodec_send_frame(self.audio_codec_ctx, frame); + try check_err(ret); // A single submitted frame can produce zero, one, or multiple packets // depending on encoder delay, so always drain after each send. try self.collect_ready_packets(allocator, audio_packets); @@ -261,30 +268,30 @@ pub const AudioEncoder = struct { allocator: Allocator, audio_packets: *std.DoublyLinkedList, ) !void { - var audio_pkt = ffmpeg.av_packet_alloc() orelse return error.FFmpegError; - defer ffmpeg.av_packet_free(&audio_pkt); + var audio_pkt = c.av_packet_alloc() orelse return error.FFmpegError; + defer c.av_packet_free(&audio_pkt); while (true) { - const ret = ffmpeg.avcodec_receive_packet(self.audio_codec_ctx, audio_pkt); - if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN) or ret == ffmpeg.AVERROR_EOF) { + const ret = c.avcodec_receive_packet(self.audio_codec_ctx, audio_pkt); + if (ret == c.AVERROR(c.EAGAIN) or ret == c.AVERROR_EOF) { break; } - try checkErr(ret); - var owned_pkt = ffmpeg.av_packet_alloc() orelse return error.FFmpegError; - errdefer ffmpeg.av_packet_free(&owned_pkt); - ffmpeg.av_packet_move_ref(owned_pkt, audio_pkt); + try check_err(ret); + var owned_pkt = c.av_packet_alloc() orelse return error.FFmpegError; + errdefer c.av_packet_free(&owned_pkt); + c.av_packet_move_ref(owned_pkt, audio_pkt); const node = try EncodedAudioPacketNode.init(allocator, owned_pkt); audio_packets.append(&node.node); } } -}; -pub fn deinit_packet_list(packets: *std.DoublyLinkedList) void { - while (packets.popFirst()) |node| { - const packet_node: *EncodedAudioPacketNode = @alignCast(@fieldParentPtr("node", node)); - packet_node.deinit(); + pub fn deinit_packet_list(packets: *std.DoublyLinkedList) void { + while (packets.popFirst()) |node| { + const packet_node: *AudioEncoder.EncodedAudioPacketNode = @alignCast(@fieldParentPtr("node", node)); + packet_node.deinit(); + } } -} +}; test "AudioEncoder - encode_chunk rejects non-contiguous sample input" { const allocator = std.testing.allocator; @@ -294,7 +301,7 @@ test "AudioEncoder - encode_chunk rejects non-contiguous sample input" { const pcm = [_]f32{ 0.0, 0.0, 0.0, 0.0 }; var first_result = (try encoder.encode_chunk(allocator, 0, &pcm)).?; - defer deinit_packet_list(&first_result); + defer AudioEncoder.deinit_packet_list(&first_result); try std.testing.expect(first_result.first == null); try std.testing.expectError(error.NonContiguousAudioPts, encoder.encode_chunk(allocator, 3, &pcm)); } @@ -320,7 +327,7 @@ test "AudioEncoder - encode_chunk plus flush produces encoded audio packets" { } var all_packets: std.DoublyLinkedList = .{}; - defer deinit_packet_list(&all_packets); + defer AudioEncoder.deinit_packet_list(&all_packets); var encoded_packets = (try encoder.encode_chunk(allocator, start_sample, pcm)).?; while (encoded_packets.popFirst()) |node| { @@ -337,10 +344,10 @@ test "AudioEncoder - encode_chunk plus flush produces encoded audio packets" { var node = all_packets.first; var previous_pts: ?i64 = null; while (node) |current| : (node = current.next) { - const packet_node: *EncodedAudioPacketNode = @fieldParentPtr("node", current); + const packet_node: *AudioEncoder.EncodedAudioPacketNode = @fieldParentPtr("node", current); try std.testing.expect(packet_node.data.*.size > 0); - try std.testing.expect(packet_node.data.*.pts != ffmpeg.AV_NOPTS_VALUE); - try std.testing.expect(packet_node.data.*.dts != ffmpeg.AV_NOPTS_VALUE); + try std.testing.expect(packet_node.data.*.pts != c.AV_NOPTS_VALUE); + try std.testing.expect(packet_node.data.*.dts != c.AV_NOPTS_VALUE); try std.testing.expect(packet_node.data.*.duration > 0); if (previous_pts) |prev| { try std.testing.expect(packet_node.data.*.pts >= prev); diff --git a/src/ffmpeg.zig b/src/ffmpeg/c.zig similarity index 74% rename from src/ffmpeg.zig rename to src/ffmpeg/c.zig index 2f1b202..27d0ea2 100644 --- a/src/ffmpeg.zig +++ b/src/ffmpeg/c.zig @@ -2,8 +2,10 @@ const std = @import("std"); const log = std.log.scoped(.ffmpeg); -pub const ffmpeg = @cImport({ +pub const c = @cImport({ @cInclude("libavutil/opt.h"); + @cInclude("libavutil/frame.h"); + @cInclude("libavutil/pixfmt.h"); @cInclude("libavformat/avformat.h"); @cInclude("libavcodec/avcodec.h"); }); @@ -12,7 +14,7 @@ pub fn check_err(ret: c_int) !void { if (ret < 0) { var errbuf = std.mem.zeroes([64]u8); const errbuf_p: [*c]u8 = @ptrCast(&errbuf); - _ = ffmpeg.av_strerror(ret, errbuf_p, errbuf.len); + _ = c.av_strerror(ret, errbuf_p, errbuf.len); log.err("FFmpeg error ({any}): {s}", .{ ret, errbuf_p }); return error.FFmpegError; } diff --git a/src/ffmpeg/main.zig b/src/ffmpeg/main.zig new file mode 100644 index 0000000..0c52126 --- /dev/null +++ b/src/ffmpeg/main.zig @@ -0,0 +1,11 @@ +const std = @import("std"); + +pub const c = @import("./c.zig").c; +pub const check_err = @import("./c.zig").check_err; +pub const Png = @import("./png.zig").Png; +pub const Muxer = @import("./muxer.zig").Muxer; +pub const AudioEncoder = @import("./audio_encoder.zig").AudioEncoder; + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/video/muxer.zig b/src/ffmpeg/muxer.zig similarity index 66% rename from src/video/muxer.zig rename to src/ffmpeg/muxer.zig index e947520..34d953d 100644 --- a/src/video/muxer.zig +++ b/src/ffmpeg/muxer.zig @@ -1,13 +1,11 @@ const std = @import("std"); const assert = std.debug.assert; - const Allocator = std.mem.Allocator; -const CodecContextInfo = @import("../audio/audio_timeline.zig").CodecContextInfo; -const SampleWindow = @import("../audio/audio_timeline.zig").SampleWindow; -const EncodedAudioPacketNode = @import("../audio/audio_encoder.zig").EncodedAudioPacketNode; -const LinkedListIterator = @import("../util.zig").LinkedListIterator; -const ffmpeg = @import("../ffmpeg.zig").ffmpeg; -const checkErr = @import("../ffmpeg.zig").check_err; +const Util = @import("../util.zig"); +const LinkedListIterator = Util.LinkedListIterator; +const c = @import("./c.zig").c; +const check_err = @import("./c.zig").check_err; +const AudioEncoder = @import("./audio_encoder.zig").AudioEncoder; pub const Muxer = struct { const Self = @This(); @@ -34,10 +32,10 @@ pub const Muxer = struct { allocator: Allocator, io: std.Io, fps: u32, - format_context: *ffmpeg.AVFormatContext, + format_context: *c.AVFormatContext, file_name: [:0]u8, - video_stream: *ffmpeg.AVStream, - audio_stream: ?*ffmpeg.AVStream, + video_stream: *c.AVStream, + audio_stream: ?*c.AVStream, first_video_time_ns: ?i128 = null, audio_start_sample: ?i64 = null, audio_end_sample: ?i64 = null, @@ -51,37 +49,37 @@ pub const Muxer = struct { io: std.Io, file_name_prefix: []const u8, header_frame: []const u8, - audio_codec_context: ?CodecContextInfo, + audio_codec_context: ?AudioEncoder.CodecContextInfo, width: u32, height: u32, fps: u32, output_directory: []const u8, ) !Self { - var format_context: *ffmpeg.AVFormatContext = undefined; + var format_context: *c.AVFormatContext = undefined; try std.Io.Dir.cwd().createDirPath(io, output_directory); const file_name = try get_output_file_name(allocator, io, file_name_prefix, output_directory); errdefer allocator.free(file_name); - var ret = ffmpeg.avformat_alloc_output_context2(@ptrCast(&format_context), null, "mp4", file_name); - try checkErr(ret); + var ret = c.avformat_alloc_output_context2(@ptrCast(&format_context), null, "mp4", file_name); + try check_err(ret); errdefer { if (format_context.pb != null) { - _ = ffmpeg.avio_closep(&format_context.pb); + _ = c.avio_closep(&format_context.pb); } - ffmpeg.avformat_free_context(format_context); + c.avformat_free_context(format_context); } // Configure the H264 video stream as passthrough of the encoded bitstream. - const video_stream = ffmpeg.avformat_new_stream(format_context, null) orelse return error.FFmpegError; + const video_stream = c.avformat_new_stream(format_context, null) orelse return error.FFmpegError; const video_codecpar = video_stream.*.codecpar; - video_codecpar.*.codec_id = ffmpeg.AV_CODEC_ID_H264; - video_codecpar.*.codec_type = ffmpeg.AVMEDIA_TYPE_VIDEO; + video_codecpar.*.codec_id = c.AV_CODEC_ID_H264; + video_codecpar.*.codec_type = c.AVMEDIA_TYPE_VIDEO; video_codecpar.*.width = @intCast(width); video_codecpar.*.height = @intCast(height); // ffmpeg frees this memory when it's done so we need to copy it. - const extradata: [*c]u8 = @ptrCast(ffmpeg.av_malloc(header_frame.len)); + const extradata: [*c]u8 = @ptrCast(c.av_malloc(header_frame.len)); if (extradata == null) { return error.OutOfMemory; } @@ -91,26 +89,26 @@ pub const Muxer = struct { video_codecpar.*.extradata_size = @intCast(header_frame.len); // Convert nanosecond capture timestamps to a muxer-friendly video time base. - video_stream.*.time_base = ffmpeg.AVRational{ .num = 1, .den = 90_000 }; - video_stream.*.avg_frame_rate = ffmpeg.AVRational{ .num = @intCast(fps), .den = 1 }; - video_stream.*.r_frame_rate = ffmpeg.AVRational{ .num = @intCast(fps), .den = 1 }; + video_stream.*.time_base = c.AVRational{ .num = 1, .den = 90_000 }; + video_stream.*.avg_frame_rate = c.AVRational{ .num = @intCast(fps), .den = 1 }; + video_stream.*.r_frame_rate = c.AVRational{ .num = @intCast(fps), .den = 1 }; - var audio_stream: ?*ffmpeg.AVStream = null; + var audio_stream: ?*c.AVStream = null; if (audio_codec_context) |codec_context| { - const stream = ffmpeg.avformat_new_stream(format_context, null) orelse return error.FFmpegError; - try checkErr(ffmpeg.avcodec_parameters_from_context(stream.*.codecpar, codec_context.audio_codec_ctx)); + const stream = c.avformat_new_stream(format_context, null) orelse return error.FFmpegError; + try check_err(c.avcodec_parameters_from_context(stream.*.codecpar, codec_context.audio_codec_ctx)); stream.*.time_base = codec_context.time_base; audio_stream = stream; } - if (format_context.oformat.*.flags & ffmpeg.AVFMT_NOFILE == 0) { - ret = ffmpeg.avio_open(&format_context.pb, file_name, ffmpeg.AVIO_FLAG_WRITE); - try checkErr(ret); + if (format_context.oformat.*.flags & c.AVFMT_NOFILE == 0) { + ret = c.avio_open(&format_context.pb, file_name, c.AVIO_FLAG_WRITE); + try check_err(ret); } // Write container headers once streams are configured. - ret = ffmpeg.avformat_write_header(format_context, null); - try checkErr(ret); + ret = c.avformat_write_header(format_context, null); + try check_err(ret); return .{ .allocator = allocator, @@ -128,15 +126,15 @@ pub const Muxer = struct { pending.deinit(); } if (self.format_context.pb != null) { - _ = ffmpeg.avio_closep(&self.format_context.pb); + _ = c.avio_closep(&self.format_context.pb); } - ffmpeg.avformat_free_context(self.format_context); + c.avformat_free_context(self.format_context); self.allocator.free(self.file_name); } fn write_video_packet_data( self: *Self, - video_pkt: [*c]ffmpeg.AVPacket, + video_pkt: [*c]c.AVPacket, data: []const u8, is_idr: bool, pts: i64, @@ -149,28 +147,28 @@ pub const Muxer = struct { video_pkt.*.dts = pts; video_pkt.*.duration = duration; if (is_idr) { - video_pkt.*.flags |= ffmpeg.AV_PKT_FLAG_KEY; + video_pkt.*.flags |= c.AV_PKT_FLAG_KEY; } else { - video_pkt.*.flags &= ~ffmpeg.AV_PKT_FLAG_KEY; + video_pkt.*.flags &= ~c.AV_PKT_FLAG_KEY; } - const ret = ffmpeg.av_interleaved_write_frame(self.format_context, video_pkt); - ffmpeg.av_packet_unref(video_pkt); - try checkErr(ret); + const ret = c.av_interleaved_write_frame(self.format_context, video_pkt); + c.av_packet_unref(video_pkt); + try check_err(ret); } - fn write_audio_packet(self: *Self, pkt: [*c]ffmpeg.AVPacket, packet_node: *EncodedAudioPacketNode, start_sample: i64) !void { + fn write_audio_packet(self: *Self, pkt: [*c]c.AVPacket, packet_node: *AudioEncoder.EncodedAudioPacketNode, start_sample: i64) !void { assert(self.audio_stream != null); - try checkErr(ffmpeg.av_packet_ref(pkt, @constCast(packet_node.data))); + try check_err(c.av_packet_ref(pkt, @constCast(packet_node.data))); pkt.*.stream_index = self.audio_stream.?.*.index; pkt.*.pts = packet_node.data.*.pts - start_sample; pkt.*.dts = packet_node.data.*.dts - start_sample; pkt.*.duration = packet_node.data.*.duration; pkt.*.flags = packet_node.data.*.flags; - const ret = ffmpeg.av_interleaved_write_frame(self.format_context, pkt); - ffmpeg.av_packet_unref(pkt); - try checkErr(ret); + const ret = c.av_interleaved_write_frame(self.format_context, pkt); + c.av_packet_unref(pkt); + try check_err(ret); } pub fn video_start_time_ns(self: *const Self) ?i128 { @@ -187,9 +185,9 @@ pub const Muxer = struct { } } - pub fn set_audio_sample_window(self: *Self, sample_window: SampleWindow) void { - self.audio_start_sample = sample_window.start_sample; - self.audio_end_sample = sample_window.end_sample; + pub fn set_audio_sample_window(self: *Self, start: i64, end: i64) void { + self.audio_start_sample = start; + self.audio_end_sample = end; } /// NOTE: Takes ownership of data. @@ -206,9 +204,9 @@ pub const Muxer = struct { self.first_video_time_ns = frame_time_ns; } - const ns_time_base = ffmpeg.AVRational{ .num = 1, .den = 1_000_000_000 }; + const ns_time_base = c.AVRational{ .num = 1, .den = 1_000_000_000 }; const frame_duration_pts = if (self.fps > 0) - @max(ffmpeg.av_rescale_q(1, .{ .num = 1, .den = @intCast(self.fps) }, self.video_stream.time_base), 1) + @max(c.av_rescale_q(1, .{ .num = 1, .den = @intCast(self.fps) }, self.video_stream.time_base), 1) else 0; const jitter_tolerance_pts = if (self.fps > 0) @@ -217,7 +215,7 @@ pub const Muxer = struct { 0; const pts_ns = frame_time_ns - self.first_video_time_ns.?; - const raw_current_pts: i64 = ffmpeg.av_rescale_q(@intCast(pts_ns), ns_time_base, self.video_stream.time_base); + const raw_current_pts: i64 = c.av_rescale_q(@intCast(pts_ns), ns_time_base, self.video_stream.time_base); const current_pts = apply_jitter_correction_to_pts( raw_current_pts, if (self.pending_video != null) self.previous_pts else null, @@ -228,8 +226,8 @@ pub const Muxer = struct { if (self.pending_video) |*pending| { const duration = if (current_pts > self.previous_pts) current_pts - self.previous_pts else 0; const safe_duration = if (duration > max_mux_duration) max_mux_duration else duration; - var video_pkt = ffmpeg.av_packet_alloc() orelse return error.FFmpegError; - defer ffmpeg.av_packet_free(&video_pkt); + var video_pkt = c.av_packet_alloc() orelse return error.FFmpegError; + defer c.av_packet_free(&video_pkt); try self.write_video_packet_data(video_pkt, pending.data, pending.is_idr, self.previous_pts, safe_duration); self.last_delta = safe_duration; pending.deinit(); @@ -249,10 +247,10 @@ pub const Muxer = struct { const start_sample = self.audio_start_sample orelse return 0; - var pkt = ffmpeg.av_packet_alloc() orelse return error.FFmpegError; - defer ffmpeg.av_packet_free(&pkt); + var pkt = c.av_packet_alloc() orelse return error.FFmpegError; + defer c.av_packet_free(&pkt); - var iter = LinkedListIterator(EncodedAudioPacketNode).init(packets); + var iter = LinkedListIterator(AudioEncoder.EncodedAudioPacketNode).init(packets); while (iter.next()) |packet_node| { const packet_start = packet_node.data.*.pts; const packet_end = packet_start + packet_node.data.*.duration; @@ -268,8 +266,8 @@ pub const Muxer = struct { pub fn flush_video(self: *Self) !void { if (self.pending_video) |*pending| { - var video_pkt = ffmpeg.av_packet_alloc() orelse return error.FFmpegError; - defer ffmpeg.av_packet_free(&video_pkt); + var video_pkt = c.av_packet_alloc() orelse return error.FFmpegError; + defer c.av_packet_free(&video_pkt); try self.write_video_packet_data(video_pkt, pending.data, pending.is_idr, self.previous_pts, self.last_delta); pending.deinit(); self.pending_video = null; @@ -283,8 +281,8 @@ pub const Muxer = struct { fn write_trailer(self: *Self) !void { if (self.wrote_trailer) return; - const ret = ffmpeg.av_write_trailer(self.format_context); - try checkErr(ret); + const ret = c.av_write_trailer(self.format_context); + try check_err(ret); self.wrote_trailer = true; } @@ -307,22 +305,24 @@ pub const Muxer = struct { } return current_pts; } -}; -fn get_output_file_name( - allocator: Allocator, - io: std.Io, - file_name_prefix: []const u8, - output_directory: []const u8, -) ![:0]u8 { - const timestamp_ns = std.Io.Clock.real.now(io).nanoseconds; - const base_name = try std.fmt.allocPrint(allocator, "{s}_{}.mp4", .{ file_name_prefix, timestamp_ns }); - defer allocator.free(base_name); - - const path = try std.fs.path.join(allocator, &.{ output_directory, base_name }); - defer allocator.free(path); - return allocator.dupeZ(u8, path); -} + fn get_output_file_name( + allocator: Allocator, + io: std.Io, + file_name_prefix: []const u8, + output_directory: []const u8, + ) ![:0]u8 { + const base_name = try Util.format_file_name(allocator, io, .{ + .prefix = file_name_prefix, + .extension = "mp4", + }); + defer allocator.free(base_name); + + const path = try std.fs.path.join(allocator, &.{ output_directory, base_name }); + defer allocator.free(path); + return allocator.dupeZ(u8, path); + } +}; test "Muxer - apply_jitter_correction_to_pts snaps small jitter to expected cadence" { const expected = 3_000; diff --git a/src/ffmpeg/png.zig b/src/ffmpeg/png.zig new file mode 100644 index 0000000..e4100f6 --- /dev/null +++ b/src/ffmpeg/png.zig @@ -0,0 +1,53 @@ +const std = @import("std"); +const c = @import("./c.zig").c; +const check_err = @import("./c.zig").check_err; + +pub const Png = struct { + pub fn encode( + allocator: std.mem.Allocator, + width: u32, + height: u32, + rgba: []const u8, + ) ![]u8 { + const codec = c.avcodec_find_encoder(c.AV_CODEC_ID_PNG) orelse return error.MissingPngEncoder; + var codec_ctx = c.avcodec_alloc_context3(codec) orelse return error.FFmpegError; + defer c.avcodec_free_context(&codec_ctx); + + codec_ctx.*.width = @intCast(width); + codec_ctx.*.height = @intCast(height); + codec_ctx.*.pix_fmt = c.AV_PIX_FMT_RGBA; // Only rgba is supported by png. + codec_ctx.*.time_base = c.AVRational{ .num = 1, .den = 1 }; + + try check_err(c.avcodec_open2(codec_ctx, codec, null)); + + var frame = c.av_frame_alloc() orelse return error.FFmpegError; + defer c.av_frame_free(&frame); + + frame.*.format = codec_ctx.*.pix_fmt; + frame.*.width = codec_ctx.*.width; + frame.*.height = codec_ctx.*.height; + try check_err(c.av_frame_get_buffer(frame, 1)); + try check_err(c.av_frame_make_writable(frame)); + + const row_bytes: usize = @intCast(width * 4); + const linesize: usize = @intCast(frame[0].linesize[0]); + for (0..height) |row| { + const src_start = row * row_bytes; + const dst_start = row * linesize; + @memcpy(frame[0].data[0][dst_start .. dst_start + row_bytes], rgba[src_start .. src_start + row_bytes]); + } + + var pkt = c.av_packet_alloc() orelse return error.FFmpegError; + defer c.av_packet_free(&pkt); + + try check_err(c.avcodec_send_frame(codec_ctx, frame)); + + const ret = c.avcodec_receive_packet(codec_ctx, pkt); + if (ret == c.AVERROR(c.EAGAIN) or ret == c.AVERROR_EOF) { + return error.ScreenshotEncoderDidNotProducePacket; + } + try check_err(ret); + + return try allocator.dupe(u8, pkt.*.data[0..@intCast(pkt.*.size)]); + } +}; diff --git a/src/global_shortcuts/global_shortcuts.zig b/src/global_shortcuts/global_shortcuts.zig index 9ab4655..53db152 100644 --- a/src/global_shortcuts/global_shortcuts.zig +++ b/src/global_shortcuts/global_shortcuts.zig @@ -6,6 +6,7 @@ pub const GlobalShortcuts = struct { pub const Shortcut = enum { save_replay, + screenshot, start_replay_buffer, stop_replay_buffer, toggle_replay_buffer, @@ -22,6 +23,7 @@ pub const GlobalShortcuts = struct { pub fn display_name(self: Shortcut) []const u8 { return switch (self) { .save_replay => "Save Replay", + .screenshot => "Screenshot", .start_replay_buffer => "Start Replay Buffer", .stop_replay_buffer => "Stop Replay Buffer", .toggle_replay_buffer => "Toggle Replay Buffer", diff --git a/src/ipc/ipc.zig b/src/ipc/ipc.zig index 0860b02..dd22769 100644 --- a/src/ipc/ipc.zig +++ b/src/ipc/ipc.zig @@ -2,6 +2,7 @@ const args = @import("../args.zig"); pub const IpcCommand = enum { save_replay, + screenshot, start_replay_buffer, stop_replay_buffer, toggle_replay_buffer, @@ -12,6 +13,7 @@ pub const IpcCommand = enum { pub fn from_send_command(send_cmd: args.SendCommand) @This() { return switch (send_cmd) { .@"save-replay" => .save_replay, + .screenshot => .screenshot, .@"start-replay-buffer" => .start_replay_buffer, .@"stop-replay-buffer" => .stop_replay_buffer, .@"toggle-replay-buffer" => .toggle_replay_buffer, diff --git a/src/ipc/linux/linux_ipc_server.zig b/src/ipc/linux/linux_ipc_server.zig index fd639bf..25b4b8d 100644 --- a/src/ipc/linux/linux_ipc_server.zig +++ b/src/ipc/linux/linux_ipc_server.zig @@ -17,6 +17,7 @@ const RequestPayload = enum(u8) { start_recording = 5, stop_recording = 6, toggle_recording = 7, + screenshot = 8, pub fn value(self: @This()) u8 { return @intFromEnum(self); @@ -191,6 +192,7 @@ pub const IpcServer = struct { const request_payload: RequestPayload = switch (command) { .save_replay => .save_replay, + .screenshot => .screenshot, .start_replay_buffer => .start_replay_buffer, .stop_replay_buffer => .stop_replay_buffer, .toggle_replay_buffer => .toggle_replay_buffer, @@ -260,6 +262,7 @@ pub const IpcServer = struct { switch (command) { .wake => {}, .save_replay => store.dispatch(.{ .capture = .save_replay }), + .screenshot => store.dispatch(.{ .capture = .screenshot_request }), .start_replay_buffer => store.dispatch(.{ .capture = .start_replay_buffer }), .stop_replay_buffer => store.dispatch(.{ .capture = .stop_replay_buffer }), .toggle_replay_buffer => { diff --git a/src/main.zig b/src/main.zig index b181205..4463c66 100644 --- a/src/main.zig +++ b/src/main.zig @@ -141,3 +141,7 @@ fn gui_app(allocator: std.mem.Allocator, io: std.Io, parsed_args: ?args.Args) !v store_thread.join(); } + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/store/README.md b/src/store/README.md index 45fc78b..29c58ba 100644 --- a/src/store/README.md +++ b/src/store/README.md @@ -25,3 +25,75 @@ A child store is a domain-specific slice of the main store. It consists of: Child stores are registered in `Store.ChildStores`. The main store uses that list to execute updates and discover effect declarations. + +### Child Store Template + +```zig +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Store = @import("./store.zig").Store; + +pub const TemplateStore = struct { + const Self = @This(); + const log = std.log.scoped(.template_store); + + pub const Message = union(enum) { + do_some_thing: bool, + + pub const effects = .{ + .do_some_thing = .{effect_do_some_thing}, + }; + + pub fn deinit(self: *@This()) void { + switch (self.*) { + // Handle cleanup like this. + .do_some_thing => |*payload| {}, + // This is a compile time check to make sure that all messages + // get cleaned up even if they are still in the queue when the app + // closes. + inline else => |payload| { + if (@typeInfo(@TypeOf(payload)) == .@"struct" and + @hasDecl(@TypeOf(payload), "deinit")) + { + @compileError("Payload with 'deinit' must be explicitly handled."); + } + }, + } + } + }; + + pub const State = struct { + did_thing: bool = false, + }; + + pub fn init() !Self { + return .{}; + } + + pub fn deinit(_: *Self) void {} + + /// Called before the app closes. + pub fn exit(_: *Self) void {} + + /// Only update the state in here. + pub fn update(_: Allocator, msg: Store.Message, state: *Store.State) !void { + switch (msg) { + .template => |template_msg| { + switch (template_msg) { + .do_some_thing => |did_some_thing| { + state.template_store.did_some_thing = did_some_thing; + }, + else => {}, + } + }, + else => {}, + } + } + + /// Handle any side effects. This runs asynchronously. + pub fn effect_do_some_thing(store: *Store, payload: bool) !void { + _ = store; + _ = payload; + } +}; +``` diff --git a/src/store/action_payload.zig b/src/store/action_payload.zig index c82ff9c..56077c0 100644 --- a/src/store/action_payload.zig +++ b/src/store/action_payload.zig @@ -1,5 +1,14 @@ const std = @import("std"); +/// ---------------------------------------------------------------------------- +/// DEPRECATED +/// ---------------------------------------------------------------------------- +/// NOTE: This is no longer used anywhere and probably shouldn't be used. This +/// was a clever implemenation, but I think I prefer to minimize abstractions +/// and send messages (previously actions) as basic structs. Keeping it around +/// now for a reference of some comptime stuff. +/// +/// /// A helper type for actions that require heap allocations. /// T must define an 'init' function with (arena, args) parameters. /// All allocations in the underlying struct are cleaned up by the diff --git a/src/store/audio_session.zig b/src/store/audio_session.zig index 37ae4b5..fc39890 100644 --- a/src/store/audio_session.zig +++ b/src/store/audio_session.zig @@ -12,11 +12,12 @@ const SelectedAudioDevice = @import("../capture/audio/audio_capture.zig").Select const UserSettings = @import("../store/user_settings.zig").UserSettings; const Store = @import("../store/store.zig").Store; const ChanError = @import("../channel.zig").ChanError; -const deinitPacketList = @import("../audio/audio_encoder.zig").deinit_packet_list; -const CodecContextInfo = @import("../audio/audio_timeline.zig").CodecContextInfo; const AudioCaptureData = @import("../capture/audio/audio_capture_data.zig"); -const Muxer = @import("../video/muxer.zig").Muxer; const Arc = @import("../arc.zig").Arc; +const ffmpeg = @import("../ffmpeg/main.zig"); +const AudioEncoder = ffmpeg.AudioEncoder; +const CodecContextInfo = ffmpeg.AudioEncoder.CodecContextInfo; +const Muxer = ffmpeg.Muxer; /// AudioSession owns the main audio capture loop. All audio captured by the /// system goes through the AudioSession. @@ -126,13 +127,16 @@ pub const AudioSession = struct { continue; }; - self.store.dispatch(.{ .capture = .{ - .update_audio_device_level = try .init(self.allocator, .{ - .device_id = data.as_ptr().id, - .level = data.as_ptr().peak_level * data.as_ptr().gain, - .updated_at = std.Io.Timestamp.now(self.io, .awake).nanoseconds, - }), - } }); + self.store.dispatch(.{ + .capture = .{ + .update_audio_device_level = .{ + .allocator = self.allocator, + .device_id = try self.allocator.dupe(u8, data.as_ptr().id), + .level = data.as_ptr().peak_level * data.as_ptr().gain, + .updated_at = std.Io.Timestamp.now(self.io, .awake).nanoseconds, + }, + }, + }); try self.write_audio_packets_to_disk(data.clone()); @@ -203,7 +207,7 @@ pub const AudioSession = struct { try _timeline.finalize(); var packets = _timeline.take_ready_packets(); - defer deinitPacketList(&packets); + defer AudioEncoder.deinit_packet_list(&packets); if (muxer) |_muxer| { _ = try mux_audio_packets(&packets, _timeline, _muxer); } @@ -232,7 +236,7 @@ pub const AudioSession = struct { try timeline.process_ready_timeline(false); var packets = timeline.take_ready_packets(); - defer deinitPacketList(&packets); + defer AudioEncoder.deinit_packet_list(&packets); const audio_bytes = try mux_audio_packets(&packets, timeline, muxer); self.store.dispatch(.{ .capture = .{ .update_recording_bytes = .{ .audio = audio_bytes } } }); } diff --git a/src/store/capture_store.zig b/src/store/capture_store.zig index 2bd5dc5..bd79e3e 100644 --- a/src/store/capture_store.zig +++ b/src/store/capture_store.zig @@ -1,7 +1,6 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; const AudioSession = @import("./audio_session.zig").AudioSession; const VideoSession = @import("./video_session.zig").VideoSession; const AudioCapture = @import("../capture/audio/audio_capture.zig").AudioCapture; @@ -10,9 +9,8 @@ const Store = @import("./store.zig").Store; const AudioDevices = @import("./audio_session.zig").AudioDevices; const String = @import("../string.zig").String; const SelectedAudioDevice = @import("../capture/audio/audio_capture.zig").SelectedAudioDevice; -const ActionPayload = @import("./action_payload.zig").ActionPayload; const Vulkan = @import("../vulkan/vulkan.zig").Vulkan; -const Muxer = @import("../video/muxer.zig").Muxer; +const Muxer = @import("../ffmpeg/main.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; @@ -34,37 +32,36 @@ pub const CaptureStore = struct { muxer: Mutex(?Muxer), pub const Message = union(enum) { - const SetAudioDeviceGainPayload = *ActionPayload(struct { + const SetAudioDeviceGainPayload = struct { + allocator: Allocator, device_id: []const u8, gain: f32, - pub fn init(arena: *ArenaAllocator, args: struct { - device_id: []const u8, - gain: f32, - }) !@This() { - return .{ - .device_id = try arena.allocator().dupe(u8, args.device_id), - .gain = args.gain, - }; + pub fn deinit(self: *@This()) void { + self.allocator.free(self.device_id); } - }); - const UpdateAudioDeviceLevelPayload = *ActionPayload(struct { + }; + const UpdateAudioDeviceLevelPayload = struct { + allocator: Allocator, device_id: []const u8, level: f32, updated_at: i128, - pub fn init(arena: *ArenaAllocator, args: struct { - device_id: []const u8, - level: f32, - updated_at: i128, - }) !@This() { - return .{ - .device_id = try arena.allocator().dupe(u8, args.device_id), - .level = args.level, - .updated_at = args.updated_at, - }; + pub fn deinit(self: *@This()) void { + self.allocator.free(self.device_id); + } + }; + const ScreenshotReadyPayload = struct { + allocator: Allocator, + /// Raw bgra data. Must be heap allocated. Takes ownership. + data: []const u8, + width: u32, + height: u32, + + pub fn deinit(self: *@This()) void { + self.allocator.free(self.data); } - }); + }; load_system_audio_devices, load_system_audio_devices_success: AudioDevices, @@ -98,6 +95,11 @@ pub const CaptureStore = struct { save_replay_success, save_replay_fail, + // Screenshots + screenshot_request, + screenshot_response: ScreenshotReadyPayload, + screenshot_fail, + start_recording_to_disk, start_recording_to_disk_success: std.Io.Timestamp, start_recording_to_disk_fail, @@ -146,15 +148,24 @@ pub const CaptureStore = struct { .select_video_source_prepared = .{effect_select_video_source_prepared}, .sync_replay_buffers = .{effect_sync_replay_buffers}, .save_replay = .{effect_save_replay}, + .screenshot_request = .{effect_screenshot_request}, + .screenshot_response = .{effect_screenshot_response}, }; pub fn deinit(self: *@This()) void { switch (self.*) { .load_system_audio_devices_success => |*audio_devices| audio_devices.deinit(), .toggle_audio_device => |*device_id| device_id.deinit(), - .set_audio_device_gain => |payload| payload.deinit(), - .update_audio_device_level => |payload| payload.deinit(), - else => {}, + .set_audio_device_gain => |*payload| payload.deinit(), + .update_audio_device_level => |*payload| payload.deinit(), + .screenshot_response => |*payload| payload.deinit(), + inline else => |payload| { + if (@typeInfo(@TypeOf(payload)) == .@"struct" and + @hasDecl(@TypeOf(payload), "deinit")) + { + @compileError("Payload with 'deinit' must be explicitly handled."); + } + }, } } }; @@ -344,26 +355,25 @@ pub const CaptureStore = struct { break; } }, - .set_audio_device_gain => |payload| { + .set_audio_device_gain => |*payload| { for (state.capture.audio_devices.list.items) |*device| { - if (!std.mem.eql(u8, device.id, payload.payload.device_id)) continue; - device.gain = std.math.clamp(payload.payload.gain, AUDIO_GAIN_MIN, AUDIO_GAIN_MAX); + if (!std.mem.eql(u8, device.id, payload.device_id)) continue; + device.gain = std.math.clamp(payload.gain, AUDIO_GAIN_MIN, AUDIO_GAIN_MAX); break; } }, - .update_audio_device_level => |payload| { - defer payload.deinit(); - const data = payload.payload; - const clamped_level = std.math.clamp(data.level, 0.0, 1.0); + .update_audio_device_level => |*payload| { + defer @constCast(payload).deinit(); + const clamped_level = std.math.clamp(payload.level, 0.0, 1.0); for (state.capture.audio_devices.list.items) |*device| { - if (!std.mem.eql(u8, device.id, data.device_id)) { + if (!std.mem.eql(u8, device.id, payload.device_id)) { continue; } if (device.selected) { device.audio_level = clamped_level; - device.audio_level_updated_at = data.updated_at; + device.audio_level_updated_at = payload.updated_at; } else { device.audio_level = 0.0; device.audio_level_updated_at = null; @@ -474,11 +484,12 @@ pub const CaptureStore = struct { } store.dispatch(.{ .user_settings = .{ - .set_audio_device_settings = try .init(store.allocator, .{ - .device_id = device.id, + .set_audio_device_settings = .{ + .allocator = store.allocator, + .device_id = try store.allocator.dupe(u8, device.id), .selected = device.selected, .gain = device.gain, - }), + }, }, }); break; @@ -486,8 +497,8 @@ pub const CaptureStore = struct { } fn effect_set_audio_device_gain(store: *Store, _payload: Message.SetAudioDeviceGainPayload) !void { - defer _payload.deinit(); - const payload = _payload.payload; + const payload = @constCast(&_payload); + defer payload.deinit(); const state_locked = store.state.lock(); defer state_locked.unlock(); const state = state_locked.unwrap_ptr(); @@ -497,11 +508,12 @@ pub const CaptureStore = struct { device.gain = std.math.clamp(payload.gain, AUDIO_GAIN_MIN, AUDIO_GAIN_MAX); store.dispatch(.{ .user_settings = .{ - .set_audio_device_settings = try .init(store.allocator, .{ - .device_id = device.id, + .set_audio_device_settings = .{ + .allocator = store.allocator, + .device_id = try store.allocator.dupe(u8, device.id), .selected = device.selected, .gain = device.gain, - }), + }, }, }); break; @@ -766,6 +778,48 @@ pub const CaptureStore = struct { store.dispatch(.{ .capture = .save_replay_success }); } + fn effect_screenshot_request(store: *Store, _: anytype) !void { + const self = &store.capture_store; + + const video_capture_active = blk: { + const state_locked = store.state.lock(); + defer state_locked.unlock(); + break :blk state_locked.unwrap_ptr().capture.video_capture_active; + }; + + if (!video_capture_active) { + log.debug("[effect_take_screenshot] video capture is not active", .{}); + return; + } + + self.video_session.screenshot_request(); + log.debug("[effect_screenshot_request] screenshot requested", .{}); + } + + fn effect_screenshot_response(store: *Store, payload: Message.ScreenshotReadyPayload) !void { + defer @constCast(&payload).deinit(); + + var screenshot_output_directory = blk: { + const state_locked = store.state.lock(); + defer state_locked.unlock(); + const settings = state_locked.unwrap_ptr().user_settings.user_settings; + break :blk try settings.screenshot_output_directory.?.clone(store.allocator); + }; + defer screenshot_output_directory.deinit(); + + const file_path = try exporter.export_image_to_file( + store.allocator, + store.io, + payload.width, + payload.height, + payload.data, + screenshot_output_directory.bytes, + ); + defer store.allocator.free(file_path); + + log.debug("[effect_screenshot_response] screenshot saved: {s}", .{file_path}); + } + fn effect_select_video_source(store: *Store, video_capture_selection: VideoCaptureSelection) !void { var self = &store.capture_store; errdefer store.dispatch(.{ .capture = .{ .select_video_source_fail = video_capture_selection } }); @@ -871,7 +925,7 @@ test "CaptureStore - toggle_audio_device" { try std.testing.expect(state.capture.audio_devices.list.items[0].selected); - store.dispatch(.{ .capture = .{ .toggle_audio_device = try .from(std.testing.allocator, "test1") } }); + store.dispatch(.{ .capture = .{ .toggle_audio_device = try .init(std.testing.allocator, "test1") } }); store.run(.{ .once = true, .wait_for_effects = true }); store.run(.{ .once = true, .wait_for_effects = true }); @@ -884,6 +938,7 @@ test "CaptureStore - set_audio_device_gain" { defer test_store.deinit(); const store = test_store.store; const state = &store.state.private.value; + const allocator = std.testing.allocator; store.dispatch(.{ .capture = .load_system_audio_devices }); store.run(.{ .once = true, .wait_for_effects = true }); @@ -891,10 +946,11 @@ test "CaptureStore - set_audio_device_gain" { store.run(.{ .once = true, .wait_for_effects = true }); store.dispatch(.{ .capture = .{ - .set_audio_device_gain = try .init(std.testing.allocator, .{ - .device_id = "test1", + .set_audio_device_gain = .{ + .allocator = allocator, + .device_id = try allocator.dupe(u8, "test1"), .gain = 1.25, - }), + }, } }); store.run(.{ .once = true, .wait_for_effects = true }); store.run(.{ .once = true, .wait_for_effects = true }); @@ -904,10 +960,11 @@ test "CaptureStore - set_audio_device_gain" { // Should clamp to the maximum linear gain. store.dispatch(.{ .capture = .{ - .set_audio_device_gain = try .init(std.testing.allocator, .{ - .device_id = "test1", + .set_audio_device_gain = .{ + .allocator = allocator, + .device_id = try allocator.dupe(u8, "test1"), .gain = 5.0, - }), + }, } }); store.run(.{ .once = true, .wait_for_effects = true }); store.run(.{ .once = true, .wait_for_effects = true }); @@ -917,10 +974,11 @@ test "CaptureStore - set_audio_device_gain" { // Should clamp to the minimum linear gain. store.dispatch(.{ .capture = .{ - .set_audio_device_gain = try .init(std.testing.allocator, .{ - .device_id = "test1", + .set_audio_device_gain = .{ + .allocator = allocator, + .device_id = try allocator.dupe(u8, "test1"), .gain = 0, - }), + }, } }); store.run(.{ .once = true, .wait_for_effects = true }); store.run(.{ .once = true, .wait_for_effects = true }); @@ -935,19 +993,23 @@ test "CaptureStore - update_audio_device_level" { defer test_store.deinit(); const store = test_store.store; const state = &store.state.private.value; + const allocator = std.testing.allocator; store.dispatch(.{ .capture = .load_system_audio_devices }); store.run(.{ .once = true, .wait_for_effects = true }); store.run(.{ .once = true, .wait_for_effects = true }); store.run(.{ .once = true, .wait_for_effects = true }); - store.dispatch(.{ .capture = .{ - .update_audio_device_level = try .init(std.testing.allocator, .{ - .device_id = "test1", - .level = 0.42, - .updated_at = 123, - }), - } }); + store.dispatch(.{ + .capture = .{ + .update_audio_device_level = .{ + .allocator = allocator, + .device_id = try allocator.dupe(u8, "test1"), + .level = 0.42, + .updated_at = 123, + }, + }, + }); store.run(.{ .once = true, .wait_for_effects = true }); try std.testing.expectEqual(@as(f32, 0.42), state.capture.audio_devices.list.items[0].audio_level); @@ -955,11 +1017,12 @@ test "CaptureStore - update_audio_device_level" { state.capture.audio_devices.list.items[0].selected = false; store.dispatch(.{ .capture = .{ - .update_audio_device_level = try .init(std.testing.allocator, .{ - .device_id = "test1", + .update_audio_device_level = .{ + .allocator = allocator, + .device_id = try allocator.dupe(u8, "test1"), .level = 0.5, .updated_at = 456, - }), + }, } }); store.run(.{ .once = true, .wait_for_effects = true }); @@ -1088,6 +1151,36 @@ test "CaptureStore - sync_replay_buffers - should remove audio frames when the v } } +test "CaptureStore - screenshot_request - should skip when capture is inactive" { + const TestStore = @import("./store.zig").TestStore; + const test_store = try TestStore.init(std.testing.allocator); + defer test_store.deinit(); + const store = test_store.store; + + store.dispatch(.{ .capture = .screenshot_request }); + store.run(.{ .once = true, .wait_for_effects = true }); + + var requests_locked = store.capture_store.video_session.screenshot_requests.lock(); + defer requests_locked.unlock(); + try std.testing.expectEqual(0, requests_locked.unwrap()); +} + +test "CaptureStore - screenshot_request - should queue a screenshot when video capture is active" { + const TestStore = @import("./store.zig").TestStore; + const test_store = try TestStore.init(std.testing.allocator); + defer test_store.deinit(); + const store = test_store.store; + const state = &store.state.private.value; + + state.capture.video_capture_active = true; + store.dispatch(.{ .capture = .screenshot_request }); + store.run(.{ .once = true, .wait_for_effects = true }); + + var requests_locked = store.capture_store.video_session.screenshot_requests.lock(); + defer requests_locked.unlock(); + try std.testing.expectEqual(1, requests_locked.unwrap()); +} + // ---------------------------------------------------------------------------- // TODO: Still need to write tests for the rest of the message types. // ---------------------------------------------------------------------------- diff --git a/src/store/global_shortcuts_store.zig b/src/store/global_shortcuts_store.zig index 6cf076a..0d6627d 100644 --- a/src/store/global_shortcuts_store.zig +++ b/src/store/global_shortcuts_store.zig @@ -6,7 +6,19 @@ const GlobalShortcuts = @import("../global_shortcuts/global_shortcuts.zig").Glob pub const GlobalShortcutsStore = struct { const Self = @This(); const log = std.log.scoped(.global_shortcuts_store); - pub const Message = union(enum) {}; + pub const Message = union(enum) { + pub fn deinit(self: *@This()) void { + switch (self.*) { + inline else => |payload| { + if (@typeInfo(@TypeOf(payload)) == .@"struct" and + @hasDecl(@TypeOf(payload), "deinit")) + { + @compileError("Payload with 'deinit' must be explicitly handled."); + } + }, + } + } + }; pub const State = struct {}; allocator: Allocator, @@ -44,6 +56,9 @@ pub const GlobalShortcutsStore = struct { .save_replay => { self.store.dispatch(.{ .capture = .save_replay }); }, + .screenshot => { + self.store.dispatch(.{ .capture = .screenshot_request }); + }, .start_replay_buffer => { self.store.dispatch(.{ .capture = .start_replay_buffer }); }, diff --git a/src/store/store.zig b/src/store/store.zig index 440f14d..2d650b6 100644 --- a/src/store/store.zig +++ b/src/store/store.zig @@ -54,7 +54,13 @@ pub const Store = struct { switch (self.*) { .capture => |*capture_msg| capture_msg.deinit(), .user_settings => |*user_settings_msg| user_settings_msg.deinit(), - .global_shortcuts, .show_demo, .exit => {}, + inline else => |payload| { + if (@typeInfo(@TypeOf(payload)) == .@"struct" and + @hasDecl(@TypeOf(payload), "deinit")) + { + @compileError("Payload with 'deinit' must be explicitly handled."); + } + }, } } }; diff --git a/src/store/user_settings.zig b/src/store/user_settings.zig index fc87207..12bf085 100644 --- a/src/store/user_settings.zig +++ b/src/store/user_settings.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const String = @import("../string.zig").String; const util = @import("../util.zig"); @@ -10,6 +11,11 @@ pub const DEFAULT_REPLAY_MAX_BYTES: u64 = 1024 * 1024 * 1024; // 1GB /// NOTE: This MUST remain JSON serializable. pub const UserSettings = struct { + pub const OutputDirectory = enum { + screenshots, + videos, + }; + pub const AudioDeviceSettings = struct { id: []const u8, selected: bool = false, @@ -31,6 +37,7 @@ pub const UserSettings = struct { // required to find the directory. It must be set before // settings are used anywhere. video_output_directory: ?String = null, + screenshot_output_directory: ?String = null, audio_devices: std.json.ArrayHashMap(AudioDeviceSettings) = .{}, /// Read the settings json file if it exists, otherwise use defaults. @@ -41,7 +48,10 @@ pub const UserSettings = struct { if (err != error.FileNotFound) { log.err("[init] error loading settings file: {}", .{err}); } - return try default_settings(allocator, io); + const _default_settings = try default_settings(allocator, io); + assert(_default_settings.video_output_directory != null); + assert(_default_settings.screenshot_output_directory != null); + return _default_settings; }; } @@ -69,36 +79,50 @@ pub const UserSettings = struct { errdefer loaded.deinit(allocator); if (loaded.video_output_directory == null) { - const video_output_directory = try util.get_default_video_output_dir(allocator, io); - defer allocator.free(video_output_directory); + const video_output_directory = try util.get_default_output_dir(allocator, io, .videos); loaded.video_output_directory = try String.from(allocator, video_output_directory); } + if (loaded.screenshot_output_directory == null) { + const screenshot_output_directory = try util.get_default_output_dir(allocator, io, .pictures); + loaded.screenshot_output_directory = try String.from(allocator, screenshot_output_directory); + } return loaded; } pub fn deinit(self: *@This(), allocator: Allocator) void { - self.clear_video_output_directory(); + self.clear_output_directory(.videos); + self.clear_output_directory(.screenshots); self.clear_audio_device_settings(allocator); self.audio_devices.deinit(allocator); } fn default_settings(allocator: Allocator, io: std.Io) !UserSettings { - const video_output_directory = try util.get_default_video_output_dir(allocator, io); - defer allocator.free(video_output_directory); - return .{ - .video_output_directory = try String.from(allocator, video_output_directory), - }; + var settings: UserSettings = .{}; + errdefer settings.deinit(allocator); + settings.video_output_directory = try String.from( + allocator, + try util.get_default_output_dir(allocator, io, .videos), + ); + settings.screenshot_output_directory = try String.from( + allocator, + try util.get_default_output_dir(allocator, io, .pictures), + ); + return settings; } /// directory - Is owned by this method. - pub fn set_video_output_directory( + pub fn set_output_directory( self: *@This(), + output_directory: OutputDirectory, directory: ?String, ) !void { - self.clear_video_output_directory(); + self.clear_output_directory(output_directory); if (directory) |_directory| { - self.video_output_directory = _directory; + switch (output_directory) { + .videos => self.video_output_directory = _directory, + .screenshots => self.screenshot_output_directory = _directory, + } } } @@ -135,26 +159,39 @@ pub const UserSettings = struct { self.audio_devices.map.clearRetainingCapacity(); } - fn clear_video_output_directory(self: *@This()) void { - if (self.video_output_directory) |*video_output_directory| { - video_output_directory.deinit(); - self.video_output_directory = null; + fn clear_output_directory(self: *@This(), output_directory: OutputDirectory) void { + const directory = switch (output_directory) { + .videos => &self.video_output_directory, + .screenshots => &self.screenshot_output_directory, + }; + if (directory.*) |*value| { + value.deinit(); + directory.* = null; } } /// Deep copy user settings. - pub fn clone(self: @This(), allocator: Allocator) !@This() { + pub fn clone(self: @This(), allocator: Allocator) Allocator.Error!@This() { var settings_copy = self; settings_copy.video_output_directory = null; + settings_copy.screenshot_output_directory = null; settings_copy.audio_devices = .{}; errdefer settings_copy.deinit(allocator); - try settings_copy.set_video_output_directory( + try settings_copy.set_output_directory( + .videos, if (self.video_output_directory) |directory| try directory.clone(allocator) else null, ); + try settings_copy.set_output_directory( + .screenshots, + if (self.screenshot_output_directory) |directory| + try directory.clone(allocator) + else + null, + ); var iter = self.audio_devices.map.iterator(); while (iter.next()) |entry| { @@ -256,6 +293,7 @@ test "UserSettings - load" { 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); + try std.testing.expectEqualStrings(Test.TEST_APP_DATA_DIR.?, settings.screenshot_output_directory.?.bytes); try TestUtil.expect_audio_device_settings(settings, "microphone-1", true, 0.5); } @@ -272,7 +310,8 @@ test "UserSettings - save" { .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"), + .video_output_directory = try String.init(allocator, "/tmp/spacecap-recordings"), + .screenshot_output_directory = try String.init(allocator, "/tmp/spacecap-screenshots"), }; defer settings.deinit(allocator); try settings.update_audio_device_settings(allocator, "desktop-audio", true, 1.25); @@ -289,6 +328,7 @@ test "UserSettings - save" { 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); + try std.testing.expectEqualStrings("/tmp/spacecap-screenshots", loaded.screenshot_output_directory.?.bytes); try TestUtil.expect_audio_device_settings(loaded, "desktop-audio", true, 1.25); } @@ -300,7 +340,8 @@ test "UserSettings - clone" { .capture_bit_rate = 10_000_000, .replay_seconds = 30, .replay_max_bytes = 128 * 1024 * 1024, - .video_output_directory = try String.from(allocator, "/tmp/original"), + .video_output_directory = try String.init(allocator, "/tmp/original"), + .screenshot_output_directory = try String.init(allocator, "/tmp/original-screenshots"), }; defer original.deinit(allocator); try original.update_audio_device_settings(allocator, "device-1", true, 0.75); @@ -309,11 +350,13 @@ test "UserSettings - clone" { defer cloned.deinit(allocator); try std.testing.expect(original.video_output_directory.?.bytes.ptr != cloned.video_output_directory.?.bytes.ptr); + try std.testing.expect(original.screenshot_output_directory.?.bytes.ptr != cloned.screenshot_output_directory.?.bytes.ptr); const original_device_before = original.audio_devices.map.get("device-1").?; const cloned_device_before = cloned.audio_devices.map.get("device-1").?; try std.testing.expect(original_device_before.id.ptr != cloned_device_before.id.ptr); - try cloned.set_video_output_directory(try String.from(allocator, "/tmp/cloned")); + try cloned.set_output_directory(.videos, try String.init(allocator, "/tmp/cloned")); + try cloned.set_output_directory(.screenshots, try String.init(allocator, "/tmp/cloned-screenshots")); cloned.capture_fps = 120; cloned.replay_max_bytes = 512 * 1024 * 1024; try cloned.update_audio_device_settings(allocator, "device-1", false, 2.0); @@ -322,12 +365,14 @@ test "UserSettings - clone" { 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 std.testing.expectEqualStrings("/tmp/original-screenshots", original.screenshot_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 std.testing.expectEqualStrings("/tmp/cloned-screenshots", cloned.screenshot_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 3954cd6..79f1944 100644 --- a/src/store/user_settings_store.zig +++ b/src/store/user_settings_store.zig @@ -2,7 +2,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const Store = @import("./store.zig").Store; const UserSettings = @import("./user_settings.zig").UserSettings; -const ActionPayload = @import("./action_payload.zig").ActionPayload; const String = @import("../string.zig").String; const FilePickerError = @import("../file_picker/file_picker.zig").FilePickerError; const CaptureStore = @import("./capture_store.zig").CaptureStore; @@ -10,44 +9,37 @@ const CaptureStore = @import("./capture_store.zig").CaptureStore; const log = std.log.scoped(.user_settings_store); pub const Message = union(enum) { - select_output_directory, + const SetOutputDirectoryPayload = struct { + allocator: Allocator, + output_directory: UserSettings.OutputDirectory, + directory: []u8, + + pub fn deinit(self: *@This()) void { + self.allocator.free(self.directory); + } + }; + + select_output_directory: UserSettings.OutputDirectory, 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 { - video_output_directory: []u8, - - pub fn init( - arena: *std.heap.ArenaAllocator, - args: struct { video_output_directory: []const u8 }, - ) !@This() { - return .{ - .video_output_directory = try arena.allocator().dupe(u8, args.video_output_directory), - }; - } - }), - set_audio_device_settings: *ActionPayload(struct { + set_output_directory: SetOutputDirectoryPayload, + set_audio_device_settings: struct { + allocator: Allocator, device_id: []u8, selected: bool, gain: f32, - pub fn init( - arena: *std.heap.ArenaAllocator, - args: struct { device_id: []u8, selected: bool, gain: f32 }, - ) !@This() { - return .{ - .device_id = try arena.allocator().dupe(u8, args.device_id), - .selected = args.selected, - .gain = args.gain, - }; + pub fn deinit(self: *@This()) void { + self.allocator.free(self.device_id); } - }), + }, pub const effects = .{ - .set_video_output_directory = .{effect_sync_settings_to_file}, + .set_output_directory = .{effect_sync_settings_to_file}, .set_audio_device_settings = .{effect_sync_settings_to_file}, .set_capture_fps = .{ effect_sync_settings_to_file, CaptureStore.effect_update_video_capture_fps }, .set_capture_bit_rate = .{effect_sync_settings_to_file}, @@ -60,9 +52,15 @@ pub const Message = union(enum) { pub fn deinit(self: *@This()) void { switch (self.*) { - .set_video_output_directory => |payload| payload.deinit(), - .set_audio_device_settings => |payload| payload.deinit(), - else => {}, + .set_output_directory => |*payload| payload.deinit(), + .set_audio_device_settings => |*payload| payload.deinit(), + inline else => |payload| { + if (@typeInfo(@TypeOf(payload)) == .@"struct" and + @hasDecl(@TypeOf(payload), "deinit")) + { + @compileError("Payload with 'deinit' must be explicitly handled."); + } + }, } } }; @@ -105,20 +103,19 @@ pub fn update(allocator: Allocator, msg: Store.Message, state: *Store.State) !vo .set_start_replay_buffer_on_startup => |payload| { state.user_settings.user_settings.start_replay_buffer_on_startup = payload; }, - .set_video_output_directory => |payload| { - defer payload.deinit(); + .set_output_directory => |*payload| { + defer @constCast(payload).deinit(); try state.user_settings.user_settings - .set_video_output_directory(try String.from(allocator, payload.payload.video_output_directory)); + .set_output_directory(payload.output_directory, try String.init(allocator, payload.directory)); }, - .set_audio_device_settings => |payload| { - defer payload.deinit(); - const _payload = payload.payload; + .set_audio_device_settings => |*payload| { + defer @constCast(payload).deinit(); try state.user_settings.user_settings.update_audio_device_settings( allocator, - _payload.device_id, - _payload.selected, - _payload.gain, + payload.device_id, + payload.selected, + payload.gain, ); }, else => {}, @@ -140,13 +137,17 @@ fn effect_sync_settings_to_file(store: *Store, _: anytype) !void { try user_settings_snapshot.save(store.allocator, store.io); } -fn effect_select_output_directory(store: *Store, _: anytype) !void { +fn effect_select_output_directory(store: *Store, output_directory: UserSettings.OutputDirectory) !void { var initial_directory = blk: { const state_locked = store.state.lock(); defer state_locked.unlock(); const state = state_locked.unwrap_ptr(); - if (state.user_settings.user_settings.video_output_directory) |video_output_directory| { - break :blk try video_output_directory.clone(store.allocator); + const directory = switch (output_directory) { + .videos => state.user_settings.user_settings.video_output_directory, + .screenshots => state.user_settings.user_settings.screenshot_output_directory, + }; + if (directory) |value| { + break :blk try value.clone(store.allocator); } break :blk null; }; @@ -167,10 +168,10 @@ fn effect_select_output_directory(store: *Store, _: anytype) !void { const selected_directory = store.file_picker.open_directory_picker(store.allocator, store.io, directory) catch |err| { switch (err) { FilePickerError.PickerCancelled => { - log.info("[select_output_directory] output directory selection cancelled", .{}); + log.info("[effect_select_output_directory] output directory selection cancelled", .{}); }, else => { - log.err("[select_output_directory] failed to open output directory picker: {}", .{err}); + log.err("[effect_select_output_directory] failed to open output directory picker: {}", .{err}); }, } return; @@ -179,7 +180,11 @@ fn effect_select_output_directory(store: *Store, _: anytype) !void { store.dispatch(.{ .user_settings = .{ - .set_video_output_directory = try .init(store.allocator, .{ .video_output_directory = selected_directory }), + .set_output_directory = .{ + .allocator = store.allocator, + .output_directory = output_directory, + .directory = try store.allocator.dupe(u8, selected_directory), + }, }, }); } diff --git a/src/store/video_session.zig b/src/store/video_session.zig index c0d0bdc..a1f9a27 100644 --- a/src/store/video_session.zig +++ b/src/store/video_session.zig @@ -11,6 +11,8 @@ const ChanError = @import("../channel.zig").ChanError; const Store = @import("../store/store.zig").Store; const VideoCaptureSelection = @import("../capture/video/video_capture.zig").VideoCaptureSelection; const VideoCaptureError = @import("../capture/video/video_capture.zig").VideoCaptureError; +const VulkanImageBuffer = @import("../vulkan/vulkan_image_buffer.zig").VulkanImageBuffer; +const Arc = @import("../arc.zig").Arc; const VideoRecordData = struct { allocator: Allocator, @@ -65,6 +67,8 @@ pub const VideoSession = struct { video_replay_buffer: Mutex(?*VideoReplayBuffer), /// Increments every frame (even when not recording/replay buffer is not going). frame_count: u64 = 0, + screenshot_requests: Mutex(u32), + screenshot_io_group: std.Io.Group = .init, // 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. @@ -84,6 +88,7 @@ pub const VideoSession = struct { .store = store, .video_capture = video_capture, .video_replay_buffer = .init(io, null), + .screenshot_requests = .init(io, 0), }; } @@ -102,6 +107,10 @@ pub const VideoSession = struct { self.record_to_disk_thread = null; } + self.screenshot_io_group.await(self.io) catch |err| { + log.err("[deinit] screenshot_io_group.await error: {}", .{err}); + }; + // NOTE: Only deinit after the record to disk thread closes. if (self.record_data_queue) |*record_data_queue| { record_data_queue.deinit(); @@ -351,6 +360,14 @@ pub const VideoSession = struct { break :blk _copy_data; }; + const screenshot_signal_semaphore = if (self.decrement_screenshot_requests()) blk: { + break :blk self.take_screenshot(vulkan_image_buffer.as_ptr()) catch |err| { + log.err("[video_capture_thread_handler] queue_screenshot error: {}", .{err}); + self.store.dispatch(.{ .capture = .screenshot_fail }); + break :blk null; + }; + } else null; + if (!should_encode) { // In capture-only mode no downstream work waits on the preview copy. // Wait here so the capture-ring source image is not recycled while still in flight. @@ -367,6 +384,15 @@ pub const VideoSession = struct { const video_encoder = self.vulkan.video_encoder orelse continue; + var external_wait_semaphore_buffer: [2]vk.Semaphore = undefined; + var external_wait_semaphores: std.ArrayList(vk.Semaphore) = .initBuffer(&external_wait_semaphore_buffer); + if (copy_data.semaphore) |semaphore| { + external_wait_semaphores.appendAssumeCapacity(semaphore); + } + if (screenshot_signal_semaphore) |semaphore| { + external_wait_semaphores.appendAssumeCapacity(semaphore); + } + try video_encoder.prepare_encode(.{ .image = &image_slc, .image_view = &image_view_slc, @@ -374,7 +400,7 @@ pub const VideoSession = struct { .width = vulkan_image_buffer.as_ptr().width, .height = vulkan_image_buffer.as_ptr().height, }, - .external_wait_semaphore = copy_data.semaphore, + .external_wait_semaphores = external_wait_semaphores.items, }); const encode_result = try video_encoder.encode(0); @@ -424,6 +450,62 @@ pub const VideoSession = struct { } } + /// Request a screenshot. Increments a request queue. + pub fn screenshot_request(self: *Self) void { + var locked = self.screenshot_requests.lock(); + defer locked.unlock(); + locked.unwrap_ptr().* += 1; + } + + /// Decrement screenshot_requests and return true if there was a request. + fn decrement_screenshot_requests(self: *Self) bool { + var locked = self.screenshot_requests.lock(); + defer locked.unlock(); + const requests = locked.unwrap_ptr(); + if (requests.* == 0) { + return false; + } + requests.* -= 1; + return true; + } + + fn take_screenshot(self: *Self, vulkan_image_buffer: *VulkanImageBuffer) !vk.Semaphore { + const copy_result = try vulkan_image_buffer.duplicate(self.io); + errdefer copy_result.vulkan_image_buffer.deinit(); + + self.screenshot_io_group.async(self.io, struct { + fn run( + allocator: std.mem.Allocator, + store: *Store, + image_buffer: Arc(VulkanImageBuffer), + ) void { + defer image_buffer.deinit(); + const bgra = image_buffer.as_ptr().copy_image_to_cpu_buffer(allocator) catch |err| { + log.err("[take_screenshot] copy_image_to_buffer error: {}", .{err}); + store.dispatch(.{ .capture = .screenshot_fail }); + return; + }; + + store.dispatch(.{ + .capture = .{ + .screenshot_response = .{ + .allocator = allocator, + .data = bgra, + .width = image_buffer.as_ptr().width, + .height = image_buffer.as_ptr().height, + }, + }, + }); + } + }.run, .{ + self.allocator, + self.store, + copy_result.vulkan_image_buffer, + }); + + return copy_result.signal_semaphore; + } + fn record_to_disk_thread_handler(self: *Self) void { while (true) { if (self.record_data_queue) |*record_data_queue| { diff --git a/src/string.zig b/src/string.zig index 9fd062c..8445f76 100644 --- a/src/string.zig +++ b/src/string.zig @@ -10,7 +10,7 @@ pub const String = struct { allocator: Allocator, /// Create a new String. Memory is duped. Does not take ownership of bytes passed in. - pub fn from(allocator: Allocator, bytes: []const u8) !String { + pub fn init(allocator: Allocator, bytes: []const u8) !String { if (!std.unicode.utf8ValidateSlice(bytes)) { return error.InvalidUtf8; } @@ -20,6 +20,17 @@ pub const String = struct { }; } + /// Create a new String. Takes ownership of bytes passed in. + pub fn from(allocator: Allocator, bytes: []u8) !String { + if (!std.unicode.utf8ValidateSlice(bytes)) { + return error.InvalidUtf8; + } + return .{ + .allocator = allocator, + .bytes = bytes, + }; + } + pub fn deinit(self: *Self) void { self.allocator.free(self.bytes); } @@ -62,13 +73,13 @@ const TestUtil = struct { }; test "String - should create from utf8 string" { - var s = try String.from(std.testing.allocator, "test 123"); + var s = try String.init(std.testing.allocator, "test 123"); defer s.deinit(); try std.testing.expectEqualStrings(s.bytes, "test 123"); } test "String - should clone" { - var s1 = try String.from(std.testing.allocator, "test1"); + var s1 = try String.init(std.testing.allocator, "test1"); defer s1.deinit(); var s2 = try s1.clone(std.testing.allocator); @@ -80,7 +91,7 @@ test "String - should clone" { } test "String - should encode json" { - var person: TestUtil.Person = .{ .name = try .from(std.testing.allocator, "mitchell") }; + var person: TestUtil.Person = .{ .name = try .init(std.testing.allocator, "mitchell") }; defer person.name.deinit(); try std.testing.expectEqualStrings(person.name.bytes, "mitchell"); diff --git a/src/test.zig b/src/test.zig index b950397..8779f5e 100644 --- a/src/test.zig +++ b/src/test.zig @@ -1,25 +1,6 @@ -const std = @import("std"); +//! Testing utils. -test { - // NOTE: This fixes a linking error with tests. Probably won't be - // needed once test coverage is expanded and starts testing things - // that link pipewire. - const pipewire = @import("pipewire"); - _ = pipewire; - _ = @import("./channel.zig"); - _ = @import("./video/video_replay_buffer.zig"); - _ = @import("./capture/audio/audio_capture_data.zig"); - _ = @import("./audio/audio_mixer.zig"); - _ = @import("./audio/audio_replay_buffer.zig"); - _ = @import("./audio/audio_encoder.zig"); - _ = @import("./video/muxer.zig"); - _ = @import("./common/linux/token_manager.zig"); - _ = @import("./mutex.zig"); - _ = @import("./string.zig"); - _ = @import("./store/capture_store.zig"); - _ = @import("./store/user_settings.zig"); - _ = @import("./arc.zig"); -} +const std = @import("std"); /// If this is set, util.get_app_data_dir will return this. If any unit /// tests rely on the user settings, then they must init/destroy this dir. diff --git a/src/ui/draw_bottom_panel.zig b/src/ui/draw_bottom_panel.zig index 1f3a496..bcb46b0 100644 --- a/src/ui/draw_bottom_panel.zig +++ b/src/ui/draw_bottom_panel.zig @@ -9,8 +9,6 @@ const imgui_util = @import("./imgui_util.zig"); const util = @import("../util.zig"); const theme = @import("./theme.zig"); -const log = std.log.scoped(.draw_bottom_panel); - const AUDIO_GAIN_DB_MIN: f32 = -60.0; const AUDIO_GAIN_DB_MAX: f32 = 12.0; @@ -57,6 +55,8 @@ pub fn draw_bottom_panel(allocator: Allocator, ui_storage: *UIStorage, store: *S c.ImGui_PushStyleVarImVec2(c.ImGuiStyleVar_CellPadding, video_cell_padding); defer c.ImGui_PopStyleVar(); + const video_capture_ready = state.capture.is_video_capture_supprted and state.capture.video_capture_active; + // ---------------------------------------------------------------------------- // Video primary. // ---------------------------------------------------------------------------- @@ -76,8 +76,6 @@ pub fn draw_bottom_panel(allocator: Allocator, ui_storage: *UIStorage, store: *S } c.ImGui_EndDisabled(); - const video_capture_ready = state.capture.is_video_capture_supprted and state.capture.video_capture_active; - c.ImGui_TableNextRow(); _ = c.ImGui_TableNextColumn(); const replay_buffer_button_label = if (state.capture.replay_buffer_active) " Replay Buffer" else " Replay Buffer"; @@ -165,10 +163,11 @@ pub fn draw_bottom_panel(allocator: Allocator, ui_storage: *UIStorage, store: *S c.ImGui_TableNextRow(); _ = c.ImGui_TableNextColumn(); - c.ImGui_BeginDisabled(true); - defer c.ImGui_EndDisabled(); - _ = c.ImGui_ButtonEx("󰹑 Screenshot", .{ .x = c.ImGui_GetContentRegionAvail().x, .y = button_height }); - imgui_util.item_tooltip("Screenshots are not implemented yet."); + c.ImGui_BeginDisabled(!video_capture_ready); + if (c.ImGui_ButtonEx("󰹑 Screenshot", .{ .x = c.ImGui_GetContentRegionAvail().x, .y = button_height })) { + store.dispatch(.{ .capture = .screenshot_request }); + } + c.ImGui_EndDisabled(); } } } @@ -240,7 +239,7 @@ pub fn draw_bottom_panel(allocator: Allocator, ui_storage: *UIStorage, store: *S &selected, flags, )) { - store.dispatch(.{ .capture = .{ .toggle_audio_device = try .from(store.allocator, audio_device.id) } }); + store.dispatch(.{ .capture = .{ .toggle_audio_device = try .init(store.allocator, audio_device.id) } }); } } } @@ -256,7 +255,7 @@ fn draw_audio_device(allocator: Allocator, ui_storage: *UIStorage, store: *Store _ = c.ImGui_TableNextColumn(); if (c.ImGui_Button("")) { - store.dispatch(.{ .capture = .{ .toggle_audio_device = try .from(allocator, audio_device.id) } }); + store.dispatch(.{ .capture = .{ .toggle_audio_device = try .init(allocator, audio_device.id) } }); } imgui_util.item_tooltip("Remove device"); @@ -284,12 +283,15 @@ fn draw_audio_device(allocator: Allocator, ui_storage: *UIStorage, store: *Store c.ImGui_SetNextItemWidth(c.ImGui_GetContentRegionAvail().x); if (c.ImGui_SliderFloatEx("", &gain_db, AUDIO_GAIN_DB_MIN, AUDIO_GAIN_DB_MAX, "%.0f dB", 0)) { gain_db = @round(gain_db); - store.dispatch(.{ .capture = .{ - .set_audio_device_gain = try .init(allocator, .{ - .device_id = audio_device.id, - .gain = util.audio_db_to_linear(gain_db), - }), - } }); + store.dispatch(.{ + .capture = .{ + .set_audio_device_gain = .{ + .allocator = allocator, + .device_id = try allocator.dupe(u8, audio_device.id), + .gain = util.audio_db_to_linear(gain_db), + }, + }, + }); } } diff --git a/src/ui/draw_left_column.zig b/src/ui/draw_left_column.zig index 4fae958..d521845 100644 --- a/src/ui/draw_left_column.zig +++ b/src/ui/draw_left_column.zig @@ -14,8 +14,8 @@ 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; +const OUTPUT_DIRECTORY_MAX_BYTES = std.fs.max_path_bytes; +const OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH: f32 = 34; // These local values are temporary to hold the value // of an input as it's being edited. We do this so that @@ -27,7 +27,9 @@ 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; +// TODO: This could take up unnecessary stack space. Revise at a later point. +var video_output_directory_local: ?[OUTPUT_DIRECTORY_MAX_BYTES:0]u8 = null; +var screenshot_output_directory_local: ?[OUTPUT_DIRECTORY_MAX_BYTES:0]u8 = null; pub fn draw_left_column(allocator: std.mem.Allocator, store: *Store, state: *Store.State) !void { _ = c.ImGui_Begin(dockspace.LEFT_WINDOW_NAME, null, c.ImGuiWindowFlags_None); @@ -77,16 +79,16 @@ pub fn draw_left_column(allocator: std.mem.Allocator, store: *Store, state: *Sto } fn draw_output_settings(allocator: std.mem.Allocator, store: *Store) !void { - c.ImGui_SeparatorText("Output Directory"); + c.ImGui_SeparatorText("Output"); - const video_output_directory = blk: { - break :blk store.state.private.value.user_settings.user_settings.video_output_directory.?.bytes; - }; + const settings = store.state.private.value.user_settings.user_settings; + const video_output_directory = settings.video_output_directory.?.bytes; + const screenshot_output_directory = settings.screenshot_output_directory.?.bytes; - c.ImGui_Text("Video"); + c.ImGui_Text("Videos"); var _video_output_directory_local = video_output_directory_local orelse blk: { - var buffer = std.mem.zeroes([VIDEO_OUTPUT_DIRECTORY_MAX_BYTES:0]u8); + var buffer = std.mem.zeroes([OUTPUT_DIRECTORY_MAX_BYTES:0]u8); const copy_len = @min(video_output_directory.len, buffer.len - 1); @memmove(buffer[0..copy_len], video_output_directory[0..copy_len]); break :blk buffer; @@ -96,7 +98,7 @@ fn draw_output_settings(allocator: std.mem.Allocator, store: *Store) !void { defer c.ImGui_EndTable(); c.ImGui_TableSetupColumnEx("input", c.ImGuiTableColumnFlags_WidthStretch, 1.0, 0); - c.ImGui_TableSetupColumnEx("button", c.ImGuiTableColumnFlags_WidthFixed, VIDEO_OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH, 0); + c.ImGui_TableSetupColumnEx("button", c.ImGuiTableColumnFlags_WidthFixed, OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH, 0); _ = c.ImGui_TableNextColumn(); imgui_util.set_next_item_width_fill(); @@ -112,11 +114,15 @@ fn draw_output_settings(allocator: std.mem.Allocator, store: *Store) !void { if (c.ImGui_IsItemDeactivatedAfterEdit()) { const updated_directory = std.mem.sliceTo(_video_output_directory_local[0..], 0); if (updated_directory.len > 0 and !std.mem.eql(u8, updated_directory, video_output_directory)) { - store.dispatch(.{ .user_settings = .{ - .set_video_output_directory = try .init(allocator, .{ - .video_output_directory = updated_directory, - }), - } }); + store.dispatch(.{ + .user_settings = .{ + .set_output_directory = .{ + .allocator = allocator, + .output_directory = .videos, + .directory = try store.allocator.dupe(u8, updated_directory), + }, + }, + }); } video_output_directory_local = null; } else if (!c.ImGui_IsItemActive()) { @@ -128,7 +134,64 @@ fn draw_output_settings(allocator: std.mem.Allocator, store: *Store) !void { .x = imgui_util.WIDTH_FILL, .y = 0, })) { - store.dispatch(.{ .user_settings = .select_output_directory }); + store.dispatch(.{ .user_settings = .{ .select_output_directory = .videos } }); + } + if (c.ImGui_BeginItemTooltip()) { + c.ImGui_TextUnformatted("Choose directory"); + c.ImGui_EndTooltip(); + } + } + + c.ImGui_Text("Screenshots"); + + var _screenshot_output_directory_local = screenshot_output_directory_local orelse blk: { + var buffer = std.mem.zeroes([OUTPUT_DIRECTORY_MAX_BYTES:0]u8); + const copy_len = @min(screenshot_output_directory.len, buffer.len - 1); + @memmove(buffer[0..copy_len], screenshot_output_directory[0..copy_len]); + break :blk buffer; + }; + + if (c.ImGui_BeginTable("screenshot_output_directory_row", 2, c.ImGuiTableFlags_SizingStretchProp)) { + defer c.ImGui_EndTable(); + + c.ImGui_TableSetupColumnEx("input", c.ImGuiTableColumnFlags_WidthStretch, 1.0, 0); + c.ImGui_TableSetupColumnEx("button", c.ImGuiTableColumnFlags_WidthFixed, OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH, 0); + + _ = c.ImGui_TableNextColumn(); + imgui_util.set_next_item_width_fill(); + _ = c.ImGui_InputText( + "##screenshot_output_directory", + &_screenshot_output_directory_local, + _screenshot_output_directory_local.len, + c.ImGuiInputTextFlags_None, + ); + if (c.ImGui_IsItemEdited()) { + screenshot_output_directory_local = _screenshot_output_directory_local; + } + if (c.ImGui_IsItemDeactivatedAfterEdit()) { + const updated_directory = std.mem.sliceTo(_screenshot_output_directory_local[0..], 0); + if (updated_directory.len > 0 and !std.mem.eql(u8, updated_directory, screenshot_output_directory)) { + store.dispatch(.{ + .user_settings = .{ + .set_output_directory = .{ + .allocator = allocator, + .output_directory = .screenshots, + .directory = try store.allocator.dupe(u8, updated_directory), + }, + }, + }); + } + screenshot_output_directory_local = null; + } else if (!c.ImGui_IsItemActive()) { + screenshot_output_directory_local = null; + } + + _ = c.ImGui_TableNextColumn(); + if (c.ImGui_ButtonEx("...##screenshot_output_directory_picker", .{ + .x = imgui_util.WIDTH_FILL, + .y = 0, + })) { + store.dispatch(.{ .user_settings = .{ .select_output_directory = .screenshots } }); } if (c.ImGui_BeginItemTooltip()) { c.ImGui_TextUnformatted("Choose directory"); diff --git a/src/ui/tray.zig b/src/ui/tray.zig index ea30012..5e6d5ec 100644 --- a/src/ui/tray.zig +++ b/src/ui/tray.zig @@ -19,6 +19,7 @@ pub const Tray = struct { store: *Store, tray: *imguiz.SDL_Tray, + screenshot_entry: *imguiz.SDL_TrayEntry, replay_buffer_entry: *imguiz.SDL_TrayEntry, save_replay_entry: *imguiz.SDL_TrayEntry, recording_entry: *imguiz.SDL_TrayEntry, @@ -41,6 +42,9 @@ pub const Tray = struct { const save_replay_entry = try insert_tray_entry(menu, "Save Replay"); imguiz.SDL_SetTrayEntryCallback(save_replay_entry, save_replay_callback, store); + const screenshot_entry = try insert_tray_entry(menu, "Screenshot"); + imguiz.SDL_SetTrayEntryCallback(screenshot_entry, screenshot_callback, store); + try insert_tray_separator(menu); const replay_buffer_entry = try insert_tray_checkbox_entry(menu, "Replay Buffer"); @@ -60,6 +64,7 @@ pub const Tray = struct { return .{ .store = store, .tray = tray, + .screenshot_entry = screenshot_entry, .replay_buffer_entry = replay_buffer_entry, .save_replay_entry = save_replay_entry, .recording_entry = recording_entry, @@ -96,6 +101,7 @@ pub const Tray = struct { imguiz.SDL_SetTrayTooltip(self.tray, get_tooltip_for_state(state)); imguiz.SDL_SetTrayEntryEnabled(self.save_replay_entry, state.replay_buffer_active); + imguiz.SDL_SetTrayEntryEnabled(self.screenshot_entry, state.video_capture_active); imguiz.SDL_SetTrayEntryChecked(self.replay_buffer_entry, state.replay_buffer_active); imguiz.SDL_SetTrayEntryEnabled(self.replay_buffer_entry, state.video_capture_active or state.replay_buffer_active); @@ -156,6 +162,12 @@ pub const Tray = struct { store.dispatch(.{ .capture = .save_replay }); } + fn screenshot_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { + assert(userdata != null); + const store: *Store = @ptrCast(@alignCast(userdata)); + store.dispatch(.{ .capture = .screenshot_request }); + } + fn replay_buffer_callback(userdata: ?*anyopaque, _: ?*imguiz.SDL_TrayEntry) callconv(.c) void { assert(userdata != null); const store: *Store = @ptrCast(@alignCast(userdata)); diff --git a/src/util.zig b/src/util.zig index b964992..77006d9 100644 --- a/src/util.zig +++ b/src/util.zig @@ -63,29 +63,128 @@ pub fn format_duration_label(allocator: std.mem.Allocator, args: struct { const TimestampString = [27]u8; pub fn format_timestamp_utc(timestamp_ms: i64) TimestampString { - const epoch_ms: u64 = @intCast(@max(timestamp_ms, 0)); - const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = epoch_ms / std.time.ms_per_s }; - const year_day = epoch_seconds.getEpochDay().calculateYearDay(); - const month_day = year_day.calculateMonthDay(); - const day_seconds = epoch_seconds.getDaySeconds(); + const timestamp = timestamp_parts_utc(timestamp_ms); var buffer: TimestampString = undefined; _ = std.fmt.bufPrint( &buffer, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}.{d:0>3} UTC", .{ - year_day.year, - month_day.month.numeric(), - month_day.day_index + 1, - day_seconds.getHoursIntoDay(), - day_seconds.getMinutesIntoHour(), - day_seconds.getSecondsIntoMinute(), - epoch_ms % std.time.ms_per_s, + timestamp.year, + timestamp.month, + timestamp.day, + timestamp.hour, + timestamp.minute, + timestamp.second, + timestamp.millisecond, }, ) catch @panic("std.fmt.bufPrint error"); return buffer; } +pub fn format_file_name( + allocator: std.mem.Allocator, + io: std.Io, + args: struct { + prefix: []const u8, + extension: []const u8, + /// Defaults to now. + timestamp_ms: ?i64 = null, + }, +) ![]u8 { + const timestamp_ms = blk: { + if (args.timestamp_ms) |ts| { + break :blk ts; + } else { + const ts: i64 = @intCast(@divFloor(std.Io.Clock.real.now(io).nanoseconds, std.time.ns_per_ms)); + break :blk ts; + } + }; + const timestamp = timestamp_parts_utc(timestamp_ms); + + return try std.fmt.allocPrint( + allocator, + "{s}_{d:0>4}-{d:0>2}-{d:0>2}_{d:0>2}-{d:0>2}-{d:0>2}-{d:0>3}.{s}", + .{ + args.prefix, + timestamp.year, + timestamp.month, + timestamp.day, + timestamp.hour, + timestamp.minute, + timestamp.second, + timestamp.millisecond, + args.extension, + }, + ); +} + +const UtcTimestampParts = struct { + year: u16, + month: u9, + day: u9, + hour: u5, + minute: u6, + second: u6, + millisecond: u10, +}; + +fn timestamp_parts_utc(timestamp_ms: i64) UtcTimestampParts { + const epoch_ms: u64 = @intCast(@max(timestamp_ms, 0)); + const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = epoch_ms / std.time.ms_per_s }; + const year_day = epoch_seconds.getEpochDay().calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + const day_seconds = epoch_seconds.getDaySeconds(); + + return .{ + .year = year_day.year, + .month = month_day.month.numeric(), + .day = month_day.day_index + 1, + .hour = day_seconds.getHoursIntoDay(), + .minute = day_seconds.getMinutesIntoHour(), + .second = day_seconds.getSecondsIntoMinute(), + .millisecond = @intCast(epoch_ms % std.time.ms_per_s), + }; +} + +test "Util - format_file_name formats Spacecap output file names" { + const allocator = std.testing.allocator; + const timestamp_ms: i64 = 1234; + + const cases = [_]struct { + prefix: []const u8, + extension: []const u8, + expected: []const u8, + }{ + .{ + .prefix = "screenshot", + .extension = "png", + .expected = "screenshot_1970-01-01_00-00-01-234.png", + }, + .{ + .prefix = "recording", + .extension = "mp4", + .expected = "recording_1970-01-01_00-00-01-234.mp4", + }, + .{ + .prefix = "replay", + .extension = "mp4", + .expected = "replay_1970-01-01_00-00-01-234.mp4", + }, + }; + + for (cases) |case| { + const file_name = try format_file_name(allocator, std.testing.io, .{ + .prefix = case.prefix, + .extension = case.extension, + .timestamp_ms = timestamp_ms, + }); + defer allocator.free(file_name); + + try std.testing.expectEqualStrings(case.expected, file_name); + } +} + /// Write bgrx data to a .bmp file - used for testing pub fn write_bmp_bgrx( allocator: std.mem.Allocator, @@ -218,12 +317,19 @@ pub fn get_app_data_dir(allocator: std.mem.Allocator, io: std.Io) std.mem.Alloca }; } +pub const OutputDirectoryType = enum { + pictures, + videos, +}; + // Falls back to the current working directory when // the home-based output directory cannot be resolved or created. -// // Caller owns the memory. -// e.g. ~/Videos/spacecap -pub fn get_default_video_output_dir(allocator: std.mem.Allocator, io: std.Io) ![]u8 { +pub fn get_default_output_dir( + allocator: std.mem.Allocator, + io: std.Io, + output_directory_type: OutputDirectoryType, +) ![]u8 { if (@import("builtin").is_test) { const TEST_APP_DATA_DIR = @import("./test.zig").TEST_APP_DATA_DIR; assert(TEST_APP_DATA_DIR != null); @@ -245,20 +351,24 @@ pub fn get_default_video_output_dir(allocator: std.mem.Allocator, io: std.Io) ![ if (home_dir) |_home_dir| { defer allocator.free(_home_dir); - const output_dir = try std.fs.path.join(allocator, &.{ _home_dir, "Videos", "spacecap" }); + const home_subdirectory = switch (output_directory_type) { + .pictures => "Pictures", + .videos => "Videos", + }; + const output_dir = try std.fs.path.join(allocator, &.{ _home_dir, home_subdirectory, "spacecap" }); errdefer allocator.free(output_dir); if (std.Io.Dir.cwd().createDirPath(io, output_dir)) { return output_dir; } else |err| { - log.err("[get_default_video_output_dir] failed to create output directory {s}: {}", .{ output_dir, err }); + log.err("[get_default_output_dir] failed to create output directory {s}: {}", .{ output_dir, err }); allocator.free(output_dir); } } - log.warn("[get_default_video_output_dir] falling back to current working directory", .{}); + log.warn("[get_default_output_dir] falling back to current working directory", .{}); return std.process.currentPathAlloc(io, allocator) catch |err| { - log.err("[get_default_video_output_dir] failed to get current working directory: {}", .{err}); + log.err("[get_default_output_dir] failed to get current working directory: {}", .{err}); return allocator.dupe(u8, "."); }; } diff --git a/src/video/vulkan_video_encoder.zig b/src/video/vulkan_video_encoder.zig index 079b53a..766419c 100644 --- a/src/video/vulkan_video_encoder.zig +++ b/src/video/vulkan_video_encoder.zig @@ -1,5 +1,7 @@ const vk = @import("vulkan"); const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; const Vulkan = @import("../vulkan/vulkan.zig").Vulkan; const VideoReplayBuffer = @import("./video_replay_buffer.zig").VideoReplayBuffer; const vulkan_h264_parameters = @import("./vulkan_h264_parameters.zig"); @@ -68,10 +70,12 @@ pub const VulkanVideoEncoder = struct { compute_descriptor_sets: std.ArrayList(vk.DescriptorSet), descriptor_pool: ?vk.DescriptorPool = null, - inter_queue_semaphore1: vk.Semaphore, - inter_queue_semaphore2: vk.Semaphore, - // TODO: this should probably be a list - external_wait_semaphore: ?vk.Semaphore = null, + /// Signaled when the rgb to ycb_cr pipeline is done. + compute_semaphore: vk.Semaphore, + /// Signaled when the encode pipeline is done. + encode_semaphore: vk.Semaphore, + wait_semaphores: std.ArrayList(vk.Semaphore), + wait_stage_masks: std.ArrayList(vk.PipelineStageFlags), encode_finished_fence: vk.Fence, compute_finished_fence: vk.Fence, @@ -119,12 +123,18 @@ pub const VulkanVideoEncoder = struct { .ycbcr_image_plane_views = try std.ArrayList(vk.ImageView).initCapacity(allocator, 0), .compute_descriptor_sets = try std.ArrayList(vk.DescriptorSet).initCapacity(allocator, 0), - .inter_queue_semaphore1 = try vulkan.device.createSemaphore(&.{}, null), - .inter_queue_semaphore2 = try vulkan.device.createSemaphore(&.{}, null), + .compute_semaphore = try vulkan.device.createSemaphore(&.{}, null), + .encode_semaphore = try vulkan.device.createSemaphore(&.{}, null), + .wait_semaphores = try .initCapacity(allocator, 0), + .wait_stage_masks = try .initCapacity(allocator, 0), + .encode_finished_fence = try vulkan.device.createFence(&.{ .flags = .{ .signaled_bit = true } }, null), .compute_finished_fence = try vulkan.device.createFence(&.{ .flags = .{ .signaled_bit = true } }, null), }; + try self.wait_semaphores.append(self.allocator, self.encode_semaphore); + try self.wait_stage_masks.append(self.allocator, .{ .all_commands_bit = true }); + try self.create_command_pools(); errdefer { if (self.encode_command_pool) |encode_command_pool| { @@ -749,8 +759,24 @@ pub const VulkanVideoEncoder = struct { try self.update_descriptor_sets(image_views); } - pub fn update_external_wait_semaphores(self: *Self, semaphore: ?vk.Semaphore) void { - self.external_wait_semaphore = semaphore; + pub fn update_external_wait_semaphores(self: *Self, semaphores: []const vk.Semaphore) Allocator.Error!void { + // Only the compute pipeline needs to wait on any external semaphores, + // including the encode_semaphore. Resize the buffers if necessary + // (including space for encode_semaphore), and then update + // wait_stage_masks accordingly. + try self.wait_semaphores.resize(self.allocator, semaphores.len + 1); + + self.wait_semaphores.clearRetainingCapacity(); + try self.wait_semaphores.append(self.allocator, self.encode_semaphore); + try self.wait_semaphores.appendSlice(self.allocator, semaphores); + + try self.wait_stage_masks.resize(self.allocator, semaphores.len + 1); + self.wait_stage_masks.clearRetainingCapacity(); + try self.wait_stage_masks.appendNTimes(self.allocator, .{ .all_commands_bit = true }, semaphores.len + 1); + + // Must always be at least length 1 for the encode_semaphore. + assert(self.wait_semaphores.items.len > 0); + assert(self.wait_stage_masks.items.len > 0); } fn update_descriptor_sets(self: *Self, image_views: []vk.ImageView) !void { @@ -1005,24 +1031,7 @@ pub const VulkanVideoEncoder = struct { try self.vulkan.device.endCommandBuffer(self.compute_command_buffer.?); - const signal_semaphores: [1]vk.Semaphore = .{self.inter_queue_semaphore1}; - var wait_semaphores: [2]vk.Semaphore = undefined; - var wait_stage_masks: [2]vk.PipelineStageFlags = undefined; - var wait_count: usize = 0; - - if (self.frame_count != 0) { - wait_semaphores[wait_count] = self.inter_queue_semaphore2; - wait_stage_masks[wait_count] = .{ .all_commands_bit = true }; - wait_count += 1; - } - - // Make sure we wait on any external wait semaphores even when the - // frame_count is 0. - if (self.external_wait_semaphore) |external_wait_semaphore| { - wait_semaphores[wait_count] = external_wait_semaphore; - wait_stage_masks[wait_count] = .{ .all_commands_bit = true }; - wait_count += 1; - } + const signal_semaphores: [1]vk.Semaphore = .{self.compute_semaphore}; var submit_info = vk.SubmitInfo{ .command_buffer_count = 1, @@ -1031,10 +1040,10 @@ pub const VulkanVideoEncoder = struct { .p_signal_semaphores = &signal_semaphores, }; - if (wait_count > 0) { - submit_info.wait_semaphore_count = @intCast(wait_count); - submit_info.p_wait_semaphores = wait_semaphores[0..wait_count].ptr; - submit_info.p_wait_dst_stage_mask = wait_stage_masks[0..wait_count].ptr; + if (self.frame_count != 0) { + submit_info.wait_semaphore_count = @intCast(self.wait_semaphores.items.len); + submit_info.p_wait_semaphores = self.wait_semaphores.items.ptr; + submit_info.p_wait_dst_stage_mask = self.wait_stage_masks.items.ptr; } try self.vulkan.queue_submit(.graphics, &.{submit_info}, .{ .fence = self.compute_finished_fence }); @@ -1044,7 +1053,7 @@ pub const VulkanVideoEncoder = struct { image: []vk.Image, image_view: []vk.ImageView, input_size: types.Size, - external_wait_semaphore: ?vk.Semaphore, + external_wait_semaphores: []const vk.Semaphore, }) !void { const sanitized_width = @max(opts.input_size.width & ~@as(u32, 1), 1); const sanitized_height = @max(opts.input_size.height & ~@as(u32, 1), 1); @@ -1052,7 +1061,7 @@ pub const VulkanVideoEncoder = struct { .width = sanitized_width, .height = sanitized_height, }; - self.update_external_wait_semaphores(opts.external_wait_semaphore); + try self.update_external_wait_semaphores(opts.external_wait_semaphores); try self.update_images( opts.image, opts.image_view, @@ -1222,12 +1231,12 @@ pub const VulkanVideoEncoder = struct { }; const submit_info = vk.SubmitInfo{ .wait_semaphore_count = 1, - .p_wait_semaphores = @ptrCast(&self.inter_queue_semaphore1), + .p_wait_semaphores = @ptrCast(&self.compute_semaphore), .p_wait_dst_stage_mask = @ptrCast(&dst_stage_mask), .command_buffer_count = 1, .p_command_buffers = @ptrCast(&self.encode_command_buffer.?), .signal_semaphore_count = 1, - .p_signal_semaphores = @ptrCast(&self.inter_queue_semaphore2), + .p_signal_semaphores = @ptrCast(&self.encode_semaphore), }; try self.vulkan.queue_submit(.encode, &.{submit_info}, .{ .fence = self.encode_finished_fence }); @@ -1316,8 +1325,8 @@ pub const VulkanVideoEncoder = struct { fn destroy_encode_finished_fence(self: *Self) void { self.vulkan.device.destroyFence(self.compute_finished_fence, null); self.vulkan.device.destroyFence(self.encode_finished_fence, null); - self.vulkan.device.destroySemaphore(self.inter_queue_semaphore1, null); - self.vulkan.device.destroySemaphore(self.inter_queue_semaphore2, null); + self.vulkan.device.destroySemaphore(self.compute_semaphore, null); + self.vulkan.device.destroySemaphore(self.encode_semaphore, null); } fn destroy_ycb_cr_conversion_pipeline(self: *Self) void { @@ -1403,6 +1412,9 @@ pub const VulkanVideoEncoder = struct { self.vulkan.device.destroyCommandPool(graphics_command_pool, null); } + self.wait_semaphores.deinit(self.allocator); + self.wait_stage_masks.deinit(self.allocator); + self.bit_stream_header.deinit(self.allocator); self.encode_session_bind_memory.deinit(self.allocator); self.dpb_images.deinit(self.allocator); diff --git a/src/vulkan/vulkan.zig b/src/vulkan/vulkan.zig index 16a2bbb..04c7ff6 100644 --- a/src/vulkan/vulkan.zig +++ b/src/vulkan/vulkan.zig @@ -109,7 +109,7 @@ pub const Vulkan = struct { /// to the vulkan image ring buffer. capture_preview_textures: std.AutoHashMap(*VulkanImageBuffer, Arc(VulkanCapturePreviewTexture)), /// Ring buffer that can be used in the capture method to hold frames - /// in which the encoded can grab from. + /// in which the encoder can grab from. capture_ring_buffer: Mutex(?*VulkanImageRingBuffer), /// The window used to render the UI with imgui @@ -789,6 +789,150 @@ pub const Vulkan = struct { self.graphics_queue.mutex.unlock(self.io); } + /// Copy a Vulkan image to a buffer in which the CPU can access. + pub fn copy_image_to_cpu_buffer( + self: *Self, + allocator: Allocator, + image: vk.Image, + image_layout: vk.ImageLayout, + width: u32, + height: u32, + args: struct { + src_stage_mask: vk.PipelineStageFlags2, + src_access_mask: vk.AccessFlags2, + }, + ) ![]u8 { + const size: u64 = width * height * 4; // bgra + + const buffer_create_info = vk.BufferCreateInfo{ + .size = size, + .usage = .{ .transfer_dst_bit = true }, + .sharing_mode = .exclusive, + }; + + const buffer = try self.device.createBuffer(&buffer_create_info, null); + defer self.device.destroyBuffer(buffer, null); + + const mem_reqs = self.device.getBufferMemoryRequirements(buffer); + const memory = try self.allocate( + mem_reqs, + .{ .host_visible_bit = true, .host_coherent_bit = true }, + null, + ); + defer self.device.freeMemory(memory, null); + + try self.device.bindBufferMemory(buffer, memory, 0); + + const command_pool = try self.device.createCommandPool(&.{ + .queue_family_index = self.graphics_queue.family, + .flags = .{ .reset_command_buffer_bit = true }, + }, null); + defer self.device.destroyCommandPool(command_pool, null); + + const command_buffer_alloc_info = vk.CommandBufferAllocateInfo{ + .command_pool = command_pool, + .level = .primary, + .command_buffer_count = 1, + }; + + var command_buffer: vk.CommandBuffer = undefined; + try self.device.allocateCommandBuffers(&command_buffer_alloc_info, @ptrCast(&command_buffer)); + defer self.device.freeCommandBuffers(command_pool, &.{command_buffer}); + + try self.device.beginCommandBuffer(command_buffer, &.{}); + + const color_subresource_range = vk.ImageSubresourceRange{ + .aspect_mask = .{ .color_bit = true }, + .base_mip_level = 0, + .level_count = 1, + .base_array_layer = 0, + .layer_count = 1, + }; + + const image_to_transfer_barrier = vk.ImageMemoryBarrier2{ + .src_stage_mask = args.src_stage_mask, + .src_access_mask = args.src_access_mask, + .dst_stage_mask = .{ .all_transfer_bit = true }, + .dst_access_mask = .{ .transfer_read_bit = true }, + .old_layout = image_layout, + .new_layout = .transfer_src_optimal, + .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED, + .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED, + .image = image, + .subresource_range = color_subresource_range, + }; + const image_to_transfer_dep_info = vk.DependencyInfoKHR{ + .image_memory_barrier_count = 1, + .p_image_memory_barriers = @ptrCast(&image_to_transfer_barrier), + }; + self.device.cmdPipelineBarrier2(command_buffer, &image_to_transfer_dep_info); + + const copy_region = vk.BufferImageCopy{ + .buffer_offset = 0, + .buffer_row_length = 0, + .buffer_image_height = 0, + .image_subresource = .{ + .aspect_mask = .{ .color_bit = true }, + .mip_level = 0, + .base_array_layer = 0, + .layer_count = 1, + }, + .image_offset = .{ .x = 0, .y = 0, .z = 0 }, + .image_extent = .{ .width = width, .height = height, .depth = 1 }, + }; + + self.device.cmdCopyImageToBuffer( + command_buffer, + image, + .transfer_src_optimal, + buffer, + &.{copy_region}, + ); + + const buffer_read_barrier = vk.BufferMemoryBarrier2{ + .src_stage_mask = .{ .all_transfer_bit = true }, + .src_access_mask = .{ .transfer_write_bit = true }, + .dst_stage_mask = .{ .host_bit = true }, + .dst_access_mask = .{ .host_read_bit = true }, + .src_queue_family_index = vk.QUEUE_FAMILY_IGNORED, + .dst_queue_family_index = vk.QUEUE_FAMILY_IGNORED, + .buffer = buffer, + .offset = 0, + .size = size, + }; + const read_dep_info = vk.DependencyInfoKHR{ + .buffer_memory_barrier_count = 1, + .p_buffer_memory_barriers = @ptrCast(&buffer_read_barrier), + }; + self.device.cmdPipelineBarrier2(command_buffer, &read_dep_info); + + try self.device.endCommandBuffer(command_buffer); + + const submit_info = vk.SubmitInfo{ + .command_buffer_count = 1, + .p_command_buffers = @ptrCast(&command_buffer), + }; + + const fence = try self.device.createFence(&.{}, null); + defer self.device.destroyFence(fence, null); + + try self.queue_submit(.graphics, &.{submit_info}, .{ .fence = fence }); + + const result = try self.device.waitForFences(&.{fence}, .true, std.math.maxInt(u64)); + if (result != .success) { + return error.WaitForFences; + } + + const mapped = try self.device.mapMemory(memory, 0, size, .{}); + defer self.device.unmapMemory(memory); + + const mapped_data: [*]const u8 = @ptrCast(mapped); + const data = try allocator.alloc(u8, size); + errdefer allocator.free(data); + @memcpy(data, mapped_data[0..data.len]); + return data; + } + /// Copy a vulkan image on the GPU. /// NOTE: This is only for the graphics queue. pub fn copy_image( diff --git a/src/vulkan/vulkan_image_buffer.zig b/src/vulkan/vulkan_image_buffer.zig index b970b62..d0dbf72 100644 --- a/src/vulkan/vulkan_image_buffer.zig +++ b/src/vulkan/vulkan_image_buffer.zig @@ -16,6 +16,8 @@ pub const VulkanImageBuffer = struct { image_layout: vk.ImageLayout, dst_stage_mask: vk.PipelineStageFlags2, dst_access_mask: vk.AccessFlags2, + usage: vk.ImageUsageFlags, + image_component_mapping: vk.ComponentMapping, command_buffer: vk.CommandBuffer, command_pool: vk.CommandPool, signal_semaphore: vk.Semaphore, @@ -26,8 +28,8 @@ pub const VulkanImageBuffer = struct { width: u32, height: u32, - /// This should be true until the image buffer has been released from the - /// encode pipeline. + /// In use means that the buffer is occupied and is not free to copy data into it. + /// (e.g. encoding, rendering on UI, etc.) in_use: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), pub const InitArgs = struct { @@ -121,6 +123,8 @@ pub const VulkanImageBuffer = struct { .image_layout = args.image_layout, .dst_stage_mask = args.dst_stage_mask, .dst_access_mask = args.dst_access_mask, + .usage = args.usage, + .image_component_mapping = args.image_component_mapping, .width = args.width, .height = args.height, .src_queue_family_index = args.src_queue_family_index, @@ -142,7 +146,7 @@ pub const VulkanImageBuffer = struct { } /// Copy an external vulkan image into the local image buffer. - pub fn copy_image( + pub fn copy_image_into( self: *Self, args: struct { src_image: vk.Image, @@ -182,4 +186,63 @@ pub const VulkanImageBuffer = struct { }, ); } + + /// Return a new instance and copy the underlying image data. + pub fn duplicate(self: *Self, io: std.Io) !struct { + vulkan_image_buffer: Arc(Self), + signal_semaphore: vk.Semaphore, + } { + const image_buffer = try Self.init(.{ + .allocator = self.allocator, + .io = io, + .vulkan = self.vulkan, + .width = self.width, + .height = self.height, + .image_layout = self.image_layout, + .dst_stage_mask = self.dst_stage_mask, + .dst_access_mask = self.dst_access_mask, + .usage = self.usage, + .image_component_mapping = self.image_component_mapping, + .src_queue_family_index = self.vulkan.graphics_queue.family, + }); + errdefer image_buffer.deinit(); + + try image_buffer.as_ptr().copy_image_into(.{ + .src_image = self.image, + .src_width = self.width, + .src_height = self.height, + .wait_semaphore = null, + .use_signal_semaphore = true, + .timestamp_ns = self.timestamp_ns, + }); + + return .{ + .vulkan_image_buffer = image_buffer, + .signal_semaphore = image_buffer.as_ptr().signal_semaphore, + }; + } + + /// Copy the raw contents of the image to a buffer. + pub fn copy_image_to_cpu_buffer(self: *Self, allocator: std.mem.Allocator) ![]u8 { + const result = try self.vulkan.device.waitForFences( + &.{self.fence}, + .true, + std.math.maxInt(u64), + ); + if (result != .success) { + return error.WaitForFences; + } + + return try self.vulkan.copy_image_to_cpu_buffer( + allocator, + self.image, + self.image_layout, + self.width, + self.height, + .{ + .src_stage_mask = self.dst_stage_mask, + .src_access_mask = self.dst_access_mask, + }, + ); + } }; diff --git a/src/vulkan/vulkan_image_ring_buffer.zig b/src/vulkan/vulkan_image_ring_buffer.zig index 473c51c..024253a 100644 --- a/src/vulkan/vulkan_image_ring_buffer.zig +++ b/src/vulkan/vulkan_image_ring_buffer.zig @@ -1,3 +1,16 @@ +// TODO: This ring buffer needs some work. It has evolved over time and I'm not +// happy with the quality of the code. Here are some things: +// - It doesn't behave like a true ring buffer. It just searches through +// buffers and picks one that's not in use. If none are available, it silently +// doesn't copy any data. It should behave like a true ring buffer and it should +// probably panic if there are no buffers available after a number of iterations. +// - The get_most_recent_buffer function should check for the in_use flag. +// Currently it also only works because there is one consumer (the UI). It +// should iterate through the ring buffer looking for the most recent one that +// is not in use, although it may be fine to use a buffer that is 'in_use' for +// things such as displaying it on the UI. Either way I'm not happy with the +// current implementation. + const std = @import("std"); const vk = @import("vulkan"); const Vulkan = @import("../vulkan/vulkan.zig").Vulkan; @@ -95,7 +108,7 @@ pub const VulkanImageRingBuffer = struct { continue; } self.most_recent_index = @intCast(i); - try buffer.copy_image(.{ + try buffer.copy_image_into(.{ .src_image = args.src_image, .src_width = args.src_width, .src_height = args.src_height,