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,