From 588f8562f32d7620db4f4fb48348a86d363f5eb4 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Wed, 8 Apr 2026 22:28:07 -0500 Subject: [PATCH] feat: add ability to change output directory - FilePicker interface created. - Linux - xdg portal directory picker. - Windows - Scaffolding setup. Nothing implemented yet. - Add String type. closes #80 --- build.zig.zon | 4 - src/exporter.zig | 3 +- src/file_picker/file_picker.zig | 29 +++ .../linux/xdg_desktop_portal_file_picker.zig | 240 ++++++++++++++++++ src/file_picker/platform_file_picker.zig | 6 + .../windows/windows_file_picker.zig | 40 +++ src/main.zig | 6 + src/state/actor.zig | 14 + src/state/audio_state.zig | 1 + src/state/user_settings.zig | 112 +++++++- src/state/user_settings_state.zig | 154 +++++------ src/string.zig | 99 ++++++++ src/test.zig | 1 + src/ui/draw_left_column.zig | 90 ++++++- src/ui/imgui_util.zig | 8 + src/util.zig | 75 ++++-- src/video/muxer.zig | 19 +- 17 files changed, 798 insertions(+), 103 deletions(-) create mode 100644 src/file_picker/file_picker.zig create mode 100644 src/file_picker/linux/xdg_desktop_portal_file_picker.zig create mode 100644 src/file_picker/platform_file_picker.zig create mode 100644 src/file_picker/windows/windows_file_picker.zig create mode 100644 src/string.zig diff --git a/build.zig.zon b/build.zig.zon index 7b6e126..d67a086 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -86,10 +86,6 @@ .url = "https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n8.0.1.tar.gz", .hash = "N-V-__8AAH4RHQXHEafp_hkUel3EMeK1wjHBfaIYYxYsKdiM", }, - .libportal = .{ - .url = "https://github.com/flatpak/libportal/archive/refs/tags/0.9.1.tar.gz", - .hash = "N-V-__8AAAl8DACVYUEhPMGsrf5o-rONBBbWCBz9g-nnL-H_", - }, .gobject = .{ .url = "https://github.com/ianprime0509/zig-gobject/releases/download/v0.3.0/bindings-gnome47.tar.zst", .hash = "gobject-0.3.0-Skun7IrmdQHh-PhvmchG9AKnrR2RFS5EhBe5oedb0ITv", diff --git a/src/exporter.zig b/src/exporter.zig index ac1a605..698614a 100644 --- a/src/exporter.zig +++ b/src/exporter.zig @@ -18,6 +18,7 @@ pub fn export_replay_buffers( fps: u32, video_replay_buffer: *VideoReplayBuffer, audio_replay_buffer: ?*AudioReplayBuffer, + output_directory: []const u8, ) !void { if (video_replay_buffer.len <= 0) { log.warn("[export_replay_buffers] video replay buffer is empty", .{}); @@ -40,7 +41,7 @@ pub fn export_replay_buffers( width, height, fps, - "replay", + output_directory, ); defer muxer.deinit(); try muxer.mux_audio_video(); diff --git a/src/file_picker/file_picker.zig b/src/file_picker/file_picker.zig new file mode 100644 index 0000000..040eaa0 --- /dev/null +++ b/src/file_picker/file_picker.zig @@ -0,0 +1,29 @@ +const std = @import("std"); + +pub const FilePickerError = error{ + PickerCancelled, +}; + +/// FilePicker interface. +pub const FilePicker = struct { + const Self = @This(); + + ptr: *anyopaque, + vtable: *const VTable, + + const VTable = struct { + open_directory_picker: *const fn (*anyopaque, ?[]const u8) anyerror![]u8, + deinit: *const fn (*anyopaque) void, + }; + + /// Open a directory picker and return the selected directory path. + /// The returned path is owned by the caller. + /// initial_directory - Open in this directory if provided. + pub fn open_directory_picker(self: *Self, initial_directory: ?[]const u8) (FilePickerError || anyerror)![]u8 { + return self.vtable.open_directory_picker(self.ptr, initial_directory); + } + + pub fn deinit(self: *Self) void { + return self.vtable.deinit(self.ptr); + } +}; diff --git a/src/file_picker/linux/xdg_desktop_portal_file_picker.zig b/src/file_picker/linux/xdg_desktop_portal_file_picker.zig new file mode 100644 index 0000000..81c4b72 --- /dev/null +++ b/src/file_picker/linux/xdg_desktop_portal_file_picker.zig @@ -0,0 +1,240 @@ +const std = @import("std"); +const TokenManager = @import("../../common/linux/token_manager.zig"); +const FilePicker = @import("../file_picker.zig").FilePicker; +const FilePickerError = @import("../file_picker.zig").FilePickerError; +const glib = @import("glib"); +const gio = @import("gio"); + +const log = std.log.scoped(.xdg_desktop_portal_file_picker); + +const DBUS_DESTINATION: [:0]const u8 = "org.freedesktop.portal.Desktop"; +const DBUS_OBJECT_PATH: [:0]const u8 = "/org/freedesktop/portal/desktop"; +const FILE_CHOOSER_INTERFACE: [:0]const u8 = "org.freedesktop.portal.FileChooser"; +const REQUEST_INTERFACE: [:0]const u8 = "org.freedesktop.portal.Request"; +const OPEN_FILE_METHOD: [:0]const u8 = "OpenFile"; +const RESPONSE_SIGNAL: [:0]const u8 = "Response"; + +fn map_g_error(err: *glib.Error) ?(FilePickerError || anyerror) { + if (err.f_domain == gio.DBusError.quark()) { + if (err.f_code == @intFromEnum(gio.DBusError.service_unknown) or err.f_code == @intFromEnum(gio.DBusError.name_has_no_owner)) { + return error.PortalServiceNotFound; + } + } + if (err.f_domain == gio.ioErrorQuark() and err.f_code == @intFromEnum(gio.IOErrorEnum.cancelled)) { + return FilePickerError.PickerCancelled; + } + return null; +} + +const OpenDirectoryPickerContext = struct { + loop: *glib.MainLoop, + response_code: u32 = 2, + response_data: ?*glib.Variant = null, +}; + +pub const XdgDesktopPortalFilePicker = struct { + const Self = @This(); + + allocator: std.mem.Allocator, + dbus: *gio.DBusConnection, + + pub fn init(allocator: std.mem.Allocator) !*Self { + var err: ?*glib.Error = null; + defer if (err) |e| e.free(); + + const dbus = gio.busGetSync(.session, null, &err) orelse { + if (err) |g_err| { + if (map_g_error(g_err)) |picker_err| { + return picker_err; + } + } + return error.Dbus; + }; + + const self = try allocator.create(Self); + errdefer allocator.destroy(self); + self.* = .{ + .allocator = allocator, + .dbus = dbus, + }; + return self; + } + + fn open_directory_picker_response( + _: *gio.DBusConnection, + _: ?[*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + _: [*:0]const u8, + parameters: *glib.Variant, + user_data: ?*anyopaque, + ) callconv(.c) void { + const ctx: *OpenDirectoryPickerContext = @ptrCast(@alignCast(user_data)); + parameters.get("(u@a{sv})", &ctx.response_code, &ctx.response_data); + ctx.loop.quit(); + } + + fn make_open_directory_picker_payload(request_token: [:0]const u8, initial_directory: ?[:0]const u8) *glib.Variant { + var options: glib.VariantBuilder = undefined; + glib.VariantBuilder.init(&options, glib.VariantType.checked("a{sv}")); + options.add("{sv}", "handle_token", glib.Variant.newString(request_token.ptr)); + options.add("{sv}", "directory", glib.Variant.newBoolean(1)); + options.add("{sv}", "modal", glib.Variant.newBoolean(1)); + if (initial_directory) |directory| { + options.add("{sv}", "current_folder", glib.Variant.newBytestring(directory.ptr)); + } + + return glib.Variant.new( + "(ss@a{sv})", + "", + "Select Output Directory", + options.end(), + ); + } + + fn selected_directory_from_result(self: *Self, result: *glib.Variant) ![]u8 { + var result_dict: glib.VariantDict = undefined; + glib.VariantDict.init(&result_dict, result); + defer result_dict.clear(); + + const uris = result_dict.lookupValue("uris", glib.VariantType.checked("as")) orelse { + return error.PickerResultMissingUris; + }; + defer uris.unref(); + + var uri_count: usize = 0; + const uri_values = uris.getStrv(&uri_count); + defer glib.free(@ptrCast(@constCast(uri_values))); + + if (uri_count == 0) { + return error.PickerResultMissingUris; + } + + const first_uri = uri_values[0] orelse return error.PickerResultMissingUris; + const file_path = glib.filenameFromUri(first_uri, null, null) orelse return error.FileUriToPathFailed; + defer glib.free(file_path); + + return self.allocator.dupe(u8, std.mem.span(file_path)); + } + + pub fn open_directory_picker(context: *anyopaque, initial_directory: ?[]const u8) ![]u8 { + const self: *Self = @ptrCast(@alignCast(context)); + + const request_token = try TokenManager.generate_token(self.allocator); + defer self.allocator.free(request_token); + + const initial_directory_z = if (initial_directory) |directory| + try self.allocator.dupeZ(u8, directory) + else + null; + defer if (initial_directory_z) |directory| self.allocator.free(directory); + + const unique_name = std.mem.span(self.dbus.getUniqueName().?); + const request_path = try TokenManager.get_request_path(self.allocator, unique_name[1..], request_token); + defer self.allocator.free(request_path); + + const loop = glib.MainLoop.new(null, 0); + defer loop.unref(); + + var ctx = OpenDirectoryPickerContext{ .loop = loop }; + var subscription_id = self.dbus.signalSubscribe( + null, + REQUEST_INTERFACE.ptr, + RESPONSE_SIGNAL.ptr, + request_path.ptr, + null, + .{}, + open_directory_picker_response, + &ctx, + null, + ); + defer { + if (subscription_id != 0) { + self.dbus.signalUnsubscribe(subscription_id); + } + } + + const payload = make_open_directory_picker_payload(request_token, initial_directory_z); + var err: ?*glib.Error = null; + const request_handle = self.dbus.callSync( + DBUS_DESTINATION.ptr, + DBUS_OBJECT_PATH.ptr, + FILE_CHOOSER_INTERFACE.ptr, + OPEN_FILE_METHOD.ptr, + payload, + null, + .{}, + -1, + null, + &err, + ); + defer { + if (request_handle != null) { + request_handle.?.unref(); + } + } + + if (err) |g_err| { + defer g_err.free(); + if (map_g_error(g_err)) |picker_err| { + return picker_err; + } + log.err("directory picker request failed: {s}", .{g_err.f_message.?}); + return error.OpenDirectoryPickerFailed; + } + + const request_handle_value = request_handle orelse return error.OpenDirectoryPickerFailed; + const actual_request_path_variant = request_handle_value.getChildValue(0); + defer actual_request_path_variant.unref(); + const actual_request_path = actual_request_path_variant.getString(null); + const actual_request_path_str = std.mem.span(actual_request_path); + + if (!std.mem.eql(u8, actual_request_path_str, request_path)) { + log.warn( + "directory picker returned unexpected request path, resubscribing: expected={s} actual={s}", + .{ request_path, actual_request_path_str }, + ); + self.dbus.signalUnsubscribe(subscription_id); + subscription_id = self.dbus.signalSubscribe( + null, + REQUEST_INTERFACE.ptr, + RESPONSE_SIGNAL.ptr, + actual_request_path, + null, + .{}, + open_directory_picker_response, + &ctx, + null, + ); + } + + loop.run(); + + switch (ctx.response_code) { + 0 => {}, + 1 => return FilePickerError.PickerCancelled, + else => return error.OpenDirectoryPickerFailed, + } + + const result = ctx.response_data orelse return error.OpenDirectoryPickerFailed; + defer result.unref(); + + return self.selected_directory_from_result(result); + } + + pub fn deinit(context: *anyopaque) void { + const self: *Self = @ptrCast(@alignCast(context)); + self.dbus.unref(); + self.allocator.destroy(self); + } + + pub fn file_picker(self: *Self) FilePicker { + return .{ + .ptr = self, + .vtable = &.{ + .open_directory_picker = open_directory_picker, + .deinit = deinit, + }, + }; + } +}; diff --git a/src/file_picker/platform_file_picker.zig b/src/file_picker/platform_file_picker.zig new file mode 100644 index 0000000..e9fbc9f --- /dev/null +++ b/src/file_picker/platform_file_picker.zig @@ -0,0 +1,6 @@ +const Util = @import("../util.zig"); + +pub const PlatformFilePicker = if (Util.is_linux()) + @import("./linux/xdg_desktop_portal_file_picker.zig").XdgDesktopPortalFilePicker +else + @import("./windows/windows_file_picker.zig").WindowsFilePicker; diff --git a/src/file_picker/windows/windows_file_picker.zig b/src/file_picker/windows/windows_file_picker.zig new file mode 100644 index 0000000..1feb821 --- /dev/null +++ b/src/file_picker/windows/windows_file_picker.zig @@ -0,0 +1,40 @@ +const std = @import("std"); +const FilePicker = @import("../file_picker.zig").FilePicker; + +pub const WindowsFilePicker = struct { + const Self = @This(); + + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) !*Self { + const self = try allocator.create(Self); + errdefer allocator.destroy(self); + + self.* = .{ + .allocator = allocator, + }; + return self; + } + + pub fn open_directory_picker(context: *anyopaque, initial_directory: ?[]const u8) ![]u8 { + const self: *Self = @ptrCast(@alignCast(context)); + _ = self; + _ = initial_directory; + return error.NotImplemented; + } + + pub fn deinit(context: *anyopaque) void { + const self: *Self = @ptrCast(@alignCast(context)); + self.allocator.destroy(self); + } + + pub fn file_picker(self: *Self) FilePicker { + return .{ + .ptr = self, + .vtable = &.{ + .open_directory_picker = open_directory_picker, + .deinit = deinit, + }, + }; + } +}; diff --git a/src/main.zig b/src/main.zig index 6898e67..a24bbd9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -9,6 +9,7 @@ const args = @import("./args.zig"); const PlatformIpc = @import("./ipc/platform_ipc.zig").PlatformIpc; const PlatformAudioCapture = @import("./capture/audio/platform_audio_capture.zig").PlatformAudioCapture; const PlatformVideoCapture = @import("./capture/video/platform_video_capture.zig").PlatformVideoCapture; +const PlatformFilePicker = @import("./file_picker/platform_file_picker.zig").PlatformFilePicker; const PlatformGlobalShortcuts = @import("./global_shortcuts/platform_global_shortcuts.zig").PlatformGlobalShortcuts; pub fn main() !void { @@ -85,6 +86,10 @@ fn gui_app(allocator: std.mem.Allocator, parsed_args: ?args.Args) !void { var audio_capture_interface = _audio_capture.audio_capture(); defer audio_capture_interface.deinit(); + const platform_file_picker = try PlatformFilePicker.init(allocator); + var file_picker = platform_file_picker.file_picker(); + defer file_picker.deinit(); + const platform_global_shortcuts = try PlatformGlobalShortcuts.init(allocator); var global_shortcuts = platform_global_shortcuts.global_shortcuts(); try global_shortcuts.run(); @@ -94,6 +99,7 @@ fn gui_app(allocator: std.mem.Allocator, parsed_args: ?args.Args) !void { allocator, vulkan, &video_capture_interface, + &file_picker, &audio_capture_interface, &global_shortcuts, ); diff --git a/src/state/actor.zig b/src/state/actor.zig index 060bbaf..ef79dbc 100644 --- a/src/state/actor.zig +++ b/src/state/actor.zig @@ -4,6 +4,7 @@ const assert = std.debug.assert; const vk = @import("vulkan"); const Util = @import("../util.zig"); +const FilePicker = @import("../file_picker/file_picker.zig").FilePicker; const VideoCapture = @import("../capture/video/video_capture.zig").VideoCapture; const VideoCaptureError = @import("../capture/video/video_capture.zig").VideoCaptureError; const VideoCaptureSelection = @import("../capture/video/video_capture.zig").VideoCaptureSelection; @@ -19,6 +20,7 @@ const VideoReplayBuffer = @import("../video/video_replay_buffer.zig").VideoRepla const exporter = @import("../exporter.zig"); const AudioActions = @import("./audio_state.zig").AudioActions; const UserSettingsActions = @import("./user_settings_state.zig").UserSettingsActions; +const String = @import("../string.zig").String; const log = std.log.scoped(.actor); @@ -46,6 +48,7 @@ pub const Actor = struct { vulkan: *Vulkan, // TODO: Create video_state and move video logic. video_capture: *VideoCapture, + file_picker: *FilePicker, global_shortcuts: *GlobalShortcuts, video_replay_buffer: Mutex(?*VideoReplayBuffer) = .init(null), action_chan: ActionChan, @@ -62,6 +65,7 @@ pub const Actor = struct { allocator: std.mem.Allocator, vulkan: *Vulkan, video_capture: *VideoCapture, + file_picker: *FilePicker, audio_capture: *AudioCapture, global_shortcuts: *GlobalShortcuts, ) !*Self { @@ -72,6 +76,7 @@ pub const Actor = struct { .allocator = allocator, .vulkan = vulkan, .video_capture = video_capture, + .file_picker = file_picker, .global_shortcuts = global_shortcuts, .action_chan = try ActionChan.init(allocator), // TODO: The state is getting a bit unwiedly and coupled. @@ -229,6 +234,10 @@ pub const Actor = struct { .save_replay => { var fps: u32 = 0; var replay_seconds: u32 = 0; + var video_output_directory: ?String = null; + defer { + if (video_output_directory) |*_video_output_directory| _video_output_directory.deinit(); + } { self.ui_mutex.lock(); defer self.ui_mutex.unlock(); @@ -244,6 +253,10 @@ pub const Actor = struct { const settings = settings_locked.unwrap_ptr(); fps = settings.capture_fps; replay_seconds = settings.replay_seconds; + // video_output_directory should never be null at this point. If so, there is + // something seriously wrong. + assert(settings.video_output_directory != null); + video_output_directory = try settings.video_output_directory.?.clone(self.allocator); } // We should always have a size if the state is recording. @@ -289,6 +302,7 @@ pub const Actor = struct { fps, video_replay_buffer.?, audio_replay_buffer, + video_output_directory.?.bytes, ); }, .select_video_source => |source_type| { diff --git a/src/state/audio_state.zig b/src/state/audio_state.zig index 1f2302e..199450b 100644 --- a/src/state/audio_state.zig +++ b/src/state/audio_state.zig @@ -105,6 +105,7 @@ pub const AudioState = struct { errdefer clear_devices(devices); for (available_devices.devices.items) |device| { + // Show default devices if user settings aren't saved yet. const persisted_settings = user_settings.audio_devices.map.get(device.id); const device_copy = try AudioDeviceViewModel.init(self.allocator, .{ .id = device.id, diff --git a/src/state/user_settings.zig b/src/state/user_settings.zig index 00c4ff9..b370e45 100644 --- a/src/state/user_settings.zig +++ b/src/state/user_settings.zig @@ -1,7 +1,13 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const String = @import("../string.zig").String; +const util = @import("../util.zig"); -/// NOTE: This MUST remain serializable. +const log = std.log.scoped(.user_settings); + +const SETTINGS_JSON = "settings.json"; + +/// NOTE: This MUST remain JSON serializable. pub const UserSettings = struct { const AudioDeviceSettings = struct { id: []const u8, @@ -9,19 +15,91 @@ pub const UserSettings = struct { gain: f32 = 1.0, }; + /// WARN: All field must have a default value! This is required + /// for when we parse the settings.json file. capture_fps: u32 = 60, /// In bits per second (bps). capture_bit_rate: u64 = 10_000_000, replay_seconds: u32 = 30, start_replay_buffer_on_startup: bool = false, restore_capture_source_on_startup: bool = true, + // Doesn't have a default value because an allocator is + // required to find the directory. It must be set before + // settings are used anywhere. + video_output_directory: ?String = null, audio_devices: std.json.ArrayHashMap(AudioDeviceSettings) = .{}, + /// Read the settings json file if it exists, otherwise use defaults. + pub fn init(allocator: Allocator) !UserSettings { + // Catch file read errors because we don't want to crash if + // something goes wrong with the settings file. + return _init(allocator) catch |err| { + if (err != error.FileNotFound) { + log.err("[init] error loading settings file: {}", .{err}); + } + return try default_settings(allocator); + }; + } + + pub fn _init(allocator: Allocator) !UserSettings { + const app_data_dir = try util.get_app_data_dir(allocator); + defer allocator.free(app_data_dir); + + const settings_path = try std.fs.path.join(allocator, &.{ app_data_dir, SETTINGS_JSON }); + defer allocator.free(settings_path); + + const file = std.fs.openFileAbsolute(settings_path, .{}) catch |err| { + return err; + }; + defer file.close(); + + const stat = try file.stat(); + var reader = file.reader(&.{}); + const file_contents = try reader.interface.readAlloc(allocator, stat.size); + defer allocator.free(file_contents); + + const parsed = try std.json.parseFromSlice(UserSettings, allocator, file_contents, .{ + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + var loaded = try parsed.value.clone(allocator); + errdefer loaded.deinit(allocator); + + if (loaded.video_output_directory == null) { + const video_output_directory = try util.get_default_video_output_dir(allocator); + defer allocator.free(video_output_directory); + loaded.video_output_directory = try String.from(allocator, video_output_directory); + } + + return loaded; + } + pub fn deinit(self: *@This(), allocator: Allocator) void { + self.clear_video_output_directory(); self.clear_audio_device_settings(allocator); self.audio_devices.deinit(allocator); } + fn default_settings(allocator: Allocator) !UserSettings { + const video_output_directory = try util.get_default_video_output_dir(allocator); + defer allocator.free(video_output_directory); + return .{ + .video_output_directory = try String.from(allocator, video_output_directory), + }; + } + + /// directory - Is owned by this method. + pub fn set_video_output_directory( + self: *@This(), + directory: ?String, + ) !void { + self.clear_video_output_directory(); + if (directory) |_directory| { + self.video_output_directory = _directory; + } + } + pub fn update_audio_device_settings( self: *@This(), allocator: Allocator, @@ -55,12 +133,27 @@ 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; + } + } + /// Deep copy user settings. pub fn clone(self: @This(), allocator: Allocator) !@This() { var settings_copy = self; + settings_copy.video_output_directory = null; settings_copy.audio_devices = .{}; errdefer settings_copy.deinit(allocator); + try settings_copy.set_video_output_directory( + if (self.video_output_directory) |directory| + try directory.clone(allocator) + else + null, + ); + var iter = self.audio_devices.map.iterator(); while (iter.next()) |entry| { const audio_device = entry.value_ptr.*; @@ -75,4 +168,21 @@ pub const UserSettings = struct { return settings_copy; } + + /// Save a copy of settings to disk. + /// NOTE: It is important to call this outside of the UI lock. + pub fn save(self: *@This(), allocator: Allocator) !void { + const app_data_dir = try util.get_app_data_dir(allocator); + defer allocator.free(app_data_dir); + + const settings_path = try std.fs.path.join(allocator, &.{ app_data_dir, SETTINGS_JSON }); + defer allocator.free(settings_path); + + const file = try std.fs.createFileAbsolute(settings_path, .{}); + defer file.close(); + + var writer = file.writer(&.{}); + var stringify: std.json.Stringify = .{ .writer = &writer.interface }; + try stringify.write(self.*); + } }; diff --git a/src/state/user_settings_state.zig b/src/state/user_settings_state.zig index b18421d..989a160 100644 --- a/src/state/user_settings_state.zig +++ b/src/state/user_settings_state.zig @@ -1,15 +1,31 @@ const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Actor = @import("./actor.zig").Actor; const ActionPayload = @import("./action_payload.zig").ActionPayload; +const FilePickerError = @import("../file_picker/file_picker.zig").FilePickerError; const util = @import("../util.zig"); const Actions = @import("./actor.zig").Actions; const UserSettings = @import("./user_settings.zig").UserSettings; const Mutex = @import("../mutex.zig").Mutex; +const String = @import("../string.zig").String; const log = std.log.scoped(.user_settings_state); pub const UserSettingsActions = union(enum) { + select_output_directory, + 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_capture_fps: u32, set_capture_bit_rate: u64, set_replay_seconds: u32, @@ -37,16 +53,14 @@ pub const UserSettingsState = struct { const Self = @This(); allocator: Allocator, - settings: Mutex(UserSettings) = .init(.{}), + settings: Mutex(UserSettings), + output_directory_picker_running: std.atomic.Value(bool) = .init(false), pub fn init(allocator: Allocator) !Self { - var self: Self = .{ + return .{ .allocator = allocator, + .settings = .init(try .init(allocator)), }; - self.load() catch |err| { - log.err("unable to load user settings: {}\n", .{err}); - }; - return self; } pub fn deinit(self: *Self) void { @@ -60,6 +74,10 @@ pub const UserSettingsState = struct { switch (action) { .user_settings => |user_settings_action| { switch (user_settings_action) { + .set_video_output_directory => |_action| { + defer _action.deinit(); + try self.set_video_output_directory(actor, _action.payload.video_output_directory); + }, .set_capture_fps => |capture_fps| { try self.set_state(actor, "capture_fps", capture_fps); try actor.video_capture.update_fps(capture_fps); @@ -93,7 +111,51 @@ pub const UserSettingsState = struct { settings_snapshot = try settings.clone(self.allocator); } defer settings_snapshot.deinit(self.allocator); - try self.save(&settings_snapshot); + try settings_snapshot.save(self.allocator); + }, + .select_output_directory => { + if (self.output_directory_picker_running.swap(true, .acq_rel)) { + return; + } + defer self.output_directory_picker_running.store(false, .release); + + var initial_directory = blk: { + const settings_locked = actor.state.user_settings.settings.lock(); + defer settings_locked.unlock(); + const settings = settings_locked.unwrap_ptr(); + if (settings.video_output_directory) |*directory| { + break :blk try directory.clone(self.allocator); + } + break :blk null; + }; + defer if (initial_directory) |*directory| directory.deinit(); + + // Check if directory exists before trying to open it with + // the file picker. + const directory = blk: { + if (initial_directory) |dir| { + var opened_dir = std.fs.openDirAbsolute(dir.bytes, .{}) catch { + break :blk null; + }; + opened_dir.close(); + break :blk dir.bytes; + } + break :blk null; + }; + const selected_directory = actor.file_picker.open_directory_picker(directory) catch |err| { + switch (err) { + FilePickerError.PickerCancelled => { + log.info("[select_output_directory] output directory selection cancelled", .{}); + }, + else => { + log.err("[select_output_directory] failed to open output directory picker: {}", .{err}); + }, + } + return; + }; + defer self.allocator.free(selected_directory); + + try self.set_video_output_directory(actor, selected_directory); }, } }, @@ -123,72 +185,22 @@ pub const UserSettingsState = struct { break :blk try settings.clone(self.allocator); }; defer settings_snapshot.deinit(self.allocator); - try self.save(&settings_snapshot); + try settings_snapshot.save(self.allocator); } - /// Read the settings json file if it exists, otherwise use defaults. - fn load(self: *Self) !void { - const app_data_dir = try util.get_app_data_dir(self.allocator); - defer self.allocator.free(app_data_dir); - - const settings_path = try std.fs.path.join(self.allocator, &.{ app_data_dir, "settings.json" }); - defer self.allocator.free(settings_path); - - const file = std.fs.openFileAbsolute(settings_path, .{}) catch |err| { - if (err == error.FileNotFound) { - return; - } - return err; + fn set_video_output_directory( + self: *Self, + actor: *Actor, + video_output_directory: []const u8, + ) !void { + var settings_snapshot: UserSettings = blk: { + const settings_locked = actor.state.user_settings.settings.lock(); + defer settings_locked.unlock(); + const settings = settings_locked.unwrap_ptr(); + try settings.set_video_output_directory(try String.from(self.allocator, video_output_directory)); + break :blk try settings.clone(self.allocator); }; - defer file.close(); - - const stat = try file.stat(); - var reader = file.reader(&.{}); - const file_contents = try reader.interface.readAlloc(self.allocator, stat.size); - defer self.allocator.free(file_contents); - - const parsed = try std.json.parseFromSlice(UserSettings, self.allocator, file_contents, .{ - .ignore_unknown_fields = true, - }); - defer parsed.deinit(); - - var loaded = parsed.value; - loaded.audio_devices = .{}; - errdefer loaded.deinit(self.allocator); - - var iter = parsed.value.audio_devices.map.iterator(); - while (iter.next()) |entry| { - const audio_device = entry.value_ptr.*; - const device_id = if (audio_device.id.len > 0) audio_device.id else entry.key_ptr.*; - try loaded.update_audio_device_settings( - self.allocator, - device_id, - audio_device.selected, - audio_device.gain, - ); - } - - var settings_locked = self.settings.lock(); - defer settings_locked.unlock(); - const settings = settings_locked.unwrap_ptr(); - settings.deinit(self.allocator); - settings_locked.set(loaded); - } - - /// Save a copy of settings to disk. - /// NOTE: It is important to call this outside of the UI lock. - fn save(self: *const Self, settings: *const UserSettings) !void { - const app_data_dir = try util.get_app_data_dir(self.allocator); - defer self.allocator.free(app_data_dir); - - const settings_path = try std.fs.path.join(self.allocator, &.{ app_data_dir, "settings.json" }); - defer self.allocator.free(settings_path); - - const file = try std.fs.createFileAbsolute(settings_path, .{}); - defer file.close(); - - var writer = file.writer(&.{}); - var stringify: std.json.Stringify = .{ .writer = &writer.interface }; - try stringify.write(settings.*); + defer settings_snapshot.deinit(self.allocator); + try settings_snapshot.save(self.allocator); } }; diff --git a/src/string.zig b/src/string.zig new file mode 100644 index 0000000..9fd062c --- /dev/null +++ b/src/string.zig @@ -0,0 +1,99 @@ +//! A String implementation that implements JSON parse/stringify methods. +//! See tests for details. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +pub const String = struct { + const Self = @This(); + bytes: []u8, + 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 { + if (!std.unicode.utf8ValidateSlice(bytes)) { + return error.InvalidUtf8; + } + return .{ + .allocator = allocator, + .bytes = try allocator.dupe(u8, bytes), + }; + } + + pub fn deinit(self: *Self) void { + self.allocator.free(self.bytes); + } + + pub fn clone(self: *const Self, allocator: Allocator) !Self { + return .{ + .allocator = allocator, + .bytes = try allocator.dupe(u8, self.bytes), + }; + } + + /// Required to satisfy the JSON encoding interface. + /// See `std.json.Stringify.write` for details. + pub fn jsonStringify(self: @This(), stringify: anytype) !void { + try stringify.write(self.bytes); + } + + /// Required to satisfy the JSON decoding interface. + /// See `std.json.static.innerParse` for details. + pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !String { + const token = try source.nextAllocMax(allocator, .alloc_always, options.max_value_len.?); + const bytes = switch (token) { + .allocated_string => |bytes| bytes, + else => return error.UnexpectedToken, + }; + errdefer allocator.free(bytes); + + if (!std.unicode.utf8ValidateSlice(bytes)) { + return error.UnexpectedToken; + } + + return .{ .allocator = allocator, .bytes = bytes }; + } +}; + +const TestUtil = struct { + const Person = struct { + name: String, + }; +}; + +test "String - should create from utf8 string" { + var s = try String.from(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"); + defer s1.deinit(); + + var s2 = try s1.clone(std.testing.allocator); + defer s2.deinit(); + @memcpy(s2.bytes[0..], "test2"); + + try std.testing.expectEqualStrings(s1.bytes, "test1"); + try std.testing.expectEqualStrings(s2.bytes, "test2"); +} + +test "String - should encode json" { + var person: TestUtil.Person = .{ .name = try .from(std.testing.allocator, "mitchell") }; + defer person.name.deinit(); + + try std.testing.expectEqualStrings(person.name.bytes, "mitchell"); + + const json_string = try std.json.Stringify.valueAlloc(std.testing.allocator, person, .{}); + defer std.testing.allocator.free(json_string); + + try std.testing.expectEqualStrings(json_string, "{\"name\":\"mitchell\"}"); +} + +test "String - should decode json" { + const parsed = try std.json.parseFromSlice(TestUtil.Person, std.testing.allocator, "{\"name\":\"mitchell\"}", .{}); + defer parsed.deinit(); + const person = parsed.value; + try std.testing.expectEqualStrings(person.name.bytes, "mitchell"); +} diff --git a/src/test.zig b/src/test.zig index b75d880..4555ff3 100644 --- a/src/test.zig +++ b/src/test.zig @@ -14,4 +14,5 @@ test { _ = @import("./video/muxer.zig"); _ = @import("./common/linux/token_manager.zig"); _ = @import("./mutex.zig"); + _ = @import("./string.zig"); } diff --git a/src/ui/draw_left_column.zig b/src/ui/draw_left_column.zig index 6f9c088..c7a94a2 100644 --- a/src/ui/draw_left_column.zig +++ b/src/ui/draw_left_column.zig @@ -20,14 +20,19 @@ const CAPTURE_BIT_RATE_KBPS_MIN: i32 = 100; const CAPTURE_BIT_RATE_KBPS_MAX: i32 = 1_000_000; const REPLAY_SECONDS_MIN: i32 = 1; const REPLAY_SECONDS_MAX: i32 = 60 * 60 * 24; +const VIDEO_OUTPUT_DIRECTORY_MAX_BYTES = std.fs.max_path_bytes; +const VIDEO_OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH: f32 = 34; -/// This is bound to a drag input. We keep a locally bound value -/// because we only want to update the global state when not dragging. +// These local values are temporary to hold the value +// of an input as it's being edited. We do this so that +// we don't update the state on every little change +// (e.g. dragging a slider). var capture_fps_local: ?i32 = null; var capture_bit_rate_local: ?i32 = null; var replay_seconds_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; fn device_type_label(device_type: AudioDeviceType) []const u8 { return switch (device_type) { @@ -143,7 +148,7 @@ fn draw_selected_audio_source_gain_sliders(allocator: std.mem.Allocator, actor: c.ImGui_Text("Gain"); _ = c.ImGui_TableNextColumn(); - c.ImGui_SetNextItemWidth(-std.math.floatMin(f32)); + imgui_util.set_next_item_width_fill(); if (c.ImGui_SliderFloatEx(gain_slider_id, &gain, AUDIO_GAIN_MIN, AUDIO_GAIN_MAX, "%.2fx", 0)) { try actor.dispatch(.{ .audio = .{ .set_audio_device_gain = try .init(allocator, .{ @@ -227,7 +232,7 @@ pub fn draw_left_column(allocator: std.mem.Allocator, actor: *Actor) !void { c.ImGui_PushStyleColorImVec4(c.ImGuiCol_ButtonHovered, c.ImVec4{ .x = 0.329, .y = 0.706, .z = 0.247, .w = 1.0 }); c.ImGui_PushStyleColorImVec4(c.ImGuiCol_ButtonActive, c.ImVec4{ .x = 0.173, .y = 0.471, .z = 0.129, .w = 1.0 }); c.ImGui_BeginDisabled(!video_capture_supported or actor.state.is_recording_video or !actor.state.is_capturing_video); - if (c.ImGui_ButtonEx("Start", .{ .x = -std.math.floatMin(f32), .y = CONTROL_HEIGHT })) { + if (c.ImGui_ButtonEx("Start", .{ .x = imgui_util.WIDTH_FILL, .y = CONTROL_HEIGHT })) { try actor.dispatch(.start_record); } c.ImGui_PopStyleColorEx(3); @@ -239,7 +244,7 @@ pub fn draw_left_column(allocator: std.mem.Allocator, actor: *Actor) !void { c.ImGui_PushStyleColorImVec4(c.ImGuiCol_ButtonHovered, c.ImVec4{ .x = 0.75, .y = 0.1, .z = 0.1, .w = 1.0 }); c.ImGui_PushStyleColorImVec4(c.ImGuiCol_ButtonActive, c.ImVec4{ .x = 0.5, .y = 0.0, .z = 0.0, .w = 1.0 }); c.ImGui_BeginDisabled(!video_capture_supported or !actor.state.is_recording_video); - if (c.ImGui_ButtonEx("Stop", .{ .x = -std.math.floatMin(f32), .y = CONTROL_HEIGHT })) { + if (c.ImGui_ButtonEx("Stop", .{ .x = imgui_util.WIDTH_FILL, .y = CONTROL_HEIGHT })) { try actor.dispatch(.stop_record); } c.ImGui_PopStyleColorEx(3); @@ -289,6 +294,10 @@ pub fn draw_left_column(allocator: std.mem.Allocator, actor: *Actor) !void { try draw_capture_settings(allocator, actor); + c.ImGui_Dummy(.{ .x = 0, .y = GROUP_SPACING }); + + try draw_output_settings(allocator, actor); + // NOTE: Hiding this for now. Linux shortcuts can be configured at the // desktop environment level. See comments regarding `Method.configure_shortcuts` // in `xdg_desktop_portal_global_shortcuts.zig` for more info. @@ -304,6 +313,7 @@ pub fn draw_left_column(allocator: std.mem.Allocator, actor: *Actor) !void { // imgui_util.help_marker("This button may not work. Configure shortcuts with your system settings."); if (util.DEBUG) { + c.ImGui_Dummy(.{ .x = 0, .y = GROUP_SPACING }); c.ImGui_SeparatorText("IMGUI Debug"); if (c.ImGui_ButtonEx("Show Demo", .{ .x = c.ImGui_GetContentRegionAvail().x, .y = 0 })) { @@ -319,6 +329,70 @@ pub fn draw_left_column(allocator: std.mem.Allocator, actor: *Actor) !void { } } +fn draw_output_settings(allocator: std.mem.Allocator, actor: *Actor) !void { + c.ImGui_SeparatorText("Output"); + + const video_output_directory = blk: { + const settings_locked = actor.state.user_settings.settings.lock(); + defer settings_locked.unlock(); + const settings = settings_locked.unwrap_ptr(); + break :blk settings.video_output_directory.?.bytes; + }; + + c.ImGui_Text("Video"); + + var _video_output_directory_local = video_output_directory_local orelse blk: { + var buffer = std.mem.zeroes([VIDEO_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; + }; + + if (c.ImGui_BeginTable("video_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, VIDEO_OUTPUT_DIRECTORY_PICKER_BUTTON_WIDTH, 0); + + _ = c.ImGui_TableNextColumn(); + imgui_util.set_next_item_width_fill(); + _ = c.ImGui_InputText( + "##video_output_directory", + &_video_output_directory_local, + _video_output_directory_local.len, + c.ImGuiInputTextFlags_None, + ); + if (c.ImGui_IsItemEdited()) { + video_output_directory_local = _video_output_directory_local; + } + 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)) { + try actor.dispatch(.{ .user_settings = .{ + .set_video_output_directory = try .init(allocator, .{ + .video_output_directory = updated_directory, + }), + } }); + } + video_output_directory_local = null; + } else if (!c.ImGui_IsItemActive()) { + video_output_directory_local = null; + } + + _ = c.ImGui_TableNextColumn(); + if (c.ImGui_ButtonEx("...##video_output_directory_picker", .{ + .x = imgui_util.WIDTH_FILL, + .y = 0, + })) { + try actor.dispatch(.{ .user_settings = .select_output_directory }); + } + if (c.ImGui_BeginItemTooltip()) { + c.ImGui_TextUnformatted("Choose directory"); + c.ImGui_EndTooltip(); + } + } +} + fn draw_capture_settings(allocator: std.mem.Allocator, actor: *Actor) !void { c.ImGui_SeparatorText("Capture Settings"); @@ -339,7 +413,7 @@ fn draw_capture_settings(allocator: std.mem.Allocator, actor: *Actor) !void { c.ImGui_Text("Max FPS"); c.ImGui_SameLine(); imgui_util.help_marker("The maximum capture rate (frames per second). If your system can't keep up, it may be slower than the desired FPS."); - c.ImGui_SetNextItemWidth(-std.math.floatMin(f32)); + imgui_util.set_next_item_width_fill(); if (c.ImGui_InputIntEx( "##capture_fps", &fps, @@ -366,7 +440,7 @@ fn draw_capture_settings(allocator: std.mem.Allocator, actor: *Actor) !void { c.ImGui_Text("Bitrate"); c.ImGui_SameLine(); imgui_util.help_marker("Capture bitrate in Kbps"); - c.ImGui_SetNextItemWidth(-std.math.floatMin(f32)); + imgui_util.set_next_item_width_fill(); var capture_bit_rate = capture_bit_rate_local orelse current_capture_bit_rate; if (c.ImGui_InputIntEx( "##bitrate", @@ -400,7 +474,7 @@ fn draw_capture_settings(allocator: std.mem.Allocator, actor: *Actor) !void { c.ImGui_Text("Replay buffer length"); c.ImGui_SameLine(); imgui_util.help_marker("Length of video and audio stored in memory (seconds)"); - c.ImGui_SetNextItemWidth(-std.math.floatMin(f32)); + imgui_util.set_next_item_width_fill(); var replay_seconds = replay_seconds_local orelse current_replay_seconds; if (c.ImGui_InputIntEx( "##replay_buffer_length", diff --git a/src/ui/imgui_util.zig b/src/ui/imgui_util.zig index c2b38d0..81ca629 100644 --- a/src/ui/imgui_util.zig +++ b/src/ui/imgui_util.zig @@ -1,5 +1,8 @@ +const std = @import("std"); const imguiz = @import("imguiz").imguiz; +pub const WIDTH_FILL = -std.math.floatMin(f32); + pub fn help_marker(text: [*]const u8) void { imguiz.ImGui_TextDisabled("(?)"); if (imguiz.ImGui_BeginItemTooltip()) { @@ -9,3 +12,8 @@ pub fn help_marker(text: [*]const u8) void { imguiz.ImGui_EndTooltip(); } } + +/// Helper for `ImGui_SetNextItemWidth(-std.math.floatMin(f32))` +pub fn set_next_item_width_fill() void { + imguiz.ImGui_SetNextItemWidth(WIDTH_FILL); +} diff --git a/src/util.zig b/src/util.zig index 188ac1f..155027b 100644 --- a/src/util.zig +++ b/src/util.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const log = std.log.scoped(.util); pub const DEBUG = @import("builtin").mode == .Debug; @@ -122,39 +123,83 @@ pub fn check_fd(fd: i64) !void { /// The returned path is owned by the caller and must be freed. /// This function will create the directory if it does not exist. pub fn get_app_data_dir(allocator: std.mem.Allocator) ![]u8 { - // TODO: test on windows - const base_dir: []u8 = if (@import("builtin").os.tag == .windows) blk: { + // TODO: Test on Windows. + const base_dir: []u8 = if (comptime is_windows()) blk: { // On Windows, use %APPDATA% break :blk try std.process.getEnvVarOwned(allocator, "APPDATA"); - } else if (@import("builtin").os.tag == .linux) blk: { + } else if (comptime is_linux()) blk: { // On Linux, use $XDG_CONFIG_HOME or $HOME/.config if (std.process.getEnvVarOwned(allocator, "XDG_CONFIG_HOME")) |xdg_config_home| { - break :blk xdg_config_home; + if (xdg_config_home.len > 0 and std.fs.path.isAbsolute(xdg_config_home)) { + break :blk xdg_config_home; + } + allocator.free(xdg_config_home); } else |err| switch (err) { - error.EnvironmentVariableNotFound => { - const home = try std.process.getEnvVarOwned(allocator, "HOME"); - defer allocator.free(home); - break :blk try std.fs.path.join(allocator, &.{ home, ".config" }); - }, + error.EnvironmentVariableNotFound => {}, else => return err, } + + const home = try std.process.getEnvVarOwned(allocator, "HOME"); + defer allocator.free(home); + break :blk try std.fs.path.join(allocator, &.{ home, ".config" }); } else { @compileError("Unsupported OS"); }; defer allocator.free(base_dir); const app_config_dir = try std.fs.path.join(allocator, &.{ base_dir, "spacecap" }); + errdefer allocator.free(app_config_dir); - // Ensure the directory exists - std.fs.makeDirAbsolute(app_config_dir) catch |err| { - if (err != error.PathAlreadyExists) { - return err; - } - }; + try std.fs.cwd().makePath(app_config_dir); return app_config_dir; } +// 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) ![]u8 { + // TODO: Test on Windows. + const home_dir: ?[]u8 = if (comptime is_windows()) blk: { + break :blk std.process.getEnvVarOwned(allocator, "USERPROFILE") catch |err| { + log.err("[get_default_video_output_dir] failed to read USERPROFILE: {}", .{err}); + break :blk std.process.getEnvVarOwned(allocator, "HOME") catch |home_err| { + log.err("[get_default_video_output_dir] failed to read HOME: {}", .{home_err}); + break :blk null; + }; + }; + } else if (comptime is_linux()) blk: { + break :blk std.process.getEnvVarOwned(allocator, "HOME") catch |err| { + log.err("[get_default_video_output_dir] failed to read HOME: {}", .{err}); + break :blk null; + }; + } else { + @compileError("Unsupported OS"); + }; + + if (home_dir) |_home_dir| { + defer allocator.free(_home_dir); + + const output_dir = try std.fs.path.join(allocator, &.{ _home_dir, "Videos", "spacecap" }); + errdefer allocator.free(output_dir); + + if (std.fs.cwd().makePath(output_dir)) { + return output_dir; + } else |err| { + log.err("[get_default_video_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", .{}); + return std.process.getCwdAlloc(allocator) catch |err| { + log.err("[get_default_video_output_dir] failed to get current working directory: {}", .{err}); + return allocator.dupe(u8, "."); + }; +} + pub fn LinkedListIterator(comptime T: type) type { return struct { current: ?*@FieldType(T, "node"), diff --git a/src/video/muxer.zig b/src/video/muxer.zig index dc173fd..d19be68 100644 --- a/src/video/muxer.zig +++ b/src/video/muxer.zig @@ -37,11 +37,11 @@ pub const Muxer = struct { width: u32, height: u32, fps: u32, - file_name_prefix: []const u8, + output_directory: []const u8, ) !Self { var format_context: *ffmpeg.AVFormatContext = undefined; - // TODO: File name. - const file_name = try std.fmt.allocPrintSentinel(allocator, "{s}_{}.mp4", .{ file_name_prefix, std.time.nanoTimestamp() }, 0); + try std.fs.cwd().makePath(output_directory); + const file_name = try get_output_file_name(allocator, "replay", output_directory); errdefer allocator.free(file_name); var ret = ffmpeg.avformat_alloc_output_context2(@ptrCast(&format_context), null, "mp4", file_name); @@ -320,6 +320,19 @@ pub const Muxer = struct { } }; +fn get_output_file_name( + allocator: Allocator, + file_name_prefix: []const u8, + output_directory: []const u8, +) ![:0]u8 { + const base_name = try std.fmt.allocPrint(allocator, "{s}_{}.mp4", .{ file_name_prefix, std.time.nanoTimestamp() }); + 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 "applyJitterCorrectionToPts snaps small jitter to expected cadence" { const expected = 3_000; const previous = 2_000;