Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User-selectable capture/output resolution and FPS #164

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 196 additions & 61 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ tokio = { version = "1.39.3", features = [
] }
tauri = { version = "2.0.0" }
specta = { version = "=2.0.0-rc.20" }
scap = { git = "https://github.com/CapSoftware/scap", rev = "4d6be030ba2b0cea565ccaee28b1999f25b8dd5d" }
scap = { git = "https://github.com/CapSoftware/scap", rev = "b1e140a3fe90" }
nokhwa = { git = "https://github.com/CapSoftware/nokhwa", rev = "c5c7e2298764", features = [
"input-native",
"serialize",
Expand Down
3 changes: 0 additions & 3 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,6 @@ windows = { version = "0.52.0", features = [
"Win32_UI_WindowsAndMessaging",
"Win32_Graphics_Gdi",
] }
# Lock the version of windows-capture used by scap@691bd88798d3
# TODO: Remove this once scap uses the latest version of `windows` and `windows-capture`
windows-capture = "=1.2.0"

[target.'cfg(unix)'.dependencies]
nix = { version = "0.29.0", features = ["fs"] }
Expand Down
23 changes: 15 additions & 8 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use cap_editor::{EditorInstance, FRAMES_WS_PATH};
use cap_editor::{EditorState, ProjectRecordings};
use cap_media::sources::CaptureScreen;
use cap_media::{
feeds::{AudioData, AudioFrameBuffer, CameraFeed, CameraFrameSender},
feeds::{AudioFrameBuffer, CameraFeed, CameraFrameSender},
platform::Bounds,
sources::{AudioInputSource, ScreenCaptureTarget},
};
Expand All @@ -34,10 +34,10 @@ use cap_rendering::{ProjectUniforms, ZOOM_DURATION};
use general_settings::GeneralSettingsStore;
use image::{ImageBuffer, Rgba};
use mp4::Mp4Reader;
use num_traits::ToBytes;
use png::{ColorType, Encoder};
use recording::{
list_cameras, list_capture_screens, list_capture_windows, InProgressRecording, FPS,
list_cameras, list_capture_screens, list_capture_windows, InProgressRecording,
RecordingSettingsStore,
};
use scap::capturer::Capturer;
use scap::frame::Frame;
Expand Down Expand Up @@ -338,10 +338,13 @@ async fn start_recording(app: AppHandle, state: MutableState<'_, App>) -> Result
}
}

let recording_settings = RecordingSettingsStore::get(&app)?;

match recording::start(
id,
recording_dir,
&state.start_recording_options,
recording_settings,
state.camera_feed.as_ref(),
)
.await
Expand Down Expand Up @@ -969,6 +972,7 @@ async fn render_to_file_impl(
let audio = editor_instance.audio.clone();
let decoders = editor_instance.decoders.clone();
let options = editor_instance.render_constants.options.clone();
let screen_fps = editor_instance.recordings.display.fps;

let (tx_image_data, mut rx_image_data) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();

Expand Down Expand Up @@ -997,7 +1001,7 @@ async fn render_to_file_impl(
ffmpeg.add_input(cap_ffmpeg_cli::FFmpegRawVideoInput {
width: output_size.0,
height: output_size.1,
fps: 30,
fps: screen_fps,
pix_fmt: "rgba",
input: pipe_path.clone().into_os_string(),
});
Expand Down Expand Up @@ -1082,7 +1086,7 @@ async fn render_to_file_impl(

let audio_info = audio.buffer.info();
let estimated_samples_per_frame =
f64::from(audio_info.sample_rate) / f64::from(FPS);
f64::from(audio_info.sample_rate) / f64::from(screen_fps);
let samples = estimated_samples_per_frame.ceil() as usize;

if let Some((_, frame_data)) =
Expand Down Expand Up @@ -1158,6 +1162,7 @@ async fn render_to_file_impl(
decoders,
editor_instance.cursor.clone(),
editor_instance.project_path.clone(),
screen_fps,
)
.await?;

Expand Down Expand Up @@ -1629,10 +1634,9 @@ async fn render_to_file(
.await
.unwrap();

// 30 FPS (calculated for output video)
let total_frames = (duration * 30.0).round() as u32;

let editor_instance = upsert_editor_instance(&app, video_id.clone()).await;
let screen_fps = editor_instance.recordings.display.fps as f64;
let total_frames = (duration * screen_fps).round() as u32;

render_to_file_impl(
&editor_instance,
Expand Down Expand Up @@ -2459,6 +2463,7 @@ pub async fn run() {
check_upgraded_and_update,
open_external_link,
hotkeys::set_hotkey,
recording::set_recording_settings,
set_general_settings,
delete_auth_open_signin,
reset_camera_permissions,
Expand Down Expand Up @@ -2489,6 +2494,7 @@ pub async fn run() {
.typ::<AuthStore>()
.typ::<hotkeys::HotkeysStore>()
.typ::<general_settings::GeneralSettingsStore>()
.typ::<recording::RecordingSettingsStore>()
.typ::<cap_flags::Flags>();

#[cfg(debug_assertions)]
Expand Down Expand Up @@ -2528,6 +2534,7 @@ pub async fn run() {
specta_builder.mount_events(app);
hotkeys::init(app.handle());
general_settings::init(app.handle());
recording::init_settings(app.handle());

let app_handle = app.handle().clone();

Expand Down
161 changes: 131 additions & 30 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
@@ -1,32 +1,95 @@
use cap_flags::FLAGS;
use cap_media::{encoders::*, feeds::*, filters::*, pipeline::*, sources::*, MediaError};
use cap_project::{CursorClickEvent, CursorMoveEvent, RecordingMeta};
use serde::Serialize;
use cap_project::{CursorClickEvent, CursorMoveEvent, RecordingMeta, TargetFPS, TargetResolution};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::fs::File;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use std::path::PathBuf;
use tauri::{AppHandle, Manager, Wry};
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot;

use crate::cursor::spawn_cursor_recorder;
use crate::RecordingOptions;

// TODO: Hacky, please fix
pub const FPS: u32 = 30;
#[derive(Serialize, Deserialize, Type, Debug)]
pub struct RecordingSettingsStore {
pub use_hardware_acceleration: bool,
#[serde(default = "default_recording_resolution")]
pub capture_resolution: Option<TargetResolution>,
#[serde(default = "default_recording_resolution")]
pub output_resolution: Option<TargetResolution>,
pub recording_fps: TargetFPS,
}

fn default_recording_resolution() -> Option<TargetResolution> {
Some(TargetResolution::_1080p)
}

impl Default for RecordingSettingsStore {
fn default() -> Self {
Self {
use_hardware_acceleration: false,
capture_resolution: Some(TargetResolution::_1080p),
output_resolution: None,
recording_fps: TargetFPS::_30,
}
}
}

pub type RecordingSettingsState = Mutex<RecordingSettingsStore>;

pub fn init_settings(app: &AppHandle) {
println!("Initializing RecordingSettingsStore");
let store = RecordingSettingsStore::get(app)
.unwrap()
.unwrap_or_default();
app.manage(RecordingSettingsState::new(store));
println!("RecordingSettingsState managed");
}

impl RecordingSettingsStore {
pub fn get(app: &AppHandle<Wry>) -> Result<Option<Self>, String> {
let Some(Some(store)) = app.get_store("store").map(|s| s.get("recording_settings")) else {
return Ok(None);
};

serde_json::from_value(store).map_err(|e| e.to_string())
}

pub fn set(app: &AppHandle, settings: Self) -> Result<(), String> {
let Some(store) = app.get_store("store") else {
return Err("Store not found".to_string());
};

store.set("recording_settings", serde_json::json!(settings));
store.save().map_err(|e| e.to_string())
}
}

#[tauri::command(async)]
#[specta::specta]
pub fn set_recording_settings(
app: AppHandle,
settings: RecordingSettingsStore,
) -> Result<(), String> {
RecordingSettingsStore::set(&app, settings)
}

#[tauri::command(async)]
#[specta::specta]
pub fn list_capture_screens() -> Vec<CaptureScreen> {
ScreenCaptureSource::list_screens()
ScreenCaptureSource::<AVFrameCapture>::list_screens()
}

#[tauri::command(async)]
#[specta::specta]
pub fn list_capture_windows() -> Vec<CaptureWindow> {
ScreenCaptureSource::list_targets()
ScreenCaptureSource::<AVFrameCapture>::list_targets()
}

#[tauri::command(async)]
Expand Down Expand Up @@ -186,6 +249,7 @@ pub async fn start(
id: String,
recording_dir: PathBuf,
recording_options: &RecordingOptions,
recording_settings: Option<RecordingSettingsStore>,
camera_feed: Option<&CameraFeed>,
) -> Result<InProgressRecording, MediaError> {
let content_dir = recording_dir.join("content");
Expand All @@ -201,22 +265,53 @@ pub async fn start(
let mut audio_output_path = None;
let mut camera_output_path = None;

let screen_source =
ScreenCaptureSource::init(dbg!(&recording_options.capture_target), None, None);
let screen_config = screen_source.info();
let screen_bounds = screen_source.bounds;

let output_config = screen_config.scaled(1920, 30);
let screen_filter = VideoFilter::init("screen", screen_config, output_config)?;
let screen_encoder = H264Encoder::init(
"screen",
output_config,
Output::File(display_output_path.clone()),
)?;
pipeline_builder = pipeline_builder
.source("screen_capture", screen_source)
.pipe("screen_capture_filter", screen_filter)
.sink("screen_capture_encoder", screen_encoder);
let settings = recording_settings.unwrap_or_default();

if settings.use_hardware_acceleration && cfg!(target_os = "macos") {
let screen_source = ScreenCaptureSource::<CMSampleBufferCapture>::init(
dbg!(&recording_options.capture_target),
settings.recording_fps,
settings.capture_resolution,
);
let screen_config = screen_source.info();
let output_config = settings.output_resolution.map(|output| {
screen_config
.with_resolution(output.to_width())
.with_hardware_format()
});

let screen_encoder = cap_media::encoders::H264AVAssetWriterEncoder::init(
"screen",
output_config,
Output::File(display_output_path.clone()),
)?;
pipeline_builder = pipeline_builder
.source("screen_capture", screen_source)
.sink("screen_capture_encoder", screen_encoder);
} else {
let screen_source = ScreenCaptureSource::<AVFrameCapture>::init(
dbg!(&recording_options.capture_target),
settings.recording_fps,
None,
);
let screen_config = screen_source.info();

let mut output_config = screen_config.with_software_format();
if let Some(output_resolution) = settings.output_resolution {
output_config = output_config.with_resolution(output_resolution.to_width());
}
let screen_filter = VideoFilter::init("screen", screen_config, output_config)?;

let screen_encoder = H264Encoder::init(
"screen",
output_config,
Output::File(display_output_path.clone()),
)?;
pipeline_builder = pipeline_builder
.source("screen_capture", screen_source)
.pipe("screen_capture_filter", screen_filter)
.sink("screen_capture_encoder", screen_encoder);
}

if let Some(mic_source) = recording_options
.audio_input_name
Expand All @@ -226,7 +321,6 @@ pub async fn start(
let mic_config = mic_source.info();
audio_output_path = Some(content_dir.join("audio-input.mp3"));

// let mic_filter = AudioFilter::init("microphone", mic_config, "aresample=async=1:min_hard_comp=0.100000:first_pts=0")?;
let mic_encoder = MP3Encoder::init(
"microphone",
mic_config,
Expand All @@ -235,13 +329,18 @@ pub async fn start(

pipeline_builder = pipeline_builder
.source("microphone_capture", mic_source)
// .pipe("microphone_filter", mic_filter)
.sink("microphone_encoder", mic_encoder);
}

if let Some(camera_source) = CameraSource::init(camera_feed) {
let camera_config = camera_source.info();
let output_config = camera_config.scaled(1920, 30);
// TODO: I'm not sure if there's a point to scaling the camera capture to the same resolution
// as the display capture (since it will be scaled down anyway, but matching at least the frame
// rate here is easier than trying to sync different frame rates while editing/rendering).
// Also, use hardware filters maybe?
let output_config = camera_config
.with_software_format()
.with_fps(settings.recording_fps.to_raw());
camera_output_path = Some(content_dir.join("camera.mp4"));

let camera_filter = VideoFilter::init("camera", camera_config, output_config)?;
Expand All @@ -263,9 +362,11 @@ pub async fn start(
let stop_signal = Arc::new(AtomicBool::new(false));

// Initialize default values for cursor channels
let (mouse_moves, mouse_clicks) = if FLAGS.record_mouse {
spawn_cursor_recorder(stop_signal.clone(), screen_bounds, content_dir, cursors_dir)
} else {
let (mouse_moves, mouse_clicks) =
// if FLAGS.record_mouse
{
// spawn_cursor_recorder(stop_signal.clone(), screen_bounds, content_dir, cursors_dir)
// } else {
// Create dummy channels that will never receive data
let (move_tx, move_rx) = oneshot::channel();
let (click_tx, click_rx) = oneshot::channel();
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/components/SwitchButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export type SwitchButtonProps<T extends string> = {
name: T,
value: boolean,
disabled?: boolean,
onChange: (name: T, value: boolean) => void,
};

export function SwitchButton<T extends string>(props: SwitchButtonProps<T>) {
return (
<button
type="button"
role="switch"
aria-checked={props.value}
data-state={props.value ? "checked" : "unchecked"}
value = {props.value ? "on" : "off"}
disabled={!!props.disabled}
class={`peer inline-flex h-4 w-8 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 ${
props.value
? "bg-blue-400 border-blue-400"
: "bg-gray-300 border-gray-300"
}`}
onClick={() => props.onChange(props.name, !props.value)}
>
<span
data-state={props.value ? "checked" : "unchecked"}
class={`pointer-events-none block h-4 w-4 rounded-full bg-gray-50 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 border-2 ${
props.value
? "border-blue-400"
: "border-gray-300"
}`}
/>
</button>
);
}
Loading